Import code from internal repository (#1)

Import code from internal repository
This commit is contained in:
Łukasz Mierzwa
2017-03-23 16:58:04 -07:00
committed by GitHub
parent 42a6268135
commit e239fd05fd
126 changed files with 11959 additions and 0 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
Dockerfile
Makefile
unsee

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
unsee

75
.gitmodules vendored Normal file
View File

@@ -0,0 +1,75 @@
[submodule "vendor/github.com/DeanThompson/ginpprof"]
path = vendor/github.com/DeanThompson/ginpprof
url = https://github.com/DeanThompson/ginpprof
[submodule "vendor/github.com/Sirupsen/logrus"]
path = vendor/github.com/Sirupsen/logrus
url = https://github.com/Sirupsen/logrus
[submodule "vendor/github.com/asaskevich/govalidator"]
path = vendor/github.com/asaskevich/govalidator
url = https://github.com/asaskevich/govalidator
[submodule "vendor/github.com/cnf/structhash"]
path = vendor/github.com/cnf/structhash
url = https://github.com/cnf/structhash
[submodule "vendor/github.com/gin-gonic/gin"]
path = vendor/github.com/gin-gonic/gin
url = https://github.com/gin-gonic/gin
[submodule "vendor/github.com/golang/protobuf"]
path = vendor/github.com/golang/protobuf
url = https://github.com/golang/protobuf
[submodule "vendor/github.com/hansrodtang/randomcolor"]
path = vendor/github.com/hansrodtang/randomcolor
url = https://github.com/hansrodtang/randomcolor
[submodule "vendor/github.com/kelseyhightower/envconfig"]
path = vendor/github.com/kelseyhightower/envconfig
url = https://github.com/kelseyhightower/envconfig
[submodule "vendor/github.com/manucorporat/sse"]
path = vendor/github.com/manucorporat/sse
url = https://github.com/manucorporat/sse
[submodule "vendor/github.com/mattn/go-isatty"]
path = vendor/github.com/mattn/go-isatty
url = https://github.com/mattn/go-isatty
[submodule "vendor/github.com/patrickmn/go-cache"]
path = vendor/github.com/patrickmn/go-cache
url = https://github.com/patrickmn/go-cache
[submodule "vendor/golang.org/x/net"]
path = vendor/golang.org/x/net
url = https://go.googlesource.com/net
[submodule "vendor/gopkg.in/go-playground/validator.v8"]
path = vendor/gopkg.in/go-playground/validator.v8
url = https://gopkg.in/go-playground/validator.v8
[submodule "vendor/gopkg.in/yaml.v2"]
path = vendor/gopkg.in/yaml.v2
url = https://gopkg.in/yaml.v2
[submodule "vendor/github.com/certifi/gocertifi"]
path = vendor/github.com/certifi/gocertifi
url = https://github.com/certifi/gocertifi
[submodule "vendor/github.com/getsentry/raven-go"]
path = vendor/github.com/getsentry/raven-go
url = https://github.com/getsentry/raven-go
[submodule "vendor/github.com/gin-gonic/contrib"]
path = vendor/github.com/gin-gonic/contrib
url = https://github.com/gin-gonic/contrib
[submodule "vendor/github.com/beorn7/perks"]
path = vendor/github.com/beorn7/perks
url = https://github.com/beorn7/perks
[submodule "vendor/github.com/matttproud/golang_protobuf_extensions"]
path = vendor/github.com/matttproud/golang_protobuf_extensions
url = https://github.com/matttproud/golang_protobuf_extensions
[submodule "vendor/github.com/mcuadros/go-gin-prometheus"]
path = vendor/github.com/mcuadros/go-gin-prometheus
url = https://github.com/mcuadros/go-gin-prometheus
[submodule "vendor/github.com/prometheus/client_golang"]
path = vendor/github.com/prometheus/client_golang
url = https://github.com/prometheus/client_golang
[submodule "vendor/github.com/prometheus/client_model"]
path = vendor/github.com/prometheus/client_model
url = https://github.com/prometheus/client_model
[submodule "vendor/github.com/prometheus/common"]
path = vendor/github.com/prometheus/common
url = https://github.com/prometheus/common
[submodule "vendor/github.com/prometheus/procfs"]
path = vendor/github.com/prometheus/procfs
url = https://github.com/prometheus/procfs
[submodule "vendor/gopkg.in/jarcoal/httpmock.v1"]
path = vendor/gopkg.in/jarcoal/httpmock.v1
url = https://gopkg.in/jarcoal/httpmock.v1

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM golang:1.7.5-alpine3.5
ADD . /go/src/github.com/cloudflare/unsee
ARG VERSION
RUN go install \
-ldflags "-X main.version=${VERSION:-dev}" \
github.com/cloudflare/unsee
RUN mv /go/src/github.com/cloudflare/unsee/static \
/go/src/github.com/cloudflare/unsee/templates \
/go/ && \
rm -fr /go/src
CMD ["unsee"]

13
LICENSE.md Normal file
View File

@@ -0,0 +1,13 @@
Copyright (c) 2017, CloudFlare. All rights reserved.
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.

123
Makefile Normal file
View File

@@ -0,0 +1,123 @@
VERSION := $(shell git describe --tags --always --dirty='-dev')
.PHONY: build
build:
docker build --build-arg VERSION=$(VERSION) -t unsee:$(VERSION) .
ALERTMANAGER_URI := https://raw.githubusercontent.com/prymitive/alertmanager-demo-api/master
PORT := 8080
.PHONY: demo
demo: build
@docker rm -f unsee-dev || true
docker run \
--name unsee-dev \
-e ALERTMANAGER_URI=$(ALERTMANAGER_URI) \
-e PORT=$(PORT) \
-p $(PORT):$(PORT) \
unsee:$(VERSION)
.PHONY: dev
dev:
go build -v -ldflags "-X main.version=${VERSION:-dev} && \
DEBUG=true \
ALERTMANAGER_URI=$(ALERTMANAGER_URI) \
PORT=$(PORT) \
./unsee
.PHONY: lint
lint:
@golint ./... | (grep -v ^vendor/ || true)
.PHONY: test
test: lint
@go test -cover `go list ./... | grep -v /vendor/`
ASSETS_DIR := $(CURDIR)/static/assets
CDNJS_PREFIX := https://cdnjs.cloudflare.com/ajax/libs
%.js:
$(eval VERSION := $(word 2, $(subst /, ,$@)))
$(eval DIRNAME := $(shell dirname $@))
$(eval BASENAME := $(shell basename $@))
$(eval MAPPATH := $(@:.js=.map))
$(eval MAPFILE := $(shell basename $(MAPPATH)))
$(eval OUTPUT := $(ASSETS_DIR)/js/$(VERSION)-$(BASENAME))
@echo Fetching js asset $@
@mkdir -p $(ASSETS_DIR)/js
@curl --fail -so $(OUTPUT) $(CDNJS_PREFIX)/$@ || (rm -f $(OUTPUT) && exit 1)
@( \
export MAP=`grep sourceMappingURL $(OUTPUT) | cut -d = -f 2`; \
(test -n "$$MAP" && echo "+ Fetching js map $${MAP}" && (curl --fail -so $(ASSETS_DIR)/js/$${MAP} $(CDNJS_PREFIX)/$(DIRNAME)/$$MAP || rm -f $(ASSETS_DIR)/js/$${MAP})); \
(test -z "$$MAP" && echo "+ Fetching js map $(MAPPATH)" && (curl --fail -so $(ASSETS_DIR)/js/$(MAPFILE) $(CDNJS_PREFIX)/$(MAPPATH) || rm -f $(ASSETS_DIR)/js/$(MAPFILE)) || true); \
) || true
@echo $(VERSION)-$(shell basename $@) >> $(ASSETS_DIR)/js/assets.txt
%.css:
$(eval VERSION := $(word 2, $(subst /, ,$@)))
$(eval OUTPUT := $(ASSETS_DIR)/css/$(VERSION)-$(shell basename $@))
@echo Fetching css asset $@
@mkdir -p $(ASSETS_DIR)/css
@curl --fail -so $(OUTPUT) $(CDNJS_PREFIX)/$@ || (rm -f $(OUTPUT) && exit 1)
@echo $(VERSION)-$(shell basename $@) >> $(ASSETS_DIR)/css/assets.txt
font-awesome/4.7.0/fonts/%:
$(eval OUTPUT := $(ASSETS_DIR)/fonts/$(shell basename $@))
@echo Fetching fonts asset $@
@mkdir -p $(ASSETS_DIR)/fonts
@curl --fail -so $(OUTPUT) $(CDNJS_PREFIX)/$@ || (rm -f $(OUTPUT) && exit 1)
.PHONY: clean-assets
clean-assets:
@git rm -f $(ASSETS_DIR)/*/* >/dev/null 2>&1 || true
.PHONY: assets
assets: clean-assets
# jquery, for everything
assets: jquery/2.2.4/jquery.min.js
# moment, for timestamp parsing and printing
assets: moment.js/2.17.1/moment.min.js
# favico, for adding alert counter to the favico
assets: favico.js/0.3.10/favico.min.js
# fontawesome, for ui icons
assets: font-awesome/4.7.0/css/font-awesome.min.css
assets: font-awesome/4.7.0/fonts/fontawesome-webfont.eot
assets: font-awesome/4.7.0/fonts/fontawesome-webfont.svg
assets: font-awesome/4.7.0/fonts/fontawesome-webfont.ttf
assets: font-awesome/4.7.0/fonts/fontawesome-webfont.woff
assets: font-awesome/4.7.0/fonts/fontawesome-webfont.woff2
assets: font-awesome/4.7.0/fonts/FontAwesome.otf
# bootstrap & bootstrap switch, for ui
assets: twitter-bootstrap/3.3.7/js/bootstrap.min.js
assets: bootswatch/3.3.7/flatly/bootstrap.min.css
assets: bootstrap-switch/3.3.2/js/bootstrap-switch.min.js
assets: bootstrap-switch/3.3.2/css/bootstrap3/bootstrap-switch.min.css
# nprogress, for refresh progress bar
assets: nprogress/0.2.0/nprogress.min.js
assets: nprogress/0.2.0/nprogress.min.css
# tagsinput & typeahead, for filter bar and autocomplete
assets: bootstrap-tagsinput/0.8.0/bootstrap-tagsinput.min.js
assets: bootstrap-tagsinput/0.8.0/bootstrap-tagsinput.css
assets: typeahead.js/0.11.1/typeahead.bundle.min.js
assets: bootstrap-tagsinput/0.8.0/bootstrap-tagsinput-typeahead.css
# loaders.css, for animated spinners
assets: loaders.css/0.1.2/loaders.css.min.js
assets: loaders.css/0.1.2/loaders.min.css
# js-cookie, for preference state loading via cookies
assets: js-cookie/2.1.3/js.cookie.min.js
# underscore & haml, for template rendering in js
assets: underscore.js/1.8.3/underscore-min.js
assets: underscore.string/2.4.0/underscore.string.min.js
assets: clientside-haml-js/5.4/haml.js
# masonry, for grid layout
assets: masonry/4.1.1/masonry.pkgd.min.js
# copy to clipboard
assets: clipboard.js/1.5.16/clipboard.min.js
# sentry client
assets: raven.js/3.9.1/raven.min.js
# sha1 function used to generate id from string
assets: js-sha1/0.4.0/sha1.min.js
# allows selecting only visible elements
assets: is-in-viewport/2.4.2/isInViewport.min.js
assets:
@git add $(ASSETS_DIR)/*/*

170
README.md Normal file
View File

@@ -0,0 +1,170 @@
# unsee
Alert dashboard for [Prometheus Alertmanager](https://prometheus.io/docs/alerting/alertmanager/).
Alertmanager UI is useful for browsing alerts and managing silences, but it's
lacking as a dashboard tool - unsee aims to fill this gap.
It's developed as a dedicated tool as it's intended to provide only read access
to alert data, therefore safe to be accessed by wider audience.
## Building and running
### Build a Docker image
make build
This will build a Docker image from sources.
### Running the Docker image in demo mode
make demo
Will run locally build Docker image. This is intended for testing build Docker
images or checking unsee functionality.
By default unsee will listen on port `8080` and Alertmanager mock data will be
used, to override Alertmanager URI set `ALERTMANAGER_URI` and/or `PORT` make
variables. Example:
make PORT=5000 ALERTMANAGER_URI=https://alertmanager.unicorn.corp run
### Running in dev mode
Requires Go.
make dev
Will compile unsee and run the binary (not using Docker), by default will use
same port and Alertmanager URI as demo mode. This is intended for testing
changes.
### Environment variables
#### ALERTMANAGER_URI
URI of the Alertmanager instance, unsee will use it to pull alert groups and
silences. Endpoints in use:
* ${ALERTMANAGER_URI}/api/v1/alerts/groups
* ${ALERTMANAGER_URI}/api/v1/silences
This variable is required and there is no default value.
#### ALERTMANAGER_TIMEOUT
Timeout for requests send to Alertmanager, accepts values in
[time.Duration](https://golang.org/pkg/time/#Duration) format. Examples:
ALERTMANAGER_TIMEOUT=10s
ALERTMANAGER_TIMEOUT=2m
Default is `40s`.
#### DEBUG
Will enable [gin](https://github.com/gin-gonic/gin) debug mode. Examples:
DEBUG=true
DEBUG=false
Default is `false`.
#### COLOR_LABELS
List of label names that should have unique colors generated in the UI. Colors
can help visually identify alerts with shared labels, for example coloring
hostname label will allow to quickly spot all alerts for the same host.
Accepts space separated list of label names. Examples:
COLOR_LABELS=hostname
COLOR_LABELS="cluster environment rack"
This variable is optional and default is not set (no label will have unique
color).
#### DEFAULT_FILTER
Default alert filter to apply when user loads unsee UI without any filter
specified. Accepts comma separated list of filter expressions (visit /help page
in unsee for details on filters). Examples:
DEFAULT_FILTER=level=critical
DEFAULT_FILTER="cluster=prod,instance=~prod"
Default is not set (no filter will be applied).
#### JIRA_REGEX
This allows to define regex rules that will be applied to silence comments.
Regex rules will be used to discover JIRA issue IDs in the comment text and
inject links to those issues, instead of rendering as plain text.
Rule syntax:
$(regex)@$(jira url)
Accepts space separated list of rules. Examples:
JIRA_REGEX="DEVOPS-[0-9]+@https://jira.unicorn.corp
The above will match DEVOPS-123 text in the silence comment string and convert
it to `https://jira.unicorn.corp/browse/DEVOPS-123` link.
This variable is optional and default is not set (no rule will be applied).
#### UPDATE_INTERVAL
Interval for refreshing alerts and silences, tells unsee how often pull new
data from Alertmanager, accepts values in
[time.Duration](https://golang.org/pkg/time/#Duration) format. Examples:
UPDATE_INTERVAL=30s
UPDATE_INTERVAL=5m
Default is `1m`.
#### SENTRY_DSN
DSN for [Sentry](https://sentry.io) integration in Go. See
[Sentry documentation](https://docs.sentry.io/quickstart/#configure-the-dsn) for
details. Example:
SENTRY_DSN=https://<key>:<secret>@sentry.io/<project>
This variable is optional and default is not set (Sentry support is disabled for
Go errors).
#### SENTRY_PUBLIC_DSN
DSN for [Sentry](https://sentry.io) integration in javascript. See
[Sentry documentation](https://docs.sentry.io/clients/javascript/) for details.
Example:
SENTRY_PUBLIC_DSN=https://<key>@sentry.io/<project>
This variable is optional and default is not set (Sentry support is disabled for
javascript errors).
#### STATIC_COLOR_LABELS
List of label names that will all have the same color applied (different than
the default label color). This allows to quickly spot a specific label that
can have high range of values, but it's important when reading the dashboard.
For example coloring the instance label allows to quickly learn which instance
is affected by given alert. Accepts space separated list of label names.
Examples:
STATIC_COLOR_LABELS=instance
STATIC_COLOR_LABELS="instance cluster"
This variable is optional and default is not set (no label will have static
color).
#### STRIP_LABELS
List of label names that should not be shown on the UI. This allows to hide some
labels that are not needed on the alert dashboard. Accepts space separated list
of label names. Examples:
STRIP_LABELS=exporter_type
STRIP_LABELS="prometheus_instance alert_type"
This variable is optional and default is not set (all labels will be shown).

40
alertmanager/alerts.go Normal file
View File

@@ -0,0 +1,40 @@
package alertmanager
import (
"errors"
"time"
"github.com/cloudflare/unsee/config"
"github.com/cloudflare/unsee/models"
log "github.com/Sirupsen/logrus"
)
// AlertGroupsAPIResponse is the schema of API response for /api/v1/alerts/groups
type AlertGroupsAPIResponse struct {
Status string `json:"status"`
Groups []models.AlertManagerAlertGroup `json:"data"`
ErrorType string `json:"errorType"`
Error string `json:"error"`
}
// Get response from AlertManager /api/v1/alerts/groups
func (response *AlertGroupsAPIResponse) Get() error {
start := time.Now()
url, err := joinURL(config.Config.AlertManagerURL, "api/v1/alerts/groups")
if err != nil {
return err
}
err = getJSONFromURL(url, config.Config.AlertManagerTimeout, response)
if err != nil {
return err
}
if response.Status != "success" {
return errors.New(response.Error)
}
log.Infof("Got %d alert group(s) in %s", len(response.Groups), time.Since(start))
return nil
}

66
alertmanager/remote.go Normal file
View File

@@ -0,0 +1,66 @@
package alertmanager
import (
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"time"
log "github.com/Sirupsen/logrus"
)
// joinURL can be used to join a base url (http(s)://domain.com) and a path (/my/path)
// it will return a joined string or an error (if you supply invalid url)
func joinURL(base string, sub string) (string, error) {
u, err := url.Parse(base)
if err != nil {
return "", err
}
u.Path = path.Join(u.Path, sub)
return u.String(), nil
}
// getJSONFromURL is a helper function that takesan URL, request timeout
// and target structure, it will make a HTTP request and decode JSON response
// onto the structure provided
func getJSONFromURL(url string, timeout time.Duration, target interface{}) error {
log.Infof("GET %s", url)
c := &http.Client{
Timeout: timeout,
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Add("Accept-Encoding", "gzip")
resp, err := c.Do(req)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Request to AlertManager failed with %s", resp.Status)
}
defer resp.Body.Close()
var reader io.ReadCloser
switch resp.Header.Get("Content-Encoding") {
case "gzip":
reader, err = gzip.NewReader(resp.Body)
if err != nil {
return fmt.Errorf("Failed to decode gzipped content: %s", err.Error())
}
defer reader.Close()
default:
reader = resp.Body
}
return json.NewDecoder(reader).Decode(target)
}

48
alertmanager/silences.go Normal file
View File

@@ -0,0 +1,48 @@
package alertmanager
import (
"errors"
"fmt"
"math"
"time"
"github.com/cloudflare/unsee/config"
"github.com/cloudflare/unsee/models"
log "github.com/Sirupsen/logrus"
)
type silencesData struct {
Silences []models.AlertManagerSilence `json:"silences"`
TotalSilences int `json:"totalSilences"`
}
// SilenceAPIResponse is what AlertManager API returns
type SilenceAPIResponse struct {
Status string `json:"status"`
Data silencesData `json:"data"`
ErrorType string `json:"errorType"`
Error string `json:"error"`
}
// Get will return fresh data from AlertManager API
func (response *SilenceAPIResponse) Get() error {
start := time.Now()
url, err := joinURL(config.Config.AlertManagerURL, "api/v1/silences")
if err != nil {
return err
}
url = fmt.Sprintf("%s?limit=%d", url, math.MaxUint32)
err = getJSONFromURL(url, config.Config.AlertManagerTimeout, response)
if err != nil {
return err
}
if response.Status != "success" {
return errors.New(response.Error)
}
log.Infof("Got %d silences(s) in %s", len(response.Data.Silences), time.Since(start))
return nil
}

32
alerts.go Normal file
View File

@@ -0,0 +1,32 @@
package main
import (
"strings"
"github.com/cloudflare/unsee/filters"
"github.com/cloudflare/unsee/models"
)
func getFiltersFromQuery(filterString string) ([]filters.FilterT, bool) {
validFilters := false
matchFilters := []filters.FilterT{}
qList := strings.Split(filterString, ",")
for _, filterExpression := range qList {
f := filters.NewFilter(filterExpression)
if f.GetIsValid() {
validFilters = true
}
matchFilters = append(matchFilters, f)
}
return matchFilters, validFilters
}
func countLabel(countStore models.UnseeCountMap, key string, val string) {
if _, found := countStore[key]; !found {
countStore[key] = make(map[string]int)
}
if _, found := countStore[key][val]; found {
countStore[key][val]++
} else {
countStore[key][val] = 1
}
}

35
assets.go Normal file
View File

@@ -0,0 +1,35 @@
package main
import (
"fmt"
"io/ioutil"
"strings"
log "github.com/Sirupsen/logrus"
)
// ReadAssets will read assets.txt file in given directory and return a list
// of file names in that file
// assets.txt contains a list of external js of css files that are mirrored
// in static/assets directory that should be loaded in the browser
// this way we don't have to maintain this list in the Makefile that does
// the mirroring and in the template
func ReadAssets(kind string) []string {
filename := fmt.Sprintf("./static/assets/%s/assets.txt", kind)
content, err := ioutil.ReadFile(filename)
if err != nil {
log.Error(err.Error())
return []string{}
}
lines := strings.Split(string(content), "\n")
ret := []string{}
for _, l := range lines {
if l != "" {
ret = append(ret, l)
}
}
return ret
}

50
config/config.go Normal file
View File

@@ -0,0 +1,50 @@
package config
import (
"reflect"
"strings"
"time"
log "github.com/Sirupsen/logrus"
"github.com/kelseyhightower/envconfig"
)
type spaceSeparatedList []string
func (mvd *spaceSeparatedList) Decode(value string) error {
*mvd = spaceSeparatedList(strings.Split(value, " "))
return nil
}
type configEnvs struct {
Debug bool `envconfig:"DEBUG" default:"false"`
AlertManagerURL string `envconfig:"ALERTMANAGER_URI" required:"true"`
AlertManagerTimeout time.Duration `envconfig:"ALERTMANAGER_TIMEOUT" default:"40s"`
UpdateInterval time.Duration `envconfig:"UPDATE_INTERVAL" default:"1m"`
SentryDSN string `envconfig:"SENTRY_DSN"`
SentryPublicDSN string `envconfig:"SENTRY_PUBLIC_DSN"`
DefaultFilter string `envconfig:"DEFAULT_FILTER"`
ColorLabels spaceSeparatedList `envconfig:"COLOR_LABELS"`
StaticColorLabels spaceSeparatedList `envconfig:"STATIC_COLOR_LABELS"`
StripLabels spaceSeparatedList `envconfig:"STRIP_LABELS"`
JIRARegexp spaceSeparatedList `envconfig:"JIRA_REGEX"`
}
// Config exposes all options required to run
var Config configEnvs
//
func (config *configEnvs) Read() {
err := envconfig.Process("", config)
if err != nil {
log.Fatal(err.Error())
}
s := reflect.ValueOf(config).Elem()
typeOfT := s.Type()
for i := 0; i < s.NumField(); i++ {
f := s.Field(i)
log.Infof("%20s => %v", typeOfT.Field(i).Tag.Get("envconfig"), f.Interface())
}
}

16
filters/autocomplete.go Normal file
View File

@@ -0,0 +1,16 @@
package filters
import (
"github.com/cloudflare/unsee/models"
)
type autocompleteFactory func(name string, operators []string, alerts []models.UnseeAlert) []models.UnseeAutocomplete
func makeAC(value string, tokens []string) models.UnseeAutocomplete {
acHint := models.UnseeAutocomplete{
Value: value,
Tokens: tokens,
}
acHint.Tokens = append(acHint.Tokens, value)
return acHint
}

99
filters/filter.go Normal file
View File

@@ -0,0 +1,99 @@
package filters
import (
"fmt"
"regexp"
"strings"
"github.com/cloudflare/unsee/models"
)
// FilterT provides methods for interacting with alert filters
type FilterT interface {
init(name string, matcher *matcherT, rawText string, isValid bool, value string)
Match(alert *models.UnseeAlert, matches int) bool
GetRawText() string
GetHits() int
GetIsValid() bool
}
type alertFilter struct {
FilterT
Matched string
Matcher matcherT
RawText string
Value interface{}
IsValid bool
Hits int
}
func (filter *alertFilter) init(name string, matcher *matcherT, rawText string, isValid bool, value string) {
filter.Matched = name
if matcher != nil {
filter.Matcher = *matcher
}
filter.RawText = rawText
filter.IsValid = isValid
filter.Value = value
}
func (filter *alertFilter) GetRawText() string {
return filter.RawText
}
func (filter *alertFilter) GetHits() int {
return filter.Hits
}
func (filter *alertFilter) GetIsValid() bool {
return filter.IsValid
}
type newFilterFactory func() FilterT
// NewFilter creates new filter object from filter expression like "key=value"
// expression will be parsed and best filter implementation and value matcher
// will be selected
func NewFilter(expression string) FilterT {
for _, fc := range AllFilters {
reOperators := strings.Join(fc.SupportedOperators, "|")
reExp := fmt.Sprintf("^(?P<matched>(%s))(?P<operator>(%s))(?P<value>(.+))", fc.Label, reOperators)
re := regexp.MustCompile(reExp)
match := re.FindStringSubmatch(expression)
result := make(map[string]string)
for i, name := range re.SubexpNames() {
if name != "" && i > 0 && i <= len(match) {
result[name] = match[i]
}
}
if matched, found := result["matched"]; found {
f := fc.Factory()
if fc.IsSimple {
matcher, err := newMatcher(regexpOperator)
if err != nil {
f.init("", nil, expression, true, expression)
} else {
f.init("", &matcher, expression, true, expression)
}
return f
} else if operator, found := result["operator"]; found {
var err error
matcher, err := newMatcher(operator)
if err != nil {
f.init(matched, nil, expression, false, "")
} else {
if value, found := result["value"]; found {
f.init(matched, &matcher, expression, true, value)
return f
}
f.init(matched, &matcher, expression, false, "")
}
}
}
}
f := alwaysInvalidFilter{}
f.init("", nil, expression, false, expression)
return &f
}

72
filters/filter_age.go Normal file
View File

@@ -0,0 +1,72 @@
package filters
import (
"fmt"
"strings"
"time"
"github.com/cloudflare/unsee/models"
)
type ageFilter struct {
alertFilter
}
func (filter *ageFilter) init(name string, matcher *matcherT, rawText string, isValid bool, value string) {
filter.Matched = name
if matcher != nil {
filter.Matcher = *matcher
}
filter.RawText = rawText
filter.IsValid = isValid
dur, err := time.ParseDuration(value)
if err != nil {
filter.IsValid = false
}
if dur > 0 {
filter.Value = -dur
} else {
filter.Value = dur
}
}
func (filter *ageFilter) Match(alert *models.UnseeAlert, matches int) bool {
if filter.IsValid {
ts := time.Now().Add(filter.Value.(time.Duration))
isMatch := filter.Matcher.Compare(int(ts.Unix()), int(alert.StartsAt.Unix()))
if isMatch {
filter.Hits++
}
return isMatch
}
e := fmt.Sprintf("Match() called on invalid filter %#v", filter)
panic(e)
}
func newAgeFilter() FilterT {
f := ageFilter{}
return &f
}
func ageAutocomplete(name string, operators []string, alerts []models.UnseeAlert) []models.UnseeAutocomplete {
tokens := []models.UnseeAutocomplete{}
for _, operator := range operators {
tokens = append(tokens, makeAC(
fmt.Sprintf("%s%s10m", name, operator),
[]string{
name,
strings.TrimPrefix(name, "@"),
fmt.Sprintf("%s%s", name, operator),
},
))
tokens = append(tokens, makeAC(
fmt.Sprintf("%s%s1h", name, operator),
[]string{
name,
strings.TrimPrefix(name, "@"),
fmt.Sprintf("%s%s", name, operator),
},
))
}
return tokens
}

63
filters/filter_fuzzy.go Normal file
View File

@@ -0,0 +1,63 @@
package filters
import (
"fmt"
"regexp"
"strconv"
"github.com/cloudflare/unsee/models"
"github.com/cloudflare/unsee/store"
)
type fuzzyFilter struct {
alertFilter
}
func (filter *fuzzyFilter) init(name string, matcher *matcherT, rawText string, isValid bool, value string) {
filter.Matched = name
if matcher != nil {
filter.Matcher = *matcher
}
filter.RawText = rawText
filter.IsValid = isValid
filter.Value = value
if _, err := regexp.Compile(value); err != nil {
filter.IsValid = false
}
}
func (filter *fuzzyFilter) Match(alert *models.UnseeAlert, matches int) bool {
if filter.IsValid {
for _, val := range alert.Annotations {
if filter.Matcher.Compare(val, filter.Value) {
filter.Hits++
return true
}
}
for _, val := range alert.Labels {
if filter.Matcher.Compare(val, filter.Value) {
filter.Hits++
return true
}
}
if alert.Silenced > 0 {
if silence, found := store.SilenceStore.Store[strconv.Itoa(alert.Silenced)]; found {
if filter.Matcher.Compare(silence.Comment, filter.Value) {
filter.Hits++
return true
}
}
}
return false
}
e := fmt.Sprintf("Match() called on invalid filter %#v", filter)
panic(e)
}
func newFuzzyFilter() FilterT {
f := fuzzyFilter{}
return &f
}

10
filters/filter_invalid.go Normal file
View File

@@ -0,0 +1,10 @@
package filters
type alwaysInvalidFilter struct {
alertFilter
}
func (filter *alwaysInvalidFilter) init(name string, matcher *matcherT, rawText string, isValid bool, value string) {
filter.Matched = name
filter.RawText = rawText
}

84
filters/filter_label.go Normal file
View File

@@ -0,0 +1,84 @@
package filters
import (
"fmt"
"strconv"
"strings"
"github.com/cloudflare/unsee/models"
)
type labelFilter struct {
alertFilter
}
func (filter *labelFilter) Match(alert *models.UnseeAlert, matches int) bool {
if filter.IsValid {
isMatch := filter.Matcher.Compare(alert.Labels[filter.Matched], filter.Value)
if isMatch {
filter.Hits++
}
return isMatch
}
e := fmt.Sprintf("Match() called on invalid filter %#v", filter)
panic(e)
}
func newLabelFilter() FilterT {
f := labelFilter{}
return &f
}
func labelAutocomplete(name string, operators []string, alerts []models.UnseeAlert) []models.UnseeAutocomplete {
tokens := map[string]models.UnseeAutocomplete{}
for _, alert := range alerts {
for key, value := range alert.Labels {
for _, operator := range operators {
switch operator {
case equalOperator, notEqualOperator:
token := fmt.Sprintf("%s%s%s", key, operator, value)
tokens[token] = makeAC(
token,
[]string{
key,
fmt.Sprintf("%s%s", key, operator),
value,
},
)
case regexpOperator, negativeRegexOperator:
substrings := strings.Split(value, " ")
if len(substrings) > 1 {
for _, substring := range substrings {
token := fmt.Sprintf("%s%s%s", key, operator, substring)
tokens[token] = makeAC(
token,
[]string{
key,
fmt.Sprintf("%s%s", key, operator),
value,
substring,
},
)
}
}
case moreThanOperator, lessThanOperator:
if _, err := strconv.Atoi(value); err == nil {
token := fmt.Sprintf("%s%s%s", key, operator, value)
tokens[token] = makeAC(
token,
[]string{
key,
fmt.Sprintf("%s%s", key, operator),
value,
},
)
}
}
}
}
}
acData := []models.UnseeAutocomplete{}
for _, token := range tokens {
acData = append(acData, token)
}
return acData
}

69
filters/filter_limit.go Normal file
View File

@@ -0,0 +1,69 @@
package filters
import (
"fmt"
"strconv"
"strings"
"github.com/cloudflare/unsee/models"
)
type limitFilter struct {
alertFilter
}
func (filter *limitFilter) init(name string, matcher *matcherT, rawText string, isValid bool, value string) {
filter.Matched = name
if matcher != nil {
filter.Matcher = *matcher
}
filter.RawText = rawText
filter.IsValid = isValid
if filter.IsValid {
val, err := strconv.Atoi(value)
if err != nil || val < 1 {
filter.IsValid = false
} else {
filter.Value = val
}
}
}
func (filter *limitFilter) Match(alert *models.UnseeAlert, matches int) bool {
if filter.IsValid {
if matches < filter.Value.(int) {
return true
}
filter.Hits++
return false
}
e := fmt.Sprintf("Match() called on invalid filter %#v", filter)
panic(e)
}
func newLimitFilter() FilterT {
f := limitFilter{}
return &f
}
func limitAutocomplete(name string, operators []string, alerts []models.UnseeAlert) []models.UnseeAutocomplete {
tokens := []models.UnseeAutocomplete{}
for _, operator := range operators {
tokens = append(tokens, makeAC(
fmt.Sprintf("%s%s10", name, operator),
[]string{
name,
strings.TrimPrefix(name, "@"),
fmt.Sprintf("%s%s", name, operator),
},
))
tokens = append(tokens, makeAC(
fmt.Sprintf("%s%s50", name, operator),
[]string{
name,
strings.TrimPrefix(name, "@"),
fmt.Sprintf("%s%s", name, operator),
},
))
}
return tokens
}

View File

@@ -0,0 +1,65 @@
package filters
import (
"fmt"
"strconv"
"strings"
"github.com/cloudflare/unsee/models"
"github.com/cloudflare/unsee/store"
)
type silenceAuthorFilter struct {
alertFilter
}
func (filter *silenceAuthorFilter) Match(alert *models.UnseeAlert, matches int) bool {
if filter.IsValid {
var isMatch bool
if alert.Silenced > 0 {
store.StoreLock.RLock()
if silence, found := store.SilenceStore.Store[strconv.Itoa(alert.Silenced)]; found {
isMatch = filter.Matcher.Compare(filter.Value, silence.CreatedBy)
}
store.StoreLock.RUnlock()
} else {
isMatch = filter.Matcher.Compare("", filter.Value)
}
if isMatch {
filter.Hits++
}
return isMatch
}
e := fmt.Sprintf("Match() called on invalid filter %#v", filter)
panic(e)
}
func newSilenceAuthorFilter() FilterT {
f := silenceAuthorFilter{}
return &f
}
func sinceAuthorAutocomplete(name string, operators []string, alerts []models.UnseeAlert) []models.UnseeAutocomplete {
tokens := map[string]models.UnseeAutocomplete{}
for _, alert := range alerts {
if alert.Silenced > 0 {
store.StoreLock.RLock()
if silence, found := store.SilenceStore.Store[strconv.Itoa(alert.Silenced)]; found && silence.CreatedBy != "" {
for _, operator := range operators {
token := fmt.Sprintf("%s%s%s", name, operator, silence.CreatedBy)
tokens[token] = makeAC(token, []string{
name,
strings.TrimPrefix(name, "@"),
fmt.Sprintf("%s%s", name, operator),
silence.CreatedBy,
})
}
}
store.StoreLock.RUnlock()
}
}
acData := []models.UnseeAutocomplete{}
for _, token := range tokens {
acData = append(acData, token)
}
return acData
}

View File

@@ -0,0 +1,65 @@
package filters
import (
"fmt"
"strconv"
"strings"
"github.com/cloudflare/unsee/models"
"github.com/cloudflare/unsee/store"
)
type silenceJiraFilter struct {
alertFilter
}
func (filter *silenceJiraFilter) Match(alert *models.UnseeAlert, matches int) bool {
if filter.IsValid {
var isMatch bool
if alert.Silenced > 0 {
store.StoreLock.RLock()
if silence, found := store.SilenceStore.Store[strconv.Itoa(alert.Silenced)]; found {
isMatch = filter.Matcher.Compare(silence.JiraID, filter.Value)
}
store.StoreLock.RUnlock()
} else {
isMatch = filter.Matcher.Compare("", filter.Value)
}
if isMatch {
filter.Hits++
}
return isMatch
}
e := fmt.Sprintf("Match() called on invalid filter %#v", filter)
panic(e)
}
func newSilenceJiraFilter() FilterT {
f := silenceJiraFilter{}
return &f
}
func sinceJiraIDAutocomplete(name string, operators []string, alerts []models.UnseeAlert) []models.UnseeAutocomplete {
tokens := map[string]models.UnseeAutocomplete{}
for _, alert := range alerts {
if alert.Silenced > 0 {
store.StoreLock.RLock()
if silence, found := store.SilenceStore.Store[strconv.Itoa(alert.Silenced)]; found && silence.JiraID != "" {
for _, operator := range operators {
token := fmt.Sprintf("%s%s%s", name, operator, silence.JiraID)
tokens[token] = makeAC(token, []string{
name,
strings.TrimPrefix(name, "@"),
fmt.Sprintf("%s%s", name, operator),
silence.JiraID,
})
}
}
store.StoreLock.RUnlock()
}
}
acData := []models.UnseeAutocomplete{}
for _, token := range tokens {
acData = append(acData, token)
}
return acData
}

View File

@@ -0,0 +1,73 @@
package filters
import (
"fmt"
"strings"
"github.com/cloudflare/unsee/models"
)
type silencedFilter struct {
alertFilter
}
func (filter *silencedFilter) init(name string, matcher *matcherT, rawText string, isValid bool, value string) {
filter.Matched = name
if matcher != nil {
filter.Matcher = *matcher
}
filter.RawText = rawText
filter.IsValid = isValid
switch value {
case "true":
filter.Value = true
case "false":
filter.Value = false
default:
filter.IsValid = false
}
}
func (filter *silencedFilter) Match(alert *models.UnseeAlert, matches int) bool {
if filter.IsValid {
var isSilenced bool
isSilenced = (alert.Silenced > 0)
isMatch := filter.Matcher.Compare(isSilenced, filter.Value)
if isMatch {
filter.Hits++
}
return isMatch
}
e := fmt.Sprintf("Match() called on invalid filter %#v", filter)
panic(e)
}
func newSilencedFilter() FilterT {
f := silencedFilter{}
return &f
}
func silencedAutocomplete(name string, operators []string, alerts []models.UnseeAlert) []models.UnseeAutocomplete {
tokens := []models.UnseeAutocomplete{}
for _, operator := range operators {
switch operator {
case equalOperator:
tokens = append(tokens, makeAC(
fmt.Sprintf("%s%strue", name, operator),
[]string{
name,
strings.TrimPrefix(name, "@"),
fmt.Sprintf("%s%s", name, operator),
},
))
tokens = append(tokens, makeAC(
fmt.Sprintf("%s%sfalse", name, operator),
[]string{
name,
strings.TrimPrefix(name, "@"),
fmt.Sprintf("%s%s", name, operator),
},
))
}
}
return tokens
}

385
filters/filter_test.go Normal file
View File

@@ -0,0 +1,385 @@
package filters_test
import (
"encoding/json"
"strconv"
"testing"
"time"
"github.com/cloudflare/unsee/filters"
"github.com/cloudflare/unsee/models"
"github.com/cloudflare/unsee/store"
)
type filterTest struct {
Expression string
IsValid bool
Alert models.UnseeAlert
Silence models.UnseeSilence
IsMatch bool
}
var tests = []filterTest{
filterTest{
Expression: "@silenced=true",
IsValid: true,
Alert: models.UnseeAlert{},
IsMatch: false,
},
filterTest{
Expression: "@silenced!=true",
IsValid: true,
Alert: models.UnseeAlert{},
IsMatch: true,
},
filterTest{
Expression: "@silenced=true",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
IsMatch: true,
},
filterTest{
Expression: "@silenced!=true",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
IsMatch: false,
},
filterTest{
Expression: "@silenced=xx",
IsValid: false,
},
filterTest{
Expression: "@silence_jira=1",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1}, JiraID: "1"},
IsMatch: true,
},
filterTest{
Expression: "@silence_jira=2",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1}},
IsMatch: false,
},
filterTest{
Expression: "@silence_jira!=3",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1}, JiraID: "x"},
IsMatch: true,
},
filterTest{
Expression: "@silence_jira!=4",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1}, JiraID: "4"},
IsMatch: false,
},
filterTest{
Expression: "@silence_jira!=5",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1}},
IsMatch: true,
},
filterTest{
Expression: "@silence_jira=~abc",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1}, JiraID: "xxabcxx"},
IsMatch: true,
},
filterTest{
Expression: "@silence_jira=~abc",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1}, JiraID: "xxx"},
IsMatch: false,
},
filterTest{
Expression: "@silence_author=john",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1, CreatedBy: "john"}},
IsMatch: true,
},
filterTest{
Expression: "@silence_author=john",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1, CreatedBy: "bob"}},
IsMatch: false,
},
filterTest{
Expression: "@silence_author!=john",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1, CreatedBy: "bob"}},
IsMatch: true,
},
filterTest{
Expression: "@silence_author!=john",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1, CreatedBy: "john"}},
IsMatch: false,
},
filterTest{
Expression: "@silence_author!=john",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1}},
IsMatch: true,
},
filterTest{
Expression: "@age<1h",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{StartsAt: time.Now().Add(time.Minute * -55)}},
IsMatch: true,
},
filterTest{
Expression: "@age>1h",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{StartsAt: time.Now().Add(time.Hour * -2)}},
IsMatch: true,
},
filterTest{
Expression: "@age<-1h",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{StartsAt: time.Now().Add(time.Minute * -55)}},
IsMatch: true,
},
filterTest{
Expression: "@age>-1h",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{StartsAt: time.Now().Add(time.Hour * -2)}},
IsMatch: true,
},
filterTest{
Expression: "node=vps1",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Labels: map[string]string{"node": "vps1"}}},
IsMatch: true,
},
filterTest{
Expression: "node=vps1",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{}},
IsMatch: false,
},
filterTest{
Expression: "node!=vps1",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Labels: map[string]string{"node": "vps1"}}},
IsMatch: false,
},
filterTest{
Expression: "node!=vps1",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Labels: map[string]string{"node": "vps2"}}},
IsMatch: true,
},
filterTest{
Expression: "node=~vps",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Labels: map[string]string{"node": "vps1"}}},
IsMatch: true,
},
filterTest{
Expression: "node!~vps",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Labels: map[string]string{"node": "vps1"}}},
IsMatch: false,
},
filterTest{
Expression: "node!~abc",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Labels: map[string]string{"node": "vps1"}}},
IsMatch: true,
},
filterTest{
Expression: "abc",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Labels: map[string]string{"key": "abc"}}},
IsMatch: true,
},
filterTest{
Expression: "abc",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Labels: map[string]string{"key": "XXXabcx"}}},
IsMatch: true,
},
filterTest{
Expression: "abc",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Labels: map[string]string{"abc": "xxxab"}}},
IsMatch: false,
},
filterTest{
Expression: "abc",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Annotations: map[string]string{"key": "abc"}}},
IsMatch: true,
},
filterTest{
Expression: "abc",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Annotations: map[string]string{"key": "ccc abc"}}},
IsMatch: true,
},
filterTest{
Expression: "abc",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Annotations: map[string]string{"abc": "zzz"}}},
IsMatch: false,
},
filterTest{
Expression: "abc",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1, Comment: "abc"}},
IsMatch: true,
},
filterTest{
Expression: "abc",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1, Comment: "abcxxx"}},
IsMatch: true,
},
filterTest{
Expression: "abc",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1, Comment: "ABCD"}},
IsMatch: true,
},
filterTest{
Expression: "abc",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{Silenced: 1}},
Silence: models.UnseeSilence{AlertManagerSilence: models.AlertManagerSilence{ID: 1, Comment: "xzc"}},
IsMatch: false,
},
filterTest{
Expression: "abc",
IsValid: true,
Alert: models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{}},
IsMatch: false,
},
filterTest{
Expression: "^abb[****].*****",
IsValid: false,
},
}
func TestFilters(t *testing.T) {
for _, ft := range tests {
if &ft.Silence != nil {
store.SilenceStore.Store = map[string]models.UnseeSilence{}
store.SilenceStore.Store[strconv.Itoa(ft.Silence.ID)] = ft.Silence
} else {
store.SilenceStore.Store = map[string]models.UnseeSilence{}
}
f := filters.NewFilter(ft.Expression)
if f == nil {
t.Errorf("[%s] No filter found", ft.Expression)
}
if f.GetHits() != 0 {
t.Errorf("[%s] Hits = %#v after init(), expected 0", ft.Expression, f.GetHits())
}
if f.GetIsValid() != ft.IsValid {
t.Errorf("[%s] GetIsValid() returned %#v while %#v was expected", ft.Expression, f.GetIsValid(), ft.IsValid)
}
if f.GetIsValid() {
m := f.Match(&ft.Alert, 0)
if m != ft.IsMatch {
j, _ := json.Marshal(ft.Alert)
s, _ := json.Marshal(ft.Silence)
t.Errorf("[%s] Match() returned %#v while %#v was expected\nalert used: %s\nsilence used: %s", ft.Expression, m, ft.IsMatch, j, s)
}
if ft.IsMatch && f.GetHits() != 1 {
t.Errorf("[%s] GetHits() returned %#v after match, expected 1", ft.Expression, f.GetHits())
}
if !ft.IsMatch && f.GetHits() != 0 {
t.Errorf("[%s] GetHits() returned %#v after non-match, expected 0", ft.Expression, f.GetHits())
}
if f.GetRawText() != ft.Expression {
t.Errorf("[%s] GetRawText() returned %#v != %s passed as the expression", ft.Expression, f.GetRawText(), ft.Expression)
}
}
}
}
type limitFilterTest struct {
Expression string
IsValid bool
IsMatch []bool
Hits int
}
var limitTests = []limitFilterTest{
limitFilterTest{
Expression: "@limit=3",
IsValid: true,
IsMatch: []bool{true, true, true},
Hits: 0,
},
limitFilterTest{
Expression: "@limit=1",
IsValid: true,
IsMatch: []bool{true, false, false},
Hits: 2,
},
limitFilterTest{
Expression: "@limit=5",
IsValid: true,
IsMatch: []bool{true, true, true, true, true, false, false, false},
Hits: 3,
},
limitFilterTest{
Expression: "@limit=0",
IsValid: false,
},
limitFilterTest{
Expression: "@limit=abc",
IsValid: false,
},
}
func TestLimitFilter(t *testing.T) {
for _, ft := range limitTests {
f := filters.NewFilter(ft.Expression)
if f == nil {
t.Errorf("[%s] No filter found", ft.Expression)
}
if f.GetHits() != 0 {
t.Errorf("[%s] Hits = %#v after init(), expected 0", ft.Expression, f.GetHits())
}
if f.GetIsValid() != ft.IsValid {
t.Errorf("[%s] GetIsValid() returned %#v while %#v was expected", ft.Expression, f.GetIsValid(), ft.IsValid)
}
if f.GetIsValid() {
alert := models.UnseeAlert{AlertManagerAlert: models.AlertManagerAlert{}}
var index int = 0
for _, isMatch := range ft.IsMatch {
m := f.Match(&alert, index)
if m != isMatch {
t.Errorf("[%s] Match() returned %#v while %#v was expected, index %d", ft.Expression, m, isMatch, index)
}
if f.GetRawText() != ft.Expression {
t.Errorf("[%s] GetRawText() returned %#v != %s passed as the expression", ft.Expression, f.GetRawText(), ft.Expression)
}
index++
}
if f.GetHits() != ft.Hits {
t.Errorf("[%s] GetHits() returned %#v hits, expected %d", ft.Expression, f.GetHits(), ft.Hits)
}
}
}
}

131
filters/matcher.go Normal file
View File

@@ -0,0 +1,131 @@
package filters
import (
"errors"
"fmt"
"regexp"
"strconv"
"time"
cache "github.com/patrickmn/go-cache"
)
var matchCache = cache.New(5*time.Minute, 1*time.Minute)
type matcherT interface {
setOperator(operator string)
GetOperator() string
Compare(valA, valB interface{}) bool
}
type abstractMatcher struct {
matcherT
Operator string
}
func (matcher *abstractMatcher) setOperator(operator string) {
matcher.Operator = operator
}
func (matcher *abstractMatcher) GetOperator() string {
return matcher.Operator
}
type equalMatcher struct {
abstractMatcher
}
func (matcher *equalMatcher) Compare(valA, valB interface{}) bool {
return valA == valB
}
type notEqualMatcher struct {
abstractMatcher
}
func (matcher *notEqualMatcher) Compare(valA, valB interface{}) bool {
return valA != valB
}
type moreThanMatcher struct {
abstractMatcher
}
func (matcher *moreThanMatcher) Compare(valA, valB interface{}) bool {
if valA == nil || valA == "" || valB == nil || valB == "" {
return false
}
if intA, ok := valA.(int); ok {
if intB, ok := valB.(int); ok {
return intA > intB
}
}
if atoiA, err := strconv.Atoi(valA.(string)); err == nil {
if atoiB, err := strconv.Atoi(valB.(string)); err == nil {
return atoiA > atoiB
}
}
return string(valA.(string)) > string(valB.(string))
}
type lessThanMatcher struct {
abstractMatcher
}
func (matcher *lessThanMatcher) Compare(valA, valB interface{}) bool {
if valA == nil || valA == "" || valB == nil || valB == "" {
return false
}
if intA, ok := valA.(int); ok {
if intB, ok := valB.(int); ok {
return intA < intB
}
}
if atoiA, err := strconv.Atoi(valA.(string)); err == nil {
if atoiB, err := strconv.Atoi(valB.(string)); err == nil {
return atoiA < atoiB
}
}
return string(valA.(string)) < string(valB.(string))
}
type regexpMatcher struct {
abstractMatcher
}
func (matcher *regexpMatcher) Compare(valA, valB interface{}) bool {
r, found := matchCache.Get(valB.(string))
if !found {
var err error
r, err = regexp.Compile("(?i)" + valB.(string))
if err != nil {
return false
}
matchCache.Set(valB.(string), r, 1*time.Minute)
}
return r.(*regexp.Regexp).MatchString(valA.(string))
}
type negativeRegexMatcher struct {
abstractMatcher
}
func (matcher *negativeRegexMatcher) Compare(valA, valB interface{}) bool {
r := regexpMatcher{}
return !r.Compare(valA, valB)
}
func newMatcher(matchType string) (matcherT, error) {
if m, found := matcherConfig[matchType]; found {
m.setOperator(matchType)
return m, nil
}
e := fmt.Sprintf("%s not matched with any know match type", matchType)
return nil, errors.New(e)
}

155
filters/matcher_test.go Normal file
View File

@@ -0,0 +1,155 @@
package filters
import (
"testing"
"time"
)
type matchTest struct {
ValA interface{}
ValB interface{}
IsValid bool
Expacted bool
}
func TestEqualMatcher(t *testing.T) {
now := time.Now()
tests := []matchTest{
matchTest{"a", "a", true, true},
matchTest{"abc", "abc", true, true},
matchTest{123, 123, true, true},
matchTest{now, now, true, true},
matchTest{"1", 1, true, false},
matchTest{"a", "ab", true, false},
matchTest{12, 13, true, false},
matchTest{time.Now(), time.Now(), true, false},
}
for _, mt := range tests {
m := equalMatcher{}
if result := m.Compare(mt.ValA, mt.ValB); result != mt.Expacted {
t.Errorf("EqualMatcher(%#v, %#v) returned %v when %v was expected", mt.ValA, mt.ValB, result, mt.Expacted)
}
}
}
func TestNotEqualMatcher(t *testing.T) {
now := time.Now()
tests := []matchTest{
matchTest{"a", "a", true, false},
matchTest{"abc", "abc", true, false},
matchTest{123, 123, true, false},
matchTest{now, now, true, false},
matchTest{"1", 1, true, true},
matchTest{"a", "ab", true, true},
matchTest{12, 13, true, true},
matchTest{time.Now(), time.Now(), true, true},
}
for _, mt := range tests {
m := notEqualMatcher{}
if result := m.Compare(mt.ValA, mt.ValB); result != mt.Expacted {
t.Errorf("NotEqualMatcher(%#v, %#v) returned %v when %v was expected", mt.ValA, mt.ValB, result, mt.Expacted)
}
}
}
func TestMoreThanMatcher(t *testing.T) {
tests := []matchTest{
matchTest{10, 1, true, true},
matchTest{"10", "1", true, true},
matchTest{8, 8, true, false},
matchTest{"8", "8", true, false},
matchTest{4, 9, true, false},
matchTest{"4", "9", true, false},
matchTest{"b", "a", true, true},
matchTest{"a", "a", true, false},
matchTest{"a", "b", true, false},
matchTest{"", "", true, false},
}
for _, mt := range tests {
m := moreThanMatcher{}
if result := m.Compare(mt.ValA, mt.ValB); result != mt.Expacted {
t.Errorf("MoreThanMatcher(%#v, %#v) returned %v when %v was expected", mt.ValA, mt.ValB, result, mt.Expacted)
}
}
}
func TestLessThanMatcher(t *testing.T) {
tests := []matchTest{
matchTest{10, 1, true, false},
matchTest{"10", "1", true, false},
matchTest{8, 8, true, false},
matchTest{"8", "8", true, false},
matchTest{4, 9, true, true},
matchTest{"4", "9", true, true},
matchTest{"b", "a", true, false},
matchTest{"a", "a", true, false},
matchTest{"a", "b", true, true},
matchTest{"", "", true, false},
}
for _, mt := range tests {
m := lessThanMatcher{}
if result := m.Compare(mt.ValA, mt.ValB); result != mt.Expacted {
t.Errorf("LessThanMatcher(%#v, %#v) returned %v when %v was expected", mt.ValA, mt.ValB, result, mt.Expacted)
}
}
}
func TestRegexpMatcher(t *testing.T) {
tests := []matchTest{
matchTest{"abcdef", "^abc", true, true},
matchTest{"abc", "^abc", true, true},
matchTest{"xxabcxx", "abc", true, true},
matchTest{"123", "123", true, true},
matchTest{"5", "^[0-9]+", true, true},
matchTest{"xb", "abc", true, false},
matchTest{"13", "12", true, false},
matchTest{"xx", "^[-xxx****", false, false},
}
for _, mt := range tests {
m := regexpMatcher{}
if result := m.Compare(mt.ValA, mt.ValB); result != mt.Expacted {
t.Errorf("RegexpMatcher(%#v, %#v) returned %v when %v was expected", mt.ValA, mt.ValB, result, mt.Expacted)
}
}
}
func TestNegativeRegexpMatcher(t *testing.T) {
tests := []matchTest{
matchTest{"abcdef", "^abc", true, false},
matchTest{"abc", "^abc", true, false},
matchTest{"xxabcxx", "abc", true, false},
matchTest{"123", "123", true, false},
matchTest{"5", "^[0-9]+", true, false},
matchTest{"xb", "abc", true, true},
matchTest{"13", "12", true, true},
matchTest{"xx", "^[-xxx****", false, true},
}
for _, mt := range tests {
m := negativeRegexMatcher{}
if result := m.Compare(mt.ValA, mt.ValB); result != mt.Expacted {
t.Errorf("NegativeRegexMatcher(%#v, %#v) returned %v when %v was expected", mt.ValA, mt.ValB, result, mt.Expacted)
}
}
}
func TestNewMatcher(t *testing.T) {
operators := []string{
equalOperator,
notEqualOperator,
moreThanOperator,
lessThanOperator,
regexpOperator,
}
for _, operator := range operators {
m, err := newMatcher(operator)
if err != nil {
t.Errorf("NewMatcher(%s) returned error: %s", operator, err.Error())
}
if m.GetOperator() != operator {
t.Errorf("Got wrong matcher for %s: %s", operator, m.GetOperator())
}
}
}

72
filters/registry.go Normal file
View File

@@ -0,0 +1,72 @@
package filters
const (
equalOperator string = "="
notEqualOperator string = "!="
moreThanOperator string = ">"
lessThanOperator string = "<"
regexpOperator string = "=~"
negativeRegexOperator string = "!~"
)
var matcherConfig = map[string]matcherT{
equalOperator: &equalMatcher{},
notEqualOperator: &notEqualMatcher{},
moreThanOperator: &moreThanMatcher{},
lessThanOperator: &lessThanMatcher{},
regexpOperator: &regexpMatcher{},
negativeRegexOperator: &negativeRegexMatcher{},
}
type filterConfig struct {
IsSimple bool
Label string
SupportedOperators []string
Factory newFilterFactory
Autocomplete autocompleteFactory
}
// AllFilters contains the mapping of all filters along with operators they
// support
var AllFilters = []filterConfig{
filterConfig{
Label: "@silenced",
SupportedOperators: []string{equalOperator, notEqualOperator},
Factory: newSilencedFilter,
Autocomplete: silencedAutocomplete,
},
filterConfig{
Label: "@age",
SupportedOperators: []string{lessThanOperator, moreThanOperator},
Factory: newAgeFilter,
Autocomplete: ageAutocomplete,
},
filterConfig{
Label: "@silence_jira",
SupportedOperators: []string{regexpOperator, negativeRegexOperator, equalOperator, notEqualOperator},
Factory: newSilenceJiraFilter,
Autocomplete: sinceJiraIDAutocomplete,
},
filterConfig{
Label: "@silence_author",
SupportedOperators: []string{regexpOperator, negativeRegexOperator, equalOperator, notEqualOperator},
Factory: newSilenceAuthorFilter,
Autocomplete: sinceAuthorAutocomplete,
},
filterConfig{
Label: "@limit",
SupportedOperators: []string{equalOperator},
Factory: newLimitFilter,
Autocomplete: limitAutocomplete,
},
filterConfig{
Label: "[a-zA-Z_][a-zA-Z0-9_]*",
SupportedOperators: []string{regexpOperator, negativeRegexOperator, equalOperator, notEqualOperator, lessThanOperator, moreThanOperator},
Factory: newLabelFilter,
Autocomplete: labelAutocomplete,
},
filterConfig{
IsSimple: true,
Factory: newFuzzyFilter,
},
}

119
main.go Normal file
View File

@@ -0,0 +1,119 @@
package main
import (
"sync"
"time"
"github.com/cloudflare/unsee/config"
"github.com/cloudflare/unsee/transform"
"github.com/DeanThompson/ginpprof"
log "github.com/Sirupsen/logrus"
raven "github.com/getsentry/raven-go"
"github.com/gin-gonic/contrib/sentry"
"github.com/gin-gonic/gin"
ginprometheus "github.com/mcuadros/go-gin-prometheus"
"github.com/patrickmn/go-cache"
"github.com/prometheus/client_golang/prometheus"
)
var (
version = "dev"
// ticker is a timer used by background loop that will keep pulling
// data from AlertManager
ticker *time.Ticker
// apiCache will be used to keep short lived copy of JSON reponses generated for the UI
// If there are requests with the same filter we should respond from cache
// rather than do all the filtering every time
apiCache *cache.Cache
// errorLock holds a mutex used to synchronize updates to AlertManagerError
// to avoid any race between readers and writers
errorLock = sync.RWMutex{}
// alertManagerError holds the description of last error raised when pulling data
// from AlertManager, if there was any error
// This error will be returned in UnseeAlertsResponse and presented by Ui
alertManagerError string
metricAlerts = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "unsee_collected_alerts",
Help: "Total number of alerts collected from AlertManager API",
},
[]string{"silenced"},
)
metricAlertGroups = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "unsee_collected_groups",
Help: "Total number of alert groups collected from AlertManager API",
},
)
metricAlertManagerErrors = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "unsee_alertmanager_errors_total",
Help: "Total number of errors encounter when requesting data from AlertManager API",
},
[]string{"endpoint"},
)
)
func init() {
prometheus.MustRegister(metricAlerts)
prometheus.MustRegister(metricAlertGroups)
prometheus.MustRegister(metricAlertManagerErrors)
metricAlertManagerErrors.With(prometheus.Labels{"endpoint": "alerts"}).Set(0)
metricAlertManagerErrors.With(prometheus.Labels{"endpoint": "silences"}).Set(0)
}
func main() {
log.Infof("Version: %s", version)
config.Config.Read()
transform.ParseRules(config.Config.JIRARegexp)
apiCache = cache.New(cache.NoExpiration, 10*time.Second)
// before we start try to fetch data from AlertManager
log.Infof("Initial AlertManager query, this can delay startup up to %s", 2*config.Config.AlertManagerTimeout)
PullFromAlertManager()
log.Info("Done, starting HTTP server")
// background loop that will fetch updates from AlertManager
ticker = time.NewTicker(config.Config.UpdateInterval)
go Tick()
switch config.Config.Debug {
case true:
gin.SetMode(gin.DebugMode)
case false:
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
prom := ginprometheus.NewPrometheus("gin")
prom.Use(router)
if config.Config.Debug {
ginpprof.Wrapper(router)
}
if config.Config.SentryDSN != "" {
raven.SetRelease(version)
router.Use(sentry.Recovery(raven.DefaultClient, false))
}
router.LoadHTMLGlob("templates/*")
router.Static("/static", "./static")
router.StaticFile("/favicon.ico", "./static/favicon.ico")
router.GET("/", Index)
router.GET("/help", Help)
router.GET("/alerts.json", Alerts)
router.GET("/autocomplete.json", Autocomplete)
router.Run()
}

133
models/models.go Normal file
View File

@@ -0,0 +1,133 @@
package models
import "time"
// AlertManagerAlert is vanilla alert object from AlertManager
type AlertManagerAlert struct {
Annotations map[string]string `json:"annotations"`
Labels map[string]string `json:"labels"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
GeneratorURL string `json:"generatorURL"`
Inhibited bool `json:"inhibited"`
Silenced int `json:"silenced"`
}
// AlertManagerAlertGroup is vanilla group object from AlertManager, exposed under api/v1/alerts/groups
type AlertManagerAlertGroup struct {
Labels map[string]string `json:"labels"`
Blocks []struct {
Alerts []AlertManagerAlert `json:"alerts"`
} `json:"blocks"`
}
// AlertManagerSilence is vanilla silence object from AlertManager, exposed under api/v1/silences
type AlertManagerSilence struct {
ID int `json:"id"`
Matchers []struct {
Name string `json:"name"`
Value string `json:"value"`
IsRegex bool `json:"isRegex"`
} `json:"matchers"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy"`
Comment string `json:"comment"`
}
// UnseeSilence is vanilla silence + some additional attributes
// Unsee adds JIRA support, it can extract JIRA IDs from comments
// extracted ID is used to generate link to JIRA issue
// this means Unsee needs to store additional fields for each silence
type UnseeSilence struct {
AlertManagerSilence
JiraID string `json:"jiraID"`
JiraURL string `json:"jiraURL"`
}
// UnseeAlert is vanilla alert + some additional attributes
// Unsee extends an alert object with Links map, it's generated from annotations
// if annotation value is an url it's pulled out of annotation map
// and returned under links field, Unsee UI used this to show links differently
// than other annotations
type UnseeAlert struct {
AlertManagerAlert
Links map[string]string `json:"links"`
}
// UnseeAlertList is flat list of UnseeAlert objects
type UnseeAlertList []UnseeAlert
func (a UnseeAlertList) Len() int {
return len(a)
}
func (a UnseeAlertList) Swap(i, j int) {
a[i], a[j] = a[j], a[i]
}
func (a UnseeAlertList) Less(i, j int) bool {
// compare timestamps rounded up to 2s, subsecond accuracy is lost to keep
// ordering stable even with small time drift
return a[i].StartsAt.Round(2 * time.Second).After(a[j].StartsAt.Round(2 * time.Second))
}
// UnseeAlertGroup is vanilla AlertManager group, but alerts are flattened
// There is a hash computed from all alerts, it's used by UI to quickly tell
// if there was any change in a group and it needs to refresh it
type UnseeAlertGroup struct {
Labels map[string]string `json:"labels"`
Alerts UnseeAlertList `json:"alerts"`
ID string `json:"id"`
Hash string `json:"hash"`
SilencedCount int `json:"silencedCount"`
UnsilencedCount int `json:"unsilencedCount"`
}
// UnseeFilter holds returned data on any filter passed by the user as part of the query
type UnseeFilter struct {
Text string `json:"text"`
Hits int `json:"hits"`
IsValid bool `json:"isValid"`
}
// UnseeColor is used by UnseeLabelColor to reprenset colors as RGBA
type UnseeColor struct {
Red uint8 `json:"red"`
Green uint8 `json:"green"`
Blue uint8 `json:"blue"`
Alpha uint8 `json:"alpha"`
}
// UnseeLabelColor holds color information for labels that should be colored in the UI
// every configured label will have a distinct coloring for each value
type UnseeLabelColor struct {
Font UnseeColor `json:"font"`
Background UnseeColor `json:"background"`
}
// UnseeColorMap is a map of "Label Key" -> "Label Value" -> UnseeLabelColor
type UnseeColorMap map[string]map[string]UnseeLabelColor
// UnseeCountMap is a map of "Label Key" -> "Label Value" -> number of occurence
type UnseeCountMap map[string]map[string]int
// UnseeAlertsResponse is the structure of JSON response UI will use to get alert data
type UnseeAlertsResponse struct {
Status string `json:"status"`
Error string `json:"error,omitempty"`
Timestamp string `json:"timestamp"`
Version string `json:"version"`
AlertGroups []UnseeAlertGroup `json:"groups"`
Silences map[string]UnseeSilence `json:"silences"`
Colors UnseeColorMap `json:"colors"`
Filters []UnseeFilter `json:"filters"`
Counters UnseeCountMap `json:"counters"`
}
// UnseeAutocomplete is the structure of autocomplete object for filter hints
// this is internal represenation, not what's returned to the user
type UnseeAutocomplete struct {
Value string `json:"value"`
Tokens []string `json:"tokens"`
}

205
static/alerts.js Normal file
View File

@@ -0,0 +1,205 @@
var Alerts = (function() {
var silences = {},
labelCache = new LRUMap(1000);
class AlertGroup {
constructor(groupData) {
$.extend(this, groupData);
}
Render() {
return haml.compileHaml('groups')({
group: this,
silences: silences,
static_color_label: Colors.GetStaticLabels(),
alert_limit: 5
});
}
// called after group was rendered for the first time
Added() {
var groupID = '#' + this.id;
$.each($(groupID).find('[data-toggle=tooltip]'), function(i, elem) {
$(elem).tooltip({
animation: false, // slows down tooltip removal
delay: {
show: 500,
hide: 0
},
title: $(elem).attr('title') || $(elem).data('ts-title'),
trigger: 'hover'
});
});
}
Update() {
// hide popovers in this group
$('#' + this.id + ' [data-label-type="filter"]').popover('hide');
// remove all elements prior to content update to purge all event listeners and hooks
$.each($('#' + this.id).find('.panel-body, .panel-heading'), function(i, elem) {
$(elem).remove();
});
$('#' + this.id).html($(this.Render()).html());
$('#' + this.id).data('hash', this.hash);
// pulse the badge to show that group content was changed, repeat it twice
$('#' + this.id + ' > .panel > .panel-heading > .badge:in-viewport').finish().fadeOut(300).fadeIn(300).fadeOut(300).fadeIn(300);
}
}
destroyGroup = function(groupID) {
$('#' + groupID + ' [data-label-type="filter"]').popover('hide');
$('#' + groupID + ' [data-toggle=tooltip]').tooltip('hide');
$.each($('#' + groupID).find('.panel-body, .panel-heading'), function(i, elem) {
$(elem).remove();
});
Grid.Remove($('#' + groupID));
}
sortMapByKey = function(mapToSort) {
var keys = Object.keys(mapToSort);
keys.sort();
var sorted = [];
$.each(keys, function(i, key) {
sorted.push({
key: key,
value: mapToSort[key],
text: key + ': ' + mapToSort[key]
});
});
return sorted;
}
labelAttrs = function(key, value) {
var label = key + ': ' + value;
var attrs = labelCache.get(label);
if (attrs != undefined) return attrs;
attrs = {
text: label,
class: 'label label-list ' + Colors.GetClass(key, value),
style: Colors.Get(key, value)
}
labelCache.set(label, attrs);
return attrs;
}
humanizeTimestamps = function() {
var now = moment();
// change timestamp labels to be relative
$.each($('.label-ts'), function(i, elem) {
var ts = moment($(elem).data('ts'), moment.ISO_8601);
var label = ts.fromNow();
$(elem).find('.label-ts-span').text(label);
$(elem).attr('data-ts-title', ts.toString());
var ts_age = now.diff(ts, 'minutes');
if (ts_age < 3) {
$(elem).addClass('recent-alert').find('.incident-indicator').removeClass('hidden');
} else {
$(elem).removeClass('recent-alert').find('.incident-indicator').addClass('hidden');
}
});
// flash recent alerts
$('.recent-alert:in-viewport').finish().fadeToggle(300).fadeToggle(300).fadeToggle(300).fadeToggle(300);
}
updateAlerts = function(apiResponse) {
var alertCount = 0;
var groups = {};
// update global silences dict as it's needed for rendering
silences = apiResponse['silences'];
var summaryData = {};
$.each(apiResponse['counters'], function(label_key, counters){
$.each(counters, function(label_val, hits){
summaryData[label_key + ': ' + label_val] = hits;
});
});
Summary.Update(summaryData);
$.each(apiResponse['groups'], function(i, groupData) {
var alertGroup = new AlertGroup(groupData);
groups[alertGroup.id] = alertGroup;
alertCount += alertGroup.alerts.length;
});
Counter.Set(alertCount);
Grid.Show();
var dirty = false;
// handle already existing groups
$.each(Grid.Items(), function(i, existingGroup) {
var group = groups[existingGroup.id];
if (group != undefined) {
// group still present, check if changed
if (group.hash != existingGroup.dataset.hash) {
// group was updated, render changes
group.Update();
existingGroup.dataset.hash = group.hash;
dirty = true;
// https://github.com/twbs/bootstrap/issues/16376
setTimeout(function() {
group.Added()
}, 1000);
}
} else {
// group is gone, destroy it
destroyGroup(existingGroup.id);
dirty = true;
}
// remove from groups dict as we're done with it
delete groups[existingGroup.id];
});
// render new groups
var content = [];
$.each(groups, function(id, group) {
content.push(group.Render());
});
// append new groups in chunks
if (content.length > 0) {
Grid.Append($(content.splice(0, 100).join('\n')));
dirty = true;
}
// always refresh timestamp labels
humanizeTimestamps();
$.each(groups, function(id, group) {
group.Added();
});
if (dirty) {
Autocomplete.Reset();
Grid.Redraw();
if (Config.GetOption('flash').Get()) {
Unsee.Flash();
}
}
}
return {
Update: updateAlerts,
SortMapByKey: sortMapByKey,
GetLabelAttrs: labelAttrs
}
}());

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
#nprogress{pointer-events:none}#nprogress .bar{background:#29d;position:fixed;z-index:1031;top:0;left:0;width:100%;height:2px}#nprogress .peg{display:block;position:absolute;right:0;width:100px;height:100%;box-shadow:0 0 10px #29d,0 0 5px #29d;opacity:1;-webkit-transform:rotate(3deg) translate(0,-4px);-ms-transform:rotate(3deg) translate(0,-4px);transform:rotate(3deg) translate(0,-4px)}#nprogress .spinner{display:block;position:fixed;z-index:1031;top:15px;right:15px}#nprogress .spinner-icon{width:18px;height:18px;box-sizing:border-box;border:2px solid transparent;border-top-color:#29d;border-left-color:#29d;border-radius:50%;-webkit-animation:nprogress-spinner 400ms linear infinite;animation:nprogress-spinner 400ms linear infinite}.nprogress-custom-parent{overflow:hidden;position:relative}.nprogress-custom-parent #nprogress .bar,.nprogress-custom-parent #nprogress .spinner{position:absolute}@-webkit-keyframes nprogress-spinner{0%{-webkit-transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg)}}@keyframes nprogress-spinner{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}

View File

@@ -0,0 +1,54 @@
/*
* bootstrap-tagsinput v0.8.0
*
*/
.twitter-typeahead .tt-query,
.twitter-typeahead .tt-hint {
margin-bottom: 0;
}
.twitter-typeahead .tt-hint
{
display: none;
}
.tt-menu {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
display: none;
float: left;
min-width: 160px;
padding: 5px 0;
margin: 2px 0 0;
list-style: none;
font-size: 14px;
background-color: #ffffff;
border: 1px solid #cccccc;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 4px;
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
background-clip: padding-box;
cursor: pointer;
}
.tt-suggestion {
display: block;
padding: 3px 20px;
clear: both;
font-weight: normal;
line-height: 1.428571429;
color: #333333;
white-space: nowrap;
}
.tt-suggestion:hover,
.tt-suggestion:focus {
color: #ffffff;
text-decoration: none;
outline: 0;
background-color: #428bca;
}

View File

@@ -0,0 +1,60 @@
/*
* bootstrap-tagsinput v0.8.0
*
*/
.bootstrap-tagsinput {
background-color: #fff;
border: 1px solid #ccc;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
display: inline-block;
padding: 4px 6px;
color: #555;
vertical-align: middle;
border-radius: 4px;
max-width: 100%;
line-height: 22px;
cursor: text;
}
.bootstrap-tagsinput input {
border: none;
box-shadow: none;
outline: none;
background-color: transparent;
padding: 0 6px;
margin: 0;
width: auto;
max-width: inherit;
}
.bootstrap-tagsinput.form-control input::-moz-placeholder {
color: #777;
opacity: 1;
}
.bootstrap-tagsinput.form-control input:-ms-input-placeholder {
color: #777;
}
.bootstrap-tagsinput.form-control input::-webkit-input-placeholder {
color: #777;
}
.bootstrap-tagsinput input:focus {
border: none;
box-shadow: none;
}
.bootstrap-tagsinput .tag {
margin-right: 2px;
color: white;
}
.bootstrap-tagsinput .tag [data-role="remove"] {
margin-left: 8px;
cursor: pointer;
}
.bootstrap-tagsinput .tag [data-role="remove"]:after {
content: "x";
padding: 0px 2px;
}
.bootstrap-tagsinput .tag [data-role="remove"]:hover {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
}
.bootstrap-tagsinput .tag [data-role="remove"]:hover:active {
box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
4.7.0-font-awesome.min.css
3.3.7-bootstrap.min.css
3.3.2-bootstrap-switch.min.css
0.2.0-nprogress.min.css
0.8.0-bootstrap-tagsinput.css
0.8.0-bootstrap-tagsinput-typeahead.css
0.1.2-loaders.min.css

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
(function($){var divs={"ball-pulse":3,"ball-grid-pulse":9,"ball-clip-rotate":1,"ball-clip-rotate-pulse":2,"square-spin":1,"ball-clip-rotate-multiple":2,"ball-pulse-rise":5,"ball-rotate":1,"cube-transition":2,"ball-zig-zag":2,"ball-zig-zag-deflect":2,"ball-triangle-path":3,"ball-scale":1,"line-scale":5,"line-scale-party":4,"ball-scale-multiple":3,"ball-pulse-sync":3,"ball-beat":3,"line-scale-pulse-out":5,"line-scale-pulse-out-rapid":5,"ball-scale-ripple":1,"ball-scale-ripple-multiple":3,"ball-spin-fade-loader":8,"line-spin-fade-loader":8,"triangle-skew-spin":1,pacman:5,"ball-grid-beat":9,"semi-circle-spin":1,"ball-scale-random":3};var addDivs=function(n){var arr=[];for(i=1;i<=n;i++){arr.push("<div></div>")}return arr};$.fn.loaders=function(){return this.each(function(){var elem=$(this);$.each(divs,function(key,value){if(elem.hasClass(key))elem.html(addDivs(value))})})};$(function(){$.each(divs,function(key,value){$(".loader-inner."+key).html(addDivs(value))})})}).call(window,window.$||window.jQuery||window.Zepto);

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
!function(n,e){"function"==typeof define&&define.amd?define(e):"object"==typeof exports?module.exports=e():n.NProgress=e()}(this,function(){function n(n,e,t){return e>n?e:n>t?t:n}function e(n){return 100*(-1+n)}function t(n,t,r){var i;return i="translate3d"===c.positionUsing?{transform:"translate3d("+e(n)+"%,0,0)"}:"translate"===c.positionUsing?{transform:"translate("+e(n)+"%,0)"}:{"margin-left":e(n)+"%"},i.transition="all "+t+"ms "+r,i}function r(n,e){var t="string"==typeof n?n:o(n);return t.indexOf(" "+e+" ")>=0}function i(n,e){var t=o(n),i=t+e;r(t,e)||(n.className=i.substring(1))}function s(n,e){var t,i=o(n);r(n,e)&&(t=i.replace(" "+e+" "," "),n.className=t.substring(1,t.length-1))}function o(n){return(" "+(n.className||"")+" ").replace(/\s+/gi," ")}function a(n){n&&n.parentNode&&n.parentNode.removeChild(n)}var u={};u.version="0.2.0";var c=u.settings={minimum:.08,easing:"ease",positionUsing:"",speed:200,trickle:!0,trickleRate:.02,trickleSpeed:800,showSpinner:!0,barSelector:'[role="bar"]',spinnerSelector:'[role="spinner"]',parent:"body",template:'<div class="bar" role="bar"><div class="peg"></div></div><div class="spinner" role="spinner"><div class="spinner-icon"></div></div>'};u.configure=function(n){var e,t;for(e in n)t=n[e],void 0!==t&&n.hasOwnProperty(e)&&(c[e]=t);return this},u.status=null,u.set=function(e){var r=u.isStarted();e=n(e,c.minimum,1),u.status=1===e?null:e;var i=u.render(!r),s=i.querySelector(c.barSelector),o=c.speed,a=c.easing;return i.offsetWidth,l(function(n){""===c.positionUsing&&(c.positionUsing=u.getPositioningCSS()),f(s,t(e,o,a)),1===e?(f(i,{transition:"none",opacity:1}),i.offsetWidth,setTimeout(function(){f(i,{transition:"all "+o+"ms linear",opacity:0}),setTimeout(function(){u.remove(),n()},o)},o)):setTimeout(n,o)}),this},u.isStarted=function(){return"number"==typeof u.status},u.start=function(){u.status||u.set(0);var n=function(){setTimeout(function(){u.status&&(u.trickle(),n())},c.trickleSpeed)};return c.trickle&&n(),this},u.done=function(n){return n||u.status?u.inc(.3+.5*Math.random()).set(1):this},u.inc=function(e){var t=u.status;return t?("number"!=typeof e&&(e=(1-t)*n(Math.random()*t,.1,.95)),t=n(t+e,0,.994),u.set(t)):u.start()},u.trickle=function(){return u.inc(Math.random()*c.trickleRate)},function(){var n=0,e=0;u.promise=function(t){return t&&"resolved"!==t.state()?(0===e&&u.start(),n++,e++,t.always(function(){e--,0===e?(n=0,u.done()):u.set((n-e)/n)}),this):this}}(),u.render=function(n){if(u.isRendered())return document.getElementById("nprogress");i(document.documentElement,"nprogress-busy");var t=document.createElement("div");t.id="nprogress",t.innerHTML=c.template;var r,s=t.querySelector(c.barSelector),o=n?"-100":e(u.status||0),l=document.querySelector(c.parent);return f(s,{transition:"all 0 linear",transform:"translate3d("+o+"%,0,0)"}),c.showSpinner||(r=t.querySelector(c.spinnerSelector),r&&a(r)),l!=document.body&&i(l,"nprogress-custom-parent"),l.appendChild(t),t},u.remove=function(){s(document.documentElement,"nprogress-busy"),s(document.querySelector(c.parent),"nprogress-custom-parent");var n=document.getElementById("nprogress");n&&a(n)},u.isRendered=function(){return!!document.getElementById("nprogress")},u.getPositioningCSS=function(){var n=document.body.style,e="WebkitTransform"in n?"Webkit":"MozTransform"in n?"Moz":"msTransform"in n?"ms":"OTransform"in n?"O":"";return e+"Perspective"in n?"translate3d":e+"Transform"in n?"translate":"margin"};var l=function(){function n(){var t=e.shift();t&&t(n)}var e=[];return function(t){e.push(t),1==e.length&&n()}}(),f=function(){function n(n){return n.replace(/^-ms-/,"ms-").replace(/-([\da-z])/gi,function(n,e){return e.toUpperCase()})}function e(n){var e=document.body.style;if(n in e)return n;for(var t,r=i.length,s=n.charAt(0).toUpperCase()+n.slice(1);r--;)if(t=i[r]+s,t in e)return t;return n}function t(t){return t=n(t),s[t]||(s[t]=e(t))}function r(n,e,r){e=t(e),n.style[e]=r}var i=["Webkit","O","Moz","ms"],s={};return function(n,e){var t,i,s=arguments;if(2==s.length)for(t in e)i=e[t],void 0!==i&&e.hasOwnProperty(t)&&r(n,t,i);else r(n,s[1],s[2])}}();return u});

1
static/assets/js/0.3.10-favico.min.js vendored Normal file

File diff suppressed because one or more lines are too long

9
static/assets/js/0.4.0-sha1.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
!function(e){var n=!1;if("function"==typeof define&&define.amd&&(define(e),n=!0),"object"==typeof exports&&(module.exports=e(),n=!0),!n){var o=window.Cookies,t=window.Cookies=e();t.noConflict=function(){return window.Cookies=o,t}}}(function(){function e(){for(var e=0,n={};e<arguments.length;e++){var o=arguments[e];for(var t in o)n[t]=o[t]}return n}function n(o){function t(n,r,i){var c;if("undefined"!=typeof document){if(arguments.length>1){if(i=e({path:"/"},t.defaults,i),"number"==typeof i.expires){var a=new Date;a.setMilliseconds(a.getMilliseconds()+864e5*i.expires),i.expires=a}try{c=JSON.stringify(r),/^[\{\[]/.test(c)&&(r=c)}catch(e){}return r=o.write?o.write(r,n):encodeURIComponent(String(r)).replace(/%(23|24|26|2B|3A|3C|3E|3D|2F|3F|40|5B|5D|5E|60|7B|7D|7C)/g,decodeURIComponent),n=encodeURIComponent(String(n)),n=n.replace(/%(23|24|26|2B|5E|60|7C)/g,decodeURIComponent),n=n.replace(/[\(\)]/g,escape),document.cookie=[n,"=",r,i.expires?"; expires="+i.expires.toUTCString():"",i.path?"; path="+i.path:"",i.domain?"; domain="+i.domain:"",i.secure?"; secure":""].join("")}n||(c={});for(var p=document.cookie?document.cookie.split("; "):[],s=/(%[0-9A-Z]{2})+/g,d=0;d<p.length;d++){var f=p[d].split("="),u=f.slice(1).join("=");'"'===u.charAt(0)&&(u=u.slice(1,-1));try{var l=f[0].replace(s,decodeURIComponent);if(u=o.read?o.read(u,l):o(u,l)||u.replace(s,decodeURIComponent),this.json)try{u=JSON.parse(u)}catch(e){}if(n===l){c=u;break}n||(c[l]=u)}catch(e){}}return c}}return t.set=t,t.get=function(e){return t.call(t,e)},t.getJSON=function(){return t.apply({json:!0},[].slice.call(arguments))},t.defaults={},t.remove=function(n,o){t(n,"",e(o,{expires:-1}))},t.withConverter=n,t}return n(function(){})});
//# sourceMappingURL=js.cookie.min.js.map

551
static/assets/js/2.17.1-moment.min.js vendored Normal file
View File

@@ -0,0 +1,551 @@
//! moment.js
//! version : 2.17.1
//! authors : Tim Wood, Iskren Chernev, Moment.js contributors
//! license : MIT
//! momentjs.com
!function(a,b){"object"==typeof exports&&"undefined"!=typeof module?module.exports=b():"function"==typeof define&&define.amd?define(b):a.moment=b()}(this,function(){"use strict";function a(){return od.apply(null,arguments)}
// This is done to register the method called with moment()
// without creating circular dependencies.
function b(a){od=a}function c(a){return a instanceof Array||"[object Array]"===Object.prototype.toString.call(a)}function d(a){
// IE8 will treat undefined and null as object if it wasn't for
// input != null
return null!=a&&"[object Object]"===Object.prototype.toString.call(a)}function e(a){var b;for(b in a)
// even if its not own property I'd still call it non-empty
return!1;return!0}function f(a){return"number"==typeof a||"[object Number]"===Object.prototype.toString.call(a)}function g(a){return a instanceof Date||"[object Date]"===Object.prototype.toString.call(a)}function h(a,b){var c,d=[];for(c=0;c<a.length;++c)d.push(b(a[c],c));return d}function i(a,b){return Object.prototype.hasOwnProperty.call(a,b)}function j(a,b){for(var c in b)i(b,c)&&(a[c]=b[c]);return i(b,"toString")&&(a.toString=b.toString),i(b,"valueOf")&&(a.valueOf=b.valueOf),a}function k(a,b,c,d){return rb(a,b,c,d,!0).utc()}function l(){
// We need to deep clone this object.
return{empty:!1,unusedTokens:[],unusedInput:[],overflow:-2,charsLeftOver:0,nullInput:!1,invalidMonth:null,invalidFormat:!1,userInvalidated:!1,iso:!1,parsedDateParts:[],meridiem:null}}function m(a){return null==a._pf&&(a._pf=l()),a._pf}function n(a){if(null==a._isValid){var b=m(a),c=qd.call(b.parsedDateParts,function(a){return null!=a}),d=!isNaN(a._d.getTime())&&b.overflow<0&&!b.empty&&!b.invalidMonth&&!b.invalidWeekday&&!b.nullInput&&!b.invalidFormat&&!b.userInvalidated&&(!b.meridiem||b.meridiem&&c);if(a._strict&&(d=d&&0===b.charsLeftOver&&0===b.unusedTokens.length&&void 0===b.bigHour),null!=Object.isFrozen&&Object.isFrozen(a))return d;a._isValid=d}return a._isValid}function o(a){var b=k(NaN);return null!=a?j(m(b),a):m(b).userInvalidated=!0,b}function p(a){return void 0===a}function q(a,b){var c,d,e;if(p(b._isAMomentObject)||(a._isAMomentObject=b._isAMomentObject),p(b._i)||(a._i=b._i),p(b._f)||(a._f=b._f),p(b._l)||(a._l=b._l),p(b._strict)||(a._strict=b._strict),p(b._tzm)||(a._tzm=b._tzm),p(b._isUTC)||(a._isUTC=b._isUTC),p(b._offset)||(a._offset=b._offset),p(b._pf)||(a._pf=m(b)),p(b._locale)||(a._locale=b._locale),rd.length>0)for(c in rd)d=rd[c],e=b[d],p(e)||(a[d]=e);return a}
// Moment prototype object
function r(b){q(this,b),this._d=new Date(null!=b._d?b._d.getTime():NaN),this.isValid()||(this._d=new Date(NaN)),
// Prevent infinite loop in case updateOffset creates new moment
// objects.
sd===!1&&(sd=!0,a.updateOffset(this),sd=!1)}function s(a){return a instanceof r||null!=a&&null!=a._isAMomentObject}function t(a){return a<0?Math.ceil(a)||0:Math.floor(a)}function u(a){var b=+a,c=0;return 0!==b&&isFinite(b)&&(c=t(b)),c}
// compare two arrays, return the number of differences
function v(a,b,c){var d,e=Math.min(a.length,b.length),f=Math.abs(a.length-b.length),g=0;for(d=0;d<e;d++)(c&&a[d]!==b[d]||!c&&u(a[d])!==u(b[d]))&&g++;return g+f}function w(b){a.suppressDeprecationWarnings===!1&&"undefined"!=typeof console&&console.warn&&console.warn("Deprecation warning: "+b)}function x(b,c){var d=!0;return j(function(){if(null!=a.deprecationHandler&&a.deprecationHandler(null,b),d){for(var e,f=[],g=0;g<arguments.length;g++){if(e="","object"==typeof arguments[g]){e+="\n["+g+"] ";for(var h in arguments[0])e+=h+": "+arguments[0][h]+", ";e=e.slice(0,-2)}else e=arguments[g];f.push(e)}w(b+"\nArguments: "+Array.prototype.slice.call(f).join("")+"\n"+(new Error).stack),d=!1}return c.apply(this,arguments)},c)}function y(b,c){null!=a.deprecationHandler&&a.deprecationHandler(b,c),td[b]||(w(c),td[b]=!0)}function z(a){return a instanceof Function||"[object Function]"===Object.prototype.toString.call(a)}function A(a){var b,c;for(c in a)b=a[c],z(b)?this[c]=b:this["_"+c]=b;this._config=a,
// Lenient ordinal parsing accepts just a number in addition to
// number + (possibly) stuff coming from _ordinalParseLenient.
this._ordinalParseLenient=new RegExp(this._ordinalParse.source+"|"+/\d{1,2}/.source)}function B(a,b){var c,e=j({},a);for(c in b)i(b,c)&&(d(a[c])&&d(b[c])?(e[c]={},j(e[c],a[c]),j(e[c],b[c])):null!=b[c]?e[c]=b[c]:delete e[c]);for(c in a)i(a,c)&&!i(b,c)&&d(a[c])&&(
// make sure changes to properties don't modify parent config
e[c]=j({},e[c]));return e}function C(a){null!=a&&this.set(a)}function D(a,b,c){var d=this._calendar[a]||this._calendar.sameElse;return z(d)?d.call(b,c):d}function E(a){var b=this._longDateFormat[a],c=this._longDateFormat[a.toUpperCase()];return b||!c?b:(this._longDateFormat[a]=c.replace(/MMMM|MM|DD|dddd/g,function(a){return a.slice(1)}),this._longDateFormat[a])}function F(){return this._invalidDate}function G(a){return this._ordinal.replace("%d",a)}function H(a,b,c,d){var e=this._relativeTime[c];return z(e)?e(a,b,c,d):e.replace(/%d/i,a)}function I(a,b){var c=this._relativeTime[a>0?"future":"past"];return z(c)?c(b):c.replace(/%s/i,b)}function J(a,b){var c=a.toLowerCase();Dd[c]=Dd[c+"s"]=Dd[b]=a}function K(a){return"string"==typeof a?Dd[a]||Dd[a.toLowerCase()]:void 0}function L(a){var b,c,d={};for(c in a)i(a,c)&&(b=K(c),b&&(d[b]=a[c]));return d}function M(a,b){Ed[a]=b}function N(a){var b=[];for(var c in a)b.push({unit:c,priority:Ed[c]});return b.sort(function(a,b){return a.priority-b.priority}),b}function O(b,c){return function(d){return null!=d?(Q(this,b,d),a.updateOffset(this,c),this):P(this,b)}}function P(a,b){return a.isValid()?a._d["get"+(a._isUTC?"UTC":"")+b]():NaN}function Q(a,b,c){a.isValid()&&a._d["set"+(a._isUTC?"UTC":"")+b](c)}
// MOMENTS
function R(a){return a=K(a),z(this[a])?this[a]():this}function S(a,b){if("object"==typeof a){a=L(a);for(var c=N(a),d=0;d<c.length;d++)this[c[d].unit](a[c[d].unit])}else if(a=K(a),z(this[a]))return this[a](b);return this}function T(a,b,c){var d=""+Math.abs(a),e=b-d.length,f=a>=0;return(f?c?"+":"":"-")+Math.pow(10,Math.max(0,e)).toString().substr(1)+d}
// token: 'M'
// padded: ['MM', 2]
// ordinal: 'Mo'
// callback: function () { this.month() + 1 }
function U(a,b,c,d){var e=d;"string"==typeof d&&(e=function(){return this[d]()}),a&&(Id[a]=e),b&&(Id[b[0]]=function(){return T(e.apply(this,arguments),b[1],b[2])}),c&&(Id[c]=function(){return this.localeData().ordinal(e.apply(this,arguments),a)})}function V(a){return a.match(/\[[\s\S]/)?a.replace(/^\[|\]$/g,""):a.replace(/\\/g,"")}function W(a){var b,c,d=a.match(Fd);for(b=0,c=d.length;b<c;b++)Id[d[b]]?d[b]=Id[d[b]]:d[b]=V(d[b]);return function(b){var e,f="";for(e=0;e<c;e++)f+=d[e]instanceof Function?d[e].call(b,a):d[e];return f}}
// format date using native date object
function X(a,b){return a.isValid()?(b=Y(b,a.localeData()),Hd[b]=Hd[b]||W(b),Hd[b](a)):a.localeData().invalidDate()}function Y(a,b){function c(a){return b.longDateFormat(a)||a}var d=5;for(Gd.lastIndex=0;d>=0&&Gd.test(a);)a=a.replace(Gd,c),Gd.lastIndex=0,d-=1;return a}function Z(a,b,c){$d[a]=z(b)?b:function(a,d){return a&&c?c:b}}function $(a,b){return i($d,a)?$d[a](b._strict,b._locale):new RegExp(_(a))}
// Code from http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript
function _(a){return aa(a.replace("\\","").replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g,function(a,b,c,d,e){return b||c||d||e}))}function aa(a){return a.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&")}function ba(a,b){var c,d=b;for("string"==typeof a&&(a=[a]),f(b)&&(d=function(a,c){c[b]=u(a)}),c=0;c<a.length;c++)_d[a[c]]=d}function ca(a,b){ba(a,function(a,c,d,e){d._w=d._w||{},b(a,d._w,d,e)})}function da(a,b,c){null!=b&&i(_d,a)&&_d[a](b,c._a,c,a)}function ea(a,b){return new Date(Date.UTC(a,b+1,0)).getUTCDate()}function fa(a,b){return a?c(this._months)?this._months[a.month()]:this._months[(this._months.isFormat||ke).test(b)?"format":"standalone"][a.month()]:this._months}function ga(a,b){return a?c(this._monthsShort)?this._monthsShort[a.month()]:this._monthsShort[ke.test(b)?"format":"standalone"][a.month()]:this._monthsShort}function ha(a,b,c){var d,e,f,g=a.toLocaleLowerCase();if(!this._monthsParse)for(
// this is not used
this._monthsParse=[],this._longMonthsParse=[],this._shortMonthsParse=[],d=0;d<12;++d)f=k([2e3,d]),this._shortMonthsParse[d]=this.monthsShort(f,"").toLocaleLowerCase(),this._longMonthsParse[d]=this.months(f,"").toLocaleLowerCase();return c?"MMM"===b?(e=je.call(this._shortMonthsParse,g),e!==-1?e:null):(e=je.call(this._longMonthsParse,g),e!==-1?e:null):"MMM"===b?(e=je.call(this._shortMonthsParse,g),e!==-1?e:(e=je.call(this._longMonthsParse,g),e!==-1?e:null)):(e=je.call(this._longMonthsParse,g),e!==-1?e:(e=je.call(this._shortMonthsParse,g),e!==-1?e:null))}function ia(a,b,c){var d,e,f;if(this._monthsParseExact)return ha.call(this,a,b,c);
// TODO: add sorting
// Sorting makes sure if one month (or abbr) is a prefix of another
// see sorting in computeMonthsParse
for(this._monthsParse||(this._monthsParse=[],this._longMonthsParse=[],this._shortMonthsParse=[]),d=0;d<12;d++){
// test the regex
if(
// make the regex if we don't have it already
e=k([2e3,d]),c&&!this._longMonthsParse[d]&&(this._longMonthsParse[d]=new RegExp("^"+this.months(e,"").replace(".","")+"$","i"),this._shortMonthsParse[d]=new RegExp("^"+this.monthsShort(e,"").replace(".","")+"$","i")),c||this._monthsParse[d]||(f="^"+this.months(e,"")+"|^"+this.monthsShort(e,""),this._monthsParse[d]=new RegExp(f.replace(".",""),"i")),c&&"MMMM"===b&&this._longMonthsParse[d].test(a))return d;if(c&&"MMM"===b&&this._shortMonthsParse[d].test(a))return d;if(!c&&this._monthsParse[d].test(a))return d}}
// MOMENTS
function ja(a,b){var c;if(!a.isValid())
// No op
return a;if("string"==typeof b)if(/^\d+$/.test(b))b=u(b);else
// TODO: Another silent failure?
if(b=a.localeData().monthsParse(b),!f(b))return a;return c=Math.min(a.date(),ea(a.year(),b)),a._d["set"+(a._isUTC?"UTC":"")+"Month"](b,c),a}function ka(b){return null!=b?(ja(this,b),a.updateOffset(this,!0),this):P(this,"Month")}function la(){return ea(this.year(),this.month())}function ma(a){return this._monthsParseExact?(i(this,"_monthsRegex")||oa.call(this),a?this._monthsShortStrictRegex:this._monthsShortRegex):(i(this,"_monthsShortRegex")||(this._monthsShortRegex=ne),this._monthsShortStrictRegex&&a?this._monthsShortStrictRegex:this._monthsShortRegex)}function na(a){return this._monthsParseExact?(i(this,"_monthsRegex")||oa.call(this),a?this._monthsStrictRegex:this._monthsRegex):(i(this,"_monthsRegex")||(this._monthsRegex=oe),this._monthsStrictRegex&&a?this._monthsStrictRegex:this._monthsRegex)}function oa(){function a(a,b){return b.length-a.length}var b,c,d=[],e=[],f=[];for(b=0;b<12;b++)
// make the regex if we don't have it already
c=k([2e3,b]),d.push(this.monthsShort(c,"")),e.push(this.months(c,"")),f.push(this.months(c,"")),f.push(this.monthsShort(c,""));for(
// Sorting makes sure if one month (or abbr) is a prefix of another it
// will match the longer piece.
d.sort(a),e.sort(a),f.sort(a),b=0;b<12;b++)d[b]=aa(d[b]),e[b]=aa(e[b]);for(b=0;b<24;b++)f[b]=aa(f[b]);this._monthsRegex=new RegExp("^("+f.join("|")+")","i"),this._monthsShortRegex=this._monthsRegex,this._monthsStrictRegex=new RegExp("^("+e.join("|")+")","i"),this._monthsShortStrictRegex=new RegExp("^("+d.join("|")+")","i")}
// HELPERS
function pa(a){return qa(a)?366:365}function qa(a){return a%4===0&&a%100!==0||a%400===0}function ra(){return qa(this.year())}function sa(a,b,c,d,e,f,g){
//can't just apply() to create a date:
//http://stackoverflow.com/questions/181348/instantiating-a-javascript-object-by-calling-prototype-constructor-apply
var h=new Date(a,b,c,d,e,f,g);
//the date constructor remaps years 0-99 to 1900-1999
return a<100&&a>=0&&isFinite(h.getFullYear())&&h.setFullYear(a),h}function ta(a){var b=new Date(Date.UTC.apply(null,arguments));
//the Date.UTC function remaps years 0-99 to 1900-1999
return a<100&&a>=0&&isFinite(b.getUTCFullYear())&&b.setUTCFullYear(a),b}
// start-of-first-week - start-of-year
function ua(a,b,c){var// first-week day -- which january is always in the first week (4 for iso, 1 for other)
d=7+b-c,
// first-week day local weekday -- which local weekday is fwd
e=(7+ta(a,0,d).getUTCDay()-b)%7;return-e+d-1}
//http://en.wikipedia.org/wiki/ISO_week_date#Calculating_a_date_given_the_year.2C_week_number_and_weekday
function va(a,b,c,d,e){var f,g,h=(7+c-d)%7,i=ua(a,d,e),j=1+7*(b-1)+h+i;return j<=0?(f=a-1,g=pa(f)+j):j>pa(a)?(f=a+1,g=j-pa(a)):(f=a,g=j),{year:f,dayOfYear:g}}function wa(a,b,c){var d,e,f=ua(a.year(),b,c),g=Math.floor((a.dayOfYear()-f-1)/7)+1;return g<1?(e=a.year()-1,d=g+xa(e,b,c)):g>xa(a.year(),b,c)?(d=g-xa(a.year(),b,c),e=a.year()+1):(e=a.year(),d=g),{week:d,year:e}}function xa(a,b,c){var d=ua(a,b,c),e=ua(a+1,b,c);return(pa(a)-d+e)/7}
// HELPERS
// LOCALES
function ya(a){return wa(a,this._week.dow,this._week.doy).week}function za(){return this._week.dow}function Aa(){return this._week.doy}
// MOMENTS
function Ba(a){var b=this.localeData().week(this);return null==a?b:this.add(7*(a-b),"d")}function Ca(a){var b=wa(this,1,4).week;return null==a?b:this.add(7*(a-b),"d")}
// HELPERS
function Da(a,b){return"string"!=typeof a?a:isNaN(a)?(a=b.weekdaysParse(a),"number"==typeof a?a:null):parseInt(a,10)}function Ea(a,b){return"string"==typeof a?b.weekdaysParse(a)%7||7:isNaN(a)?null:a}function Fa(a,b){return a?c(this._weekdays)?this._weekdays[a.day()]:this._weekdays[this._weekdays.isFormat.test(b)?"format":"standalone"][a.day()]:this._weekdays}function Ga(a){return a?this._weekdaysShort[a.day()]:this._weekdaysShort}function Ha(a){return a?this._weekdaysMin[a.day()]:this._weekdaysMin}function Ia(a,b,c){var d,e,f,g=a.toLocaleLowerCase();if(!this._weekdaysParse)for(this._weekdaysParse=[],this._shortWeekdaysParse=[],this._minWeekdaysParse=[],d=0;d<7;++d)f=k([2e3,1]).day(d),this._minWeekdaysParse[d]=this.weekdaysMin(f,"").toLocaleLowerCase(),this._shortWeekdaysParse[d]=this.weekdaysShort(f,"").toLocaleLowerCase(),this._weekdaysParse[d]=this.weekdays(f,"").toLocaleLowerCase();return c?"dddd"===b?(e=je.call(this._weekdaysParse,g),e!==-1?e:null):"ddd"===b?(e=je.call(this._shortWeekdaysParse,g),e!==-1?e:null):(e=je.call(this._minWeekdaysParse,g),e!==-1?e:null):"dddd"===b?(e=je.call(this._weekdaysParse,g),e!==-1?e:(e=je.call(this._shortWeekdaysParse,g),e!==-1?e:(e=je.call(this._minWeekdaysParse,g),e!==-1?e:null))):"ddd"===b?(e=je.call(this._shortWeekdaysParse,g),e!==-1?e:(e=je.call(this._weekdaysParse,g),e!==-1?e:(e=je.call(this._minWeekdaysParse,g),e!==-1?e:null))):(e=je.call(this._minWeekdaysParse,g),e!==-1?e:(e=je.call(this._weekdaysParse,g),e!==-1?e:(e=je.call(this._shortWeekdaysParse,g),e!==-1?e:null)))}function Ja(a,b,c){var d,e,f;if(this._weekdaysParseExact)return Ia.call(this,a,b,c);for(this._weekdaysParse||(this._weekdaysParse=[],this._minWeekdaysParse=[],this._shortWeekdaysParse=[],this._fullWeekdaysParse=[]),d=0;d<7;d++){
// test the regex
if(
// make the regex if we don't have it already
e=k([2e3,1]).day(d),c&&!this._fullWeekdaysParse[d]&&(this._fullWeekdaysParse[d]=new RegExp("^"+this.weekdays(e,"").replace(".",".?")+"$","i"),this._shortWeekdaysParse[d]=new RegExp("^"+this.weekdaysShort(e,"").replace(".",".?")+"$","i"),this._minWeekdaysParse[d]=new RegExp("^"+this.weekdaysMin(e,"").replace(".",".?")+"$","i")),this._weekdaysParse[d]||(f="^"+this.weekdays(e,"")+"|^"+this.weekdaysShort(e,"")+"|^"+this.weekdaysMin(e,""),this._weekdaysParse[d]=new RegExp(f.replace(".",""),"i")),c&&"dddd"===b&&this._fullWeekdaysParse[d].test(a))return d;if(c&&"ddd"===b&&this._shortWeekdaysParse[d].test(a))return d;if(c&&"dd"===b&&this._minWeekdaysParse[d].test(a))return d;if(!c&&this._weekdaysParse[d].test(a))return d}}
// MOMENTS
function Ka(a){if(!this.isValid())return null!=a?this:NaN;var b=this._isUTC?this._d.getUTCDay():this._d.getDay();return null!=a?(a=Da(a,this.localeData()),this.add(a-b,"d")):b}function La(a){if(!this.isValid())return null!=a?this:NaN;var b=(this.day()+7-this.localeData()._week.dow)%7;return null==a?b:this.add(a-b,"d")}function Ma(a){if(!this.isValid())return null!=a?this:NaN;
// behaves the same as moment#day except
// as a getter, returns 7 instead of 0 (1-7 range instead of 0-6)
// as a setter, sunday should belong to the previous week.
if(null!=a){var b=Ea(a,this.localeData());return this.day(this.day()%7?b:b-7)}return this.day()||7}function Na(a){return this._weekdaysParseExact?(i(this,"_weekdaysRegex")||Qa.call(this),a?this._weekdaysStrictRegex:this._weekdaysRegex):(i(this,"_weekdaysRegex")||(this._weekdaysRegex=ue),this._weekdaysStrictRegex&&a?this._weekdaysStrictRegex:this._weekdaysRegex)}function Oa(a){return this._weekdaysParseExact?(i(this,"_weekdaysRegex")||Qa.call(this),a?this._weekdaysShortStrictRegex:this._weekdaysShortRegex):(i(this,"_weekdaysShortRegex")||(this._weekdaysShortRegex=ve),this._weekdaysShortStrictRegex&&a?this._weekdaysShortStrictRegex:this._weekdaysShortRegex)}function Pa(a){return this._weekdaysParseExact?(i(this,"_weekdaysRegex")||Qa.call(this),a?this._weekdaysMinStrictRegex:this._weekdaysMinRegex):(i(this,"_weekdaysMinRegex")||(this._weekdaysMinRegex=we),this._weekdaysMinStrictRegex&&a?this._weekdaysMinStrictRegex:this._weekdaysMinRegex)}function Qa(){function a(a,b){return b.length-a.length}var b,c,d,e,f,g=[],h=[],i=[],j=[];for(b=0;b<7;b++)
// make the regex if we don't have it already
c=k([2e3,1]).day(b),d=this.weekdaysMin(c,""),e=this.weekdaysShort(c,""),f=this.weekdays(c,""),g.push(d),h.push(e),i.push(f),j.push(d),j.push(e),j.push(f);for(
// Sorting makes sure if one weekday (or abbr) is a prefix of another it
// will match the longer piece.
g.sort(a),h.sort(a),i.sort(a),j.sort(a),b=0;b<7;b++)h[b]=aa(h[b]),i[b]=aa(i[b]),j[b]=aa(j[b]);this._weekdaysRegex=new RegExp("^("+j.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+i.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+h.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+g.join("|")+")","i")}
// FORMATTING
function Ra(){return this.hours()%12||12}function Sa(){return this.hours()||24}function Ta(a,b){U(a,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),b)})}
// PARSING
function Ua(a,b){return b._meridiemParse}
// LOCALES
function Va(a){
// IE8 Quirks Mode & IE7 Standards Mode do not allow accessing strings like arrays
// Using charAt should be more compatible.
return"p"===(a+"").toLowerCase().charAt(0)}function Wa(a,b,c){return a>11?c?"pm":"PM":c?"am":"AM"}function Xa(a){return a?a.toLowerCase().replace("_","-"):a}
// pick the locale from the array
// try ['en-au', 'en-gb'] as 'en-au', 'en-gb', 'en', as in move through the list trying each
// substring from most specific to least, but move to the next array item if it's a more specific variant than the current root
function Ya(a){for(var b,c,d,e,f=0;f<a.length;){for(e=Xa(a[f]).split("-"),b=e.length,c=Xa(a[f+1]),c=c?c.split("-"):null;b>0;){if(d=Za(e.slice(0,b).join("-")))return d;if(c&&c.length>=b&&v(e,c,!0)>=b-1)
//the next array item is better than a shallower substring of this one
break;b--}f++}return null}function Za(a){var b=null;
// TODO: Find a better way to register and load all the locales in Node
if(!Be[a]&&"undefined"!=typeof module&&module&&module.exports)try{b=xe._abbr,require("./locale/"+a),
// because defineLocale currently also sets the global locale, we
// want to undo that for lazy loaded locales
$a(b)}catch(a){}return Be[a]}
// This function will load locale and then set the global locale. If
// no arguments are passed in, it will simply return the current global
// locale key.
function $a(a,b){var c;
// moment.duration._locale = moment._locale = data;
return a&&(c=p(b)?bb(a):_a(a,b),c&&(xe=c)),xe._abbr}function _a(a,b){if(null!==b){var c=Ae;if(b.abbr=a,null!=Be[a])y("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),c=Be[a]._config;else if(null!=b.parentLocale){if(null==Be[b.parentLocale])return Ce[b.parentLocale]||(Ce[b.parentLocale]=[]),Ce[b.parentLocale].push({name:a,config:b}),null;c=Be[b.parentLocale]._config}
// backwards compat for now: also set the locale
// make sure we set the locale AFTER all child locales have been
// created, so we won't end up with the child locale set.
return Be[a]=new C(B(c,b)),Ce[a]&&Ce[a].forEach(function(a){_a(a.name,a.config)}),$a(a),Be[a]}
// useful for testing
return delete Be[a],null}function ab(a,b){if(null!=b){var c,d=Ae;
// MERGE
null!=Be[a]&&(d=Be[a]._config),b=B(d,b),c=new C(b),c.parentLocale=Be[a],Be[a]=c,
// backwards compat for now: also set the locale
$a(a)}else
// pass null for config to unupdate, useful for tests
null!=Be[a]&&(null!=Be[a].parentLocale?Be[a]=Be[a].parentLocale:null!=Be[a]&&delete Be[a]);return Be[a]}
// returns locale data
function bb(a){var b;if(a&&a._locale&&a._locale._abbr&&(a=a._locale._abbr),!a)return xe;if(!c(a)){if(
//short-circuit everything else
b=Za(a))return b;a=[a]}return Ya(a)}function cb(){return wd(Be)}function db(a){var b,c=a._a;return c&&m(a).overflow===-2&&(b=c[be]<0||c[be]>11?be:c[ce]<1||c[ce]>ea(c[ae],c[be])?ce:c[de]<0||c[de]>24||24===c[de]&&(0!==c[ee]||0!==c[fe]||0!==c[ge])?de:c[ee]<0||c[ee]>59?ee:c[fe]<0||c[fe]>59?fe:c[ge]<0||c[ge]>999?ge:-1,m(a)._overflowDayOfYear&&(b<ae||b>ce)&&(b=ce),m(a)._overflowWeeks&&b===-1&&(b=he),m(a)._overflowWeekday&&b===-1&&(b=ie),m(a).overflow=b),a}
// date from iso format
function eb(a){var b,c,d,e,f,g,h=a._i,i=De.exec(h)||Ee.exec(h);if(i){for(m(a).iso=!0,b=0,c=Ge.length;b<c;b++)if(Ge[b][1].exec(i[1])){e=Ge[b][0],d=Ge[b][2]!==!1;break}if(null==e)return void(a._isValid=!1);if(i[3]){for(b=0,c=He.length;b<c;b++)if(He[b][1].exec(i[3])){
// match[2] should be 'T' or space
f=(i[2]||" ")+He[b][0];break}if(null==f)return void(a._isValid=!1)}if(!d&&null!=f)return void(a._isValid=!1);if(i[4]){if(!Fe.exec(i[4]))return void(a._isValid=!1);g="Z"}a._f=e+(f||"")+(g||""),kb(a)}else a._isValid=!1}
// date from iso format or fallback
function fb(b){var c=Ie.exec(b._i);return null!==c?void(b._d=new Date(+c[1])):(eb(b),void(b._isValid===!1&&(delete b._isValid,a.createFromInputFallback(b))))}
// Pick the first defined of two or three arguments.
function gb(a,b,c){return null!=a?a:null!=b?b:c}function hb(b){
// hooks is actually the exported moment object
var c=new Date(a.now());return b._useUTC?[c.getUTCFullYear(),c.getUTCMonth(),c.getUTCDate()]:[c.getFullYear(),c.getMonth(),c.getDate()]}
// convert an array to a date.
// the array should mirror the parameters below
// note: all values past the year are optional and will default to the lowest possible value.
// [year, month, day , hour, minute, second, millisecond]
function ib(a){var b,c,d,e,f=[];if(!a._d){
// Default to current date.
// * if no year, month, day of month are given, default to today
// * if day of month is given, default month and year
// * if month is given, default only year
// * if year is given, don't default anything
for(d=hb(a),
//compute day of the year from weeks and weekdays
a._w&&null==a._a[ce]&&null==a._a[be]&&jb(a),
//if the day of the year is set, figure out what it is
a._dayOfYear&&(e=gb(a._a[ae],d[ae]),a._dayOfYear>pa(e)&&(m(a)._overflowDayOfYear=!0),c=ta(e,0,a._dayOfYear),a._a[be]=c.getUTCMonth(),a._a[ce]=c.getUTCDate()),b=0;b<3&&null==a._a[b];++b)a._a[b]=f[b]=d[b];
// Zero out whatever was not defaulted, including time
for(;b<7;b++)a._a[b]=f[b]=null==a._a[b]?2===b?1:0:a._a[b];
// Check for 24:00:00.000
24===a._a[de]&&0===a._a[ee]&&0===a._a[fe]&&0===a._a[ge]&&(a._nextDay=!0,a._a[de]=0),a._d=(a._useUTC?ta:sa).apply(null,f),
// Apply timezone offset from input. The actual utcOffset can be changed
// with parseZone.
null!=a._tzm&&a._d.setUTCMinutes(a._d.getUTCMinutes()-a._tzm),a._nextDay&&(a._a[de]=24)}}function jb(a){var b,c,d,e,f,g,h,i;if(b=a._w,null!=b.GG||null!=b.W||null!=b.E)f=1,g=4,
// TODO: We need to take the current isoWeekYear, but that depends on
// how we interpret now (local, utc, fixed offset). So create
// a now version of current config (take local/utc/offset flags, and
// create now).
c=gb(b.GG,a._a[ae],wa(sb(),1,4).year),d=gb(b.W,1),e=gb(b.E,1),(e<1||e>7)&&(i=!0);else{f=a._locale._week.dow,g=a._locale._week.doy;var j=wa(sb(),f,g);c=gb(b.gg,a._a[ae],j.year),
// Default to current week.
d=gb(b.w,j.week),null!=b.d?(
// weekday -- low day numbers are considered next week
e=b.d,(e<0||e>6)&&(i=!0)):null!=b.e?(
// local weekday -- counting starts from begining of week
e=b.e+f,(b.e<0||b.e>6)&&(i=!0)):
// default to begining of week
e=f}d<1||d>xa(c,f,g)?m(a)._overflowWeeks=!0:null!=i?m(a)._overflowWeekday=!0:(h=va(c,d,e,f,g),a._a[ae]=h.year,a._dayOfYear=h.dayOfYear)}
// date from string and format string
function kb(b){
// TODO: Move this to another part of the creation flow to prevent circular deps
if(b._f===a.ISO_8601)return void eb(b);b._a=[],m(b).empty=!0;
// This array is used to make a Date, either with `new Date` or `Date.UTC`
var c,d,e,f,g,h=""+b._i,i=h.length,j=0;for(e=Y(b._f,b._locale).match(Fd)||[],c=0;c<e.length;c++)f=e[c],d=(h.match($(f,b))||[])[0],
// console.log('token', token, 'parsedInput', parsedInput,
// 'regex', getParseRegexForToken(token, config));
d&&(g=h.substr(0,h.indexOf(d)),g.length>0&&m(b).unusedInput.push(g),h=h.slice(h.indexOf(d)+d.length),j+=d.length),
// don't parse if it's not a known token
Id[f]?(d?m(b).empty=!1:m(b).unusedTokens.push(f),da(f,d,b)):b._strict&&!d&&m(b).unusedTokens.push(f);
// add remaining unparsed input length to the string
m(b).charsLeftOver=i-j,h.length>0&&m(b).unusedInput.push(h),
// clear _12h flag if hour is <= 12
b._a[de]<=12&&m(b).bigHour===!0&&b._a[de]>0&&(m(b).bigHour=void 0),m(b).parsedDateParts=b._a.slice(0),m(b).meridiem=b._meridiem,
// handle meridiem
b._a[de]=lb(b._locale,b._a[de],b._meridiem),ib(b),db(b)}function lb(a,b,c){var d;
// Fallback
return null==c?b:null!=a.meridiemHour?a.meridiemHour(b,c):null!=a.isPM?(d=a.isPM(c),d&&b<12&&(b+=12),d||12!==b||(b=0),b):b}
// date from string and array of format strings
function mb(a){var b,c,d,e,f;if(0===a._f.length)return m(a).invalidFormat=!0,void(a._d=new Date(NaN));for(e=0;e<a._f.length;e++)f=0,b=q({},a),null!=a._useUTC&&(b._useUTC=a._useUTC),b._f=a._f[e],kb(b),n(b)&&(
// if there is any input that was not parsed add a penalty for that format
f+=m(b).charsLeftOver,
//or tokens
f+=10*m(b).unusedTokens.length,m(b).score=f,(null==d||f<d)&&(d=f,c=b));j(a,c||b)}function nb(a){if(!a._d){var b=L(a._i);a._a=h([b.year,b.month,b.day||b.date,b.hour,b.minute,b.second,b.millisecond],function(a){return a&&parseInt(a,10)}),ib(a)}}function ob(a){var b=new r(db(pb(a)));
// Adding is smart enough around DST
return b._nextDay&&(b.add(1,"d"),b._nextDay=void 0),b}function pb(a){var b=a._i,d=a._f;return a._locale=a._locale||bb(a._l),null===b||void 0===d&&""===b?o({nullInput:!0}):("string"==typeof b&&(a._i=b=a._locale.preparse(b)),s(b)?new r(db(b)):(g(b)?a._d=b:c(d)?mb(a):d?kb(a):qb(a),n(a)||(a._d=null),a))}function qb(b){var d=b._i;void 0===d?b._d=new Date(a.now()):g(d)?b._d=new Date(d.valueOf()):"string"==typeof d?fb(b):c(d)?(b._a=h(d.slice(0),function(a){return parseInt(a,10)}),ib(b)):"object"==typeof d?nb(b):f(d)?
// from milliseconds
b._d=new Date(d):a.createFromInputFallback(b)}function rb(a,b,f,g,h){var i={};
// object construction must be done this way.
// https://github.com/moment/moment/issues/1423
return f!==!0&&f!==!1||(g=f,f=void 0),(d(a)&&e(a)||c(a)&&0===a.length)&&(a=void 0),i._isAMomentObject=!0,i._useUTC=i._isUTC=h,i._l=f,i._i=a,i._f=b,i._strict=g,ob(i)}function sb(a,b,c,d){return rb(a,b,c,d,!1)}
// Pick a moment m from moments so that m[fn](other) is true for all
// other. This relies on the function fn to be transitive.
//
// moments should either be an array of moment objects or an array, whose
// first element is an array of moment objects.
function tb(a,b){var d,e;if(1===b.length&&c(b[0])&&(b=b[0]),!b.length)return sb();for(d=b[0],e=1;e<b.length;++e)b[e].isValid()&&!b[e][a](d)||(d=b[e]);return d}
// TODO: Use [].sort instead?
function ub(){var a=[].slice.call(arguments,0);return tb("isBefore",a)}function vb(){var a=[].slice.call(arguments,0);return tb("isAfter",a)}function wb(a){var b=L(a),c=b.year||0,d=b.quarter||0,e=b.month||0,f=b.week||0,g=b.day||0,h=b.hour||0,i=b.minute||0,j=b.second||0,k=b.millisecond||0;
// representation for dateAddRemove
this._milliseconds=+k+1e3*j+// 1000
6e4*i+// 1000 * 60
1e3*h*60*60,//using 1000 * 60 * 60 instead of 36e5 to avoid floating point rounding errors https://github.com/moment/moment/issues/2978
// Because of dateAddRemove treats 24 hours as different from a
// day when working around DST, we need to store them separately
this._days=+g+7*f,
// It is impossible translate months into days without knowing
// which months you are are talking about, so we have to store
// it separately.
this._months=+e+3*d+12*c,this._data={},this._locale=bb(),this._bubble()}function xb(a){return a instanceof wb}function yb(a){return a<0?Math.round(-1*a)*-1:Math.round(a)}
// FORMATTING
function zb(a,b){U(a,0,0,function(){var a=this.utcOffset(),c="+";return a<0&&(a=-a,c="-"),c+T(~~(a/60),2)+b+T(~~a%60,2)})}function Ab(a,b){var c=(b||"").match(a);if(null===c)return null;var d=c[c.length-1]||[],e=(d+"").match(Me)||["-",0,0],f=+(60*e[1])+u(e[2]);return 0===f?0:"+"===e[0]?f:-f}
// Return a moment from input, that is local/utc/zone equivalent to model.
function Bb(b,c){var d,e;
// Use low-level api, because this fn is low-level api.
return c._isUTC?(d=c.clone(),e=(s(b)||g(b)?b.valueOf():sb(b).valueOf())-d.valueOf(),d._d.setTime(d._d.valueOf()+e),a.updateOffset(d,!1),d):sb(b).local()}function Cb(a){
// On Firefox.24 Date#getTimezoneOffset returns a floating point.
// https://github.com/moment/moment/pull/1871
return 15*-Math.round(a._d.getTimezoneOffset()/15)}
// MOMENTS
// keepLocalTime = true means only change the timezone, without
// affecting the local hour. So 5:31:26 +0300 --[utcOffset(2, true)]-->
// 5:31:26 +0200 It is possible that 5:31:26 doesn't exist with offset
// +0200, so we adjust the time as needed, to be valid.
//
// Keeping the time actually adds/subtracts (one hour)
// from the actual represented time. That is why we call updateOffset
// a second time. In case it wants us to change the offset again
// _changeInProgress == true case, then we have to adjust, because
// there is no such time in the given timezone.
function Db(b,c){var d,e=this._offset||0;if(!this.isValid())return null!=b?this:NaN;if(null!=b){if("string"==typeof b){if(b=Ab(Xd,b),null===b)return this}else Math.abs(b)<16&&(b=60*b);return!this._isUTC&&c&&(d=Cb(this)),this._offset=b,this._isUTC=!0,null!=d&&this.add(d,"m"),e!==b&&(!c||this._changeInProgress?Tb(this,Ob(b-e,"m"),1,!1):this._changeInProgress||(this._changeInProgress=!0,a.updateOffset(this,!0),this._changeInProgress=null)),this}return this._isUTC?e:Cb(this)}function Eb(a,b){return null!=a?("string"!=typeof a&&(a=-a),this.utcOffset(a,b),this):-this.utcOffset()}function Fb(a){return this.utcOffset(0,a)}function Gb(a){return this._isUTC&&(this.utcOffset(0,a),this._isUTC=!1,a&&this.subtract(Cb(this),"m")),this}function Hb(){if(null!=this._tzm)this.utcOffset(this._tzm);else if("string"==typeof this._i){var a=Ab(Wd,this._i);null!=a?this.utcOffset(a):this.utcOffset(0,!0)}return this}function Ib(a){return!!this.isValid()&&(a=a?sb(a).utcOffset():0,(this.utcOffset()-a)%60===0)}function Jb(){return this.utcOffset()>this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()}function Kb(){if(!p(this._isDSTShifted))return this._isDSTShifted;var a={};if(q(a,this),a=pb(a),a._a){var b=a._isUTC?k(a._a):sb(a._a);this._isDSTShifted=this.isValid()&&v(a._a,b.toArray())>0}else this._isDSTShifted=!1;return this._isDSTShifted}function Lb(){return!!this.isValid()&&!this._isUTC}function Mb(){return!!this.isValid()&&this._isUTC}function Nb(){return!!this.isValid()&&(this._isUTC&&0===this._offset)}function Ob(a,b){var c,d,e,g=a,
// matching against regexp is expensive, do it on demand
h=null;// checks for null or undefined
return xb(a)?g={ms:a._milliseconds,d:a._days,M:a._months}:f(a)?(g={},b?g[b]=a:g.milliseconds=a):(h=Ne.exec(a))?(c="-"===h[1]?-1:1,g={y:0,d:u(h[ce])*c,h:u(h[de])*c,m:u(h[ee])*c,s:u(h[fe])*c,ms:u(yb(1e3*h[ge]))*c}):(h=Oe.exec(a))?(c="-"===h[1]?-1:1,g={y:Pb(h[2],c),M:Pb(h[3],c),w:Pb(h[4],c),d:Pb(h[5],c),h:Pb(h[6],c),m:Pb(h[7],c),s:Pb(h[8],c)}):null==g?g={}:"object"==typeof g&&("from"in g||"to"in g)&&(e=Rb(sb(g.from),sb(g.to)),g={},g.ms=e.milliseconds,g.M=e.months),d=new wb(g),xb(a)&&i(a,"_locale")&&(d._locale=a._locale),d}function Pb(a,b){
// We'd normally use ~~inp for this, but unfortunately it also
// converts floats to ints.
// inp may be undefined, so careful calling replace on it.
var c=a&&parseFloat(a.replace(",","."));
// apply sign while we're at it
return(isNaN(c)?0:c)*b}function Qb(a,b){var c={milliseconds:0,months:0};return c.months=b.month()-a.month()+12*(b.year()-a.year()),a.clone().add(c.months,"M").isAfter(b)&&--c.months,c.milliseconds=+b-+a.clone().add(c.months,"M"),c}function Rb(a,b){var c;return a.isValid()&&b.isValid()?(b=Bb(b,a),a.isBefore(b)?c=Qb(a,b):(c=Qb(b,a),c.milliseconds=-c.milliseconds,c.months=-c.months),c):{milliseconds:0,months:0}}
// TODO: remove 'name' arg after deprecation is removed
function Sb(a,b){return function(c,d){var e,f;
//invert the arguments, but complain about it
return null===d||isNaN(+d)||(y(b,"moment()."+b+"(period, number) is deprecated. Please use moment()."+b+"(number, period). See http://momentjs.com/guides/#/warnings/add-inverted-param/ for more info."),f=c,c=d,d=f),c="string"==typeof c?+c:c,e=Ob(c,d),Tb(this,e,a),this}}function Tb(b,c,d,e){var f=c._milliseconds,g=yb(c._days),h=yb(c._months);b.isValid()&&(e=null==e||e,f&&b._d.setTime(b._d.valueOf()+f*d),g&&Q(b,"Date",P(b,"Date")+g*d),h&&ja(b,P(b,"Month")+h*d),e&&a.updateOffset(b,g||h))}function Ub(a,b){var c=a.diff(b,"days",!0);return c<-6?"sameElse":c<-1?"lastWeek":c<0?"lastDay":c<1?"sameDay":c<2?"nextDay":c<7?"nextWeek":"sameElse"}function Vb(b,c){
// We want to compare the start of today, vs this.
// Getting start-of-today depends on whether we're local/utc/offset or not.
var d=b||sb(),e=Bb(d,this).startOf("day"),f=a.calendarFormat(this,e)||"sameElse",g=c&&(z(c[f])?c[f].call(this,d):c[f]);return this.format(g||this.localeData().calendar(f,this,sb(d)))}function Wb(){return new r(this)}function Xb(a,b){var c=s(a)?a:sb(a);return!(!this.isValid()||!c.isValid())&&(b=K(p(b)?"millisecond":b),"millisecond"===b?this.valueOf()>c.valueOf():c.valueOf()<this.clone().startOf(b).valueOf())}function Yb(a,b){var c=s(a)?a:sb(a);return!(!this.isValid()||!c.isValid())&&(b=K(p(b)?"millisecond":b),"millisecond"===b?this.valueOf()<c.valueOf():this.clone().endOf(b).valueOf()<c.valueOf())}function Zb(a,b,c,d){return d=d||"()",("("===d[0]?this.isAfter(a,c):!this.isBefore(a,c))&&(")"===d[1]?this.isBefore(b,c):!this.isAfter(b,c))}function $b(a,b){var c,d=s(a)?a:sb(a);return!(!this.isValid()||!d.isValid())&&(b=K(b||"millisecond"),"millisecond"===b?this.valueOf()===d.valueOf():(c=d.valueOf(),this.clone().startOf(b).valueOf()<=c&&c<=this.clone().endOf(b).valueOf()))}function _b(a,b){return this.isSame(a,b)||this.isAfter(a,b)}function ac(a,b){return this.isSame(a,b)||this.isBefore(a,b)}function bc(a,b,c){var d,e,f,g;// 1000
// 1000 * 60
// 1000 * 60 * 60
// 1000 * 60 * 60 * 24, negate dst
// 1000 * 60 * 60 * 24 * 7, negate dst
return this.isValid()?(d=Bb(a,this),d.isValid()?(e=6e4*(d.utcOffset()-this.utcOffset()),b=K(b),"year"===b||"month"===b||"quarter"===b?(g=cc(this,d),"quarter"===b?g/=3:"year"===b&&(g/=12)):(f=this-d,g="second"===b?f/1e3:"minute"===b?f/6e4:"hour"===b?f/36e5:"day"===b?(f-e)/864e5:"week"===b?(f-e)/6048e5:f),c?g:t(g)):NaN):NaN}function cc(a,b){
// difference in months
var c,d,e=12*(b.year()-a.year())+(b.month()-a.month()),
// b is in (anchor - 1 month, anchor + 1 month)
f=a.clone().add(e,"months");
//check for negative zero, return zero if negative zero
// linear across the month
// linear across the month
return b-f<0?(c=a.clone().add(e-1,"months"),d=(b-f)/(f-c)):(c=a.clone().add(e+1,"months"),d=(b-f)/(c-f)),-(e+d)||0}function dc(){return this.clone().locale("en").format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ")}function ec(){var a=this.clone().utc();return 0<a.year()&&a.year()<=9999?z(Date.prototype.toISOString)?this.toDate().toISOString():X(a,"YYYY-MM-DD[T]HH:mm:ss.SSS[Z]"):X(a,"YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]")}/**
* Return a human readable representation of a moment that can
* also be evaluated to get a new moment which is the same
*
* @link https://nodejs.org/dist/latest/docs/api/util.html#util_custom_inspect_function_on_objects
*/
function fc(){if(!this.isValid())return"moment.invalid(/* "+this._i+" */)";var a="moment",b="";this.isLocal()||(a=0===this.utcOffset()?"moment.utc":"moment.parseZone",b="Z");var c="["+a+'("]',d=0<this.year()&&this.year()<=9999?"YYYY":"YYYYYY",e="-MM-DD[T]HH:mm:ss.SSS",f=b+'[")]';return this.format(c+d+e+f)}function gc(b){b||(b=this.isUtc()?a.defaultFormatUtc:a.defaultFormat);var c=X(this,b);return this.localeData().postformat(c)}function hc(a,b){return this.isValid()&&(s(a)&&a.isValid()||sb(a).isValid())?Ob({to:this,from:a}).locale(this.locale()).humanize(!b):this.localeData().invalidDate()}function ic(a){return this.from(sb(),a)}function jc(a,b){return this.isValid()&&(s(a)&&a.isValid()||sb(a).isValid())?Ob({from:this,to:a}).locale(this.locale()).humanize(!b):this.localeData().invalidDate()}function kc(a){return this.to(sb(),a)}
// If passed a locale key, it will set the locale for this
// instance. Otherwise, it will return the locale configuration
// variables for this instance.
function lc(a){var b;return void 0===a?this._locale._abbr:(b=bb(a),null!=b&&(this._locale=b),this)}function mc(){return this._locale}function nc(a){
// the following switch intentionally omits break keywords
// to utilize falling through the cases.
switch(a=K(a)){case"year":this.month(0);/* falls through */
case"quarter":case"month":this.date(1);/* falls through */
case"week":case"isoWeek":case"day":case"date":this.hours(0);/* falls through */
case"hour":this.minutes(0);/* falls through */
case"minute":this.seconds(0);/* falls through */
case"second":this.milliseconds(0)}
// weeks are a special case
// quarters are also special
return"week"===a&&this.weekday(0),"isoWeek"===a&&this.isoWeekday(1),"quarter"===a&&this.month(3*Math.floor(this.month()/3)),this}function oc(a){
// 'date' is an alias for 'day', so it should be considered as such.
return a=K(a),void 0===a||"millisecond"===a?this:("date"===a&&(a="day"),this.startOf(a).add(1,"isoWeek"===a?"week":a).subtract(1,"ms"))}function pc(){return this._d.valueOf()-6e4*(this._offset||0)}function qc(){return Math.floor(this.valueOf()/1e3)}function rc(){return new Date(this.valueOf())}function sc(){var a=this;return[a.year(),a.month(),a.date(),a.hour(),a.minute(),a.second(),a.millisecond()]}function tc(){var a=this;return{years:a.year(),months:a.month(),date:a.date(),hours:a.hours(),minutes:a.minutes(),seconds:a.seconds(),milliseconds:a.milliseconds()}}function uc(){
// new Date(NaN).toJSON() === null
return this.isValid()?this.toISOString():null}function vc(){return n(this)}function wc(){return j({},m(this))}function xc(){return m(this).overflow}function yc(){return{input:this._i,format:this._f,locale:this._locale,isUTC:this._isUTC,strict:this._strict}}function zc(a,b){U(0,[a,a.length],0,b)}
// MOMENTS
function Ac(a){return Ec.call(this,a,this.week(),this.weekday(),this.localeData()._week.dow,this.localeData()._week.doy)}function Bc(a){return Ec.call(this,a,this.isoWeek(),this.isoWeekday(),1,4)}function Cc(){return xa(this.year(),1,4)}function Dc(){var a=this.localeData()._week;return xa(this.year(),a.dow,a.doy)}function Ec(a,b,c,d,e){var f;return null==a?wa(this,d,e).year:(f=xa(a,d,e),b>f&&(b=f),Fc.call(this,a,b,c,d,e))}function Fc(a,b,c,d,e){var f=va(a,b,c,d,e),g=ta(f.year,0,f.dayOfYear);return this.year(g.getUTCFullYear()),this.month(g.getUTCMonth()),this.date(g.getUTCDate()),this}
// MOMENTS
function Gc(a){return null==a?Math.ceil((this.month()+1)/3):this.month(3*(a-1)+this.month()%3)}
// HELPERS
// MOMENTS
function Hc(a){var b=Math.round((this.clone().startOf("day")-this.clone().startOf("year"))/864e5)+1;return null==a?b:this.add(a-b,"d")}function Ic(a,b){b[ge]=u(1e3*("0."+a))}
// MOMENTS
function Jc(){return this._isUTC?"UTC":""}function Kc(){return this._isUTC?"Coordinated Universal Time":""}function Lc(a){return sb(1e3*a)}function Mc(){return sb.apply(null,arguments).parseZone()}function Nc(a){return a}function Oc(a,b,c,d){var e=bb(),f=k().set(d,b);return e[c](f,a)}function Pc(a,b,c){if(f(a)&&(b=a,a=void 0),a=a||"",null!=b)return Oc(a,b,c,"month");var d,e=[];for(d=0;d<12;d++)e[d]=Oc(a,d,c,"month");return e}
// ()
// (5)
// (fmt, 5)
// (fmt)
// (true)
// (true, 5)
// (true, fmt, 5)
// (true, fmt)
function Qc(a,b,c,d){"boolean"==typeof a?(f(b)&&(c=b,b=void 0),b=b||""):(b=a,c=b,a=!1,f(b)&&(c=b,b=void 0),b=b||"");var e=bb(),g=a?e._week.dow:0;if(null!=c)return Oc(b,(c+g)%7,d,"day");var h,i=[];for(h=0;h<7;h++)i[h]=Oc(b,(h+g)%7,d,"day");return i}function Rc(a,b){return Pc(a,b,"months")}function Sc(a,b){return Pc(a,b,"monthsShort")}function Tc(a,b,c){return Qc(a,b,c,"weekdays")}function Uc(a,b,c){return Qc(a,b,c,"weekdaysShort")}function Vc(a,b,c){return Qc(a,b,c,"weekdaysMin")}function Wc(){var a=this._data;return this._milliseconds=Ze(this._milliseconds),this._days=Ze(this._days),this._months=Ze(this._months),a.milliseconds=Ze(a.milliseconds),a.seconds=Ze(a.seconds),a.minutes=Ze(a.minutes),a.hours=Ze(a.hours),a.months=Ze(a.months),a.years=Ze(a.years),this}function Xc(a,b,c,d){var e=Ob(b,c);return a._milliseconds+=d*e._milliseconds,a._days+=d*e._days,a._months+=d*e._months,a._bubble()}
// supports only 2.0-style add(1, 's') or add(duration)
function Yc(a,b){return Xc(this,a,b,1)}
// supports only 2.0-style subtract(1, 's') or subtract(duration)
function Zc(a,b){return Xc(this,a,b,-1)}function $c(a){return a<0?Math.floor(a):Math.ceil(a)}function _c(){var a,b,c,d,e,f=this._milliseconds,g=this._days,h=this._months,i=this._data;
// if we have a mix of positive and negative values, bubble down first
// check: https://github.com/moment/moment/issues/2166
// The following code bubbles up values, see the tests for
// examples of what that means.
// convert days to months
// 12 months -> 1 year
return f>=0&&g>=0&&h>=0||f<=0&&g<=0&&h<=0||(f+=864e5*$c(bd(h)+g),g=0,h=0),i.milliseconds=f%1e3,a=t(f/1e3),i.seconds=a%60,b=t(a/60),i.minutes=b%60,c=t(b/60),i.hours=c%24,g+=t(c/24),e=t(ad(g)),h+=e,g-=$c(bd(e)),d=t(h/12),h%=12,i.days=g,i.months=h,i.years=d,this}function ad(a){
// 400 years have 146097 days (taking into account leap year rules)
// 400 years have 12 months === 4800
return 4800*a/146097}function bd(a){
// the reverse of daysToMonths
return 146097*a/4800}function cd(a){var b,c,d=this._milliseconds;if(a=K(a),"month"===a||"year"===a)return b=this._days+d/864e5,c=this._months+ad(b),"month"===a?c:c/12;switch(
// handle milliseconds separately because of floating point math errors (issue #1867)
b=this._days+Math.round(bd(this._months)),a){case"week":return b/7+d/6048e5;case"day":return b+d/864e5;case"hour":return 24*b+d/36e5;case"minute":return 1440*b+d/6e4;case"second":return 86400*b+d/1e3;
// Math.floor prevents floating point math errors here
case"millisecond":return Math.floor(864e5*b)+d;default:throw new Error("Unknown unit "+a)}}
// TODO: Use this.as('ms')?
function dd(){return this._milliseconds+864e5*this._days+this._months%12*2592e6+31536e6*u(this._months/12)}function ed(a){return function(){return this.as(a)}}function fd(a){return a=K(a),this[a+"s"]()}function gd(a){return function(){return this._data[a]}}function hd(){return t(this.days()/7)}
// helper function for moment.fn.from, moment.fn.fromNow, and moment.duration.fn.humanize
function id(a,b,c,d,e){return e.relativeTime(b||1,!!c,a,d)}function jd(a,b,c){var d=Ob(a).abs(),e=of(d.as("s")),f=of(d.as("m")),g=of(d.as("h")),h=of(d.as("d")),i=of(d.as("M")),j=of(d.as("y")),k=e<pf.s&&["s",e]||f<=1&&["m"]||f<pf.m&&["mm",f]||g<=1&&["h"]||g<pf.h&&["hh",g]||h<=1&&["d"]||h<pf.d&&["dd",h]||i<=1&&["M"]||i<pf.M&&["MM",i]||j<=1&&["y"]||["yy",j];return k[2]=b,k[3]=+a>0,k[4]=c,id.apply(null,k)}
// This function allows you to set the rounding function for relative time strings
function kd(a){return void 0===a?of:"function"==typeof a&&(of=a,!0)}
// This function allows you to set a threshold for relative time strings
function ld(a,b){return void 0!==pf[a]&&(void 0===b?pf[a]:(pf[a]=b,!0))}function md(a){var b=this.localeData(),c=jd(this,!a,b);return a&&(c=b.pastFuture(+this,c)),b.postformat(c)}function nd(){
// for ISO strings we do not use the normal bubbling rules:
// * milliseconds bubble up until they become hours
// * days do not bubble at all
// * months bubble up until they become years
// This is because there is no context-free conversion between hours and days
// (think of clock changes)
// and also not between days and months (28-31 days per month)
var a,b,c,d=qf(this._milliseconds)/1e3,e=qf(this._days),f=qf(this._months);
// 3600 seconds -> 60 minutes -> 1 hour
a=t(d/60),b=t(a/60),d%=60,a%=60,
// 12 months -> 1 year
c=t(f/12),f%=12;
// inspired by https://github.com/dordille/moment-isoduration/blob/master/moment.isoduration.js
var g=c,h=f,i=e,j=b,k=a,l=d,m=this.asSeconds();return m?(m<0?"-":"")+"P"+(g?g+"Y":"")+(h?h+"M":"")+(i?i+"D":"")+(j||k||l?"T":"")+(j?j+"H":"")+(k?k+"M":"")+(l?l+"S":""):"P0D"}var od,pd;pd=Array.prototype.some?Array.prototype.some:function(a){for(var b=Object(this),c=b.length>>>0,d=0;d<c;d++)if(d in b&&a.call(this,b[d],d,b))return!0;return!1};var qd=pd,rd=a.momentProperties=[],sd=!1,td={};a.suppressDeprecationWarnings=!1,a.deprecationHandler=null;var ud;ud=Object.keys?Object.keys:function(a){var b,c=[];for(b in a)i(a,b)&&c.push(b);return c};var vd,wd=ud,xd={sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},yd={LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},zd="Invalid date",Ad="%d",Bd=/\d{1,2}/,Cd={future:"in %s",past:"%s ago",s:"a few seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},Dd={},Ed={},Fd=/(\[[^\[]*\])|(\\)?([Hh]mm(ss)?|Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Qo?|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|kk?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g,Gd=/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g,Hd={},Id={},Jd=/\d/,Kd=/\d\d/,Ld=/\d{3}/,Md=/\d{4}/,Nd=/[+-]?\d{6}/,Od=/\d\d?/,Pd=/\d\d\d\d?/,Qd=/\d\d\d\d\d\d?/,Rd=/\d{1,3}/,Sd=/\d{1,4}/,Td=/[+-]?\d{1,6}/,Ud=/\d+/,Vd=/[+-]?\d+/,Wd=/Z|[+-]\d\d:?\d\d/gi,Xd=/Z|[+-]\d\d(?::?\d\d)?/gi,Yd=/[+-]?\d+(\.\d{1,3})?/,Zd=/[0-9]*['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF\/]+(\s*?[\u0600-\u06FF]+){1,2}/i,$d={},_d={},ae=0,be=1,ce=2,de=3,ee=4,fe=5,ge=6,he=7,ie=8;vd=Array.prototype.indexOf?Array.prototype.indexOf:function(a){
// I know
var b;for(b=0;b<this.length;++b)if(this[b]===a)return b;return-1};var je=vd;
// FORMATTING
U("M",["MM",2],"Mo",function(){return this.month()+1}),U("MMM",0,0,function(a){return this.localeData().monthsShort(this,a)}),U("MMMM",0,0,function(a){return this.localeData().months(this,a)}),
// ALIASES
J("month","M"),
// PRIORITY
M("month",8),
// PARSING
Z("M",Od),Z("MM",Od,Kd),Z("MMM",function(a,b){return b.monthsShortRegex(a)}),Z("MMMM",function(a,b){return b.monthsRegex(a)}),ba(["M","MM"],function(a,b){b[be]=u(a)-1}),ba(["MMM","MMMM"],function(a,b,c,d){var e=c._locale.monthsParse(a,d,c._strict);
// if we didn't find a month name, mark the date as invalid.
null!=e?b[be]=e:m(c).invalidMonth=a});
// LOCALES
var ke=/D[oD]?(\[[^\[\]]*\]|\s)+MMMM?/,le="January_February_March_April_May_June_July_August_September_October_November_December".split("_"),me="Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"),ne=Zd,oe=Zd;
// FORMATTING
U("Y",0,0,function(){var a=this.year();return a<=9999?""+a:"+"+a}),U(0,["YY",2],0,function(){return this.year()%100}),U(0,["YYYY",4],0,"year"),U(0,["YYYYY",5],0,"year"),U(0,["YYYYYY",6,!0],0,"year"),
// ALIASES
J("year","y"),
// PRIORITIES
M("year",1),
// PARSING
Z("Y",Vd),Z("YY",Od,Kd),Z("YYYY",Sd,Md),Z("YYYYY",Td,Nd),Z("YYYYYY",Td,Nd),ba(["YYYYY","YYYYYY"],ae),ba("YYYY",function(b,c){c[ae]=2===b.length?a.parseTwoDigitYear(b):u(b)}),ba("YY",function(b,c){c[ae]=a.parseTwoDigitYear(b)}),ba("Y",function(a,b){b[ae]=parseInt(a,10)}),
// HOOKS
a.parseTwoDigitYear=function(a){return u(a)+(u(a)>68?1900:2e3)};
// MOMENTS
var pe=O("FullYear",!0);
// FORMATTING
U("w",["ww",2],"wo","week"),U("W",["WW",2],"Wo","isoWeek"),
// ALIASES
J("week","w"),J("isoWeek","W"),
// PRIORITIES
M("week",5),M("isoWeek",5),
// PARSING
Z("w",Od),Z("ww",Od,Kd),Z("W",Od),Z("WW",Od,Kd),ca(["w","ww","W","WW"],function(a,b,c,d){b[d.substr(0,1)]=u(a)});var qe={dow:0,// Sunday is the first day of the week.
doy:6};
// FORMATTING
U("d",0,"do","day"),U("dd",0,0,function(a){return this.localeData().weekdaysMin(this,a)}),U("ddd",0,0,function(a){return this.localeData().weekdaysShort(this,a)}),U("dddd",0,0,function(a){return this.localeData().weekdays(this,a)}),U("e",0,0,"weekday"),U("E",0,0,"isoWeekday"),
// ALIASES
J("day","d"),J("weekday","e"),J("isoWeekday","E"),
// PRIORITY
M("day",11),M("weekday",11),M("isoWeekday",11),
// PARSING
Z("d",Od),Z("e",Od),Z("E",Od),Z("dd",function(a,b){return b.weekdaysMinRegex(a)}),Z("ddd",function(a,b){return b.weekdaysShortRegex(a)}),Z("dddd",function(a,b){return b.weekdaysRegex(a)}),ca(["dd","ddd","dddd"],function(a,b,c,d){var e=c._locale.weekdaysParse(a,d,c._strict);
// if we didn't get a weekday name, mark the date as invalid
null!=e?b.d=e:m(c).invalidWeekday=a}),ca(["d","e","E"],function(a,b,c,d){b[d]=u(a)});
// LOCALES
var re="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),se="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),te="Su_Mo_Tu_We_Th_Fr_Sa".split("_"),ue=Zd,ve=Zd,we=Zd;U("H",["HH",2],0,"hour"),U("h",["hh",2],0,Ra),U("k",["kk",2],0,Sa),U("hmm",0,0,function(){return""+Ra.apply(this)+T(this.minutes(),2)}),U("hmmss",0,0,function(){return""+Ra.apply(this)+T(this.minutes(),2)+T(this.seconds(),2)}),U("Hmm",0,0,function(){return""+this.hours()+T(this.minutes(),2)}),U("Hmmss",0,0,function(){return""+this.hours()+T(this.minutes(),2)+T(this.seconds(),2)}),Ta("a",!0),Ta("A",!1),
// ALIASES
J("hour","h"),
// PRIORITY
M("hour",13),Z("a",Ua),Z("A",Ua),Z("H",Od),Z("h",Od),Z("HH",Od,Kd),Z("hh",Od,Kd),Z("hmm",Pd),Z("hmmss",Qd),Z("Hmm",Pd),Z("Hmmss",Qd),ba(["H","HH"],de),ba(["a","A"],function(a,b,c){c._isPm=c._locale.isPM(a),c._meridiem=a}),ba(["h","hh"],function(a,b,c){b[de]=u(a),m(c).bigHour=!0}),ba("hmm",function(a,b,c){var d=a.length-2;b[de]=u(a.substr(0,d)),b[ee]=u(a.substr(d)),m(c).bigHour=!0}),ba("hmmss",function(a,b,c){var d=a.length-4,e=a.length-2;b[de]=u(a.substr(0,d)),b[ee]=u(a.substr(d,2)),b[fe]=u(a.substr(e)),m(c).bigHour=!0}),ba("Hmm",function(a,b,c){var d=a.length-2;b[de]=u(a.substr(0,d)),b[ee]=u(a.substr(d))}),ba("Hmmss",function(a,b,c){var d=a.length-4,e=a.length-2;b[de]=u(a.substr(0,d)),b[ee]=u(a.substr(d,2)),b[fe]=u(a.substr(e))});var xe,ye=/[ap]\.?m?\.?/i,ze=O("Hours",!0),Ae={calendar:xd,longDateFormat:yd,invalidDate:zd,ordinal:Ad,ordinalParse:Bd,relativeTime:Cd,months:le,monthsShort:me,week:qe,weekdays:re,weekdaysMin:te,weekdaysShort:se,meridiemParse:ye},Be={},Ce={},De=/^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,Ee=/^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,Fe=/Z|[+-]\d\d(?::?\d\d)?/,Ge=[["YYYYYY-MM-DD",/[+-]\d{6}-\d\d-\d\d/],["YYYY-MM-DD",/\d{4}-\d\d-\d\d/],["GGGG-[W]WW-E",/\d{4}-W\d\d-\d/],["GGGG-[W]WW",/\d{4}-W\d\d/,!1],["YYYY-DDD",/\d{4}-\d{3}/],["YYYY-MM",/\d{4}-\d\d/,!1],["YYYYYYMMDD",/[+-]\d{10}/],["YYYYMMDD",/\d{8}/],
// YYYYMM is NOT allowed by the standard
["GGGG[W]WWE",/\d{4}W\d{3}/],["GGGG[W]WW",/\d{4}W\d{2}/,!1],["YYYYDDD",/\d{7}/]],He=[["HH:mm:ss.SSSS",/\d\d:\d\d:\d\d\.\d+/],["HH:mm:ss,SSSS",/\d\d:\d\d:\d\d,\d+/],["HH:mm:ss",/\d\d:\d\d:\d\d/],["HH:mm",/\d\d:\d\d/],["HHmmss.SSSS",/\d\d\d\d\d\d\.\d+/],["HHmmss,SSSS",/\d\d\d\d\d\d,\d+/],["HHmmss",/\d\d\d\d\d\d/],["HHmm",/\d\d\d\d/],["HH",/\d\d/]],Ie=/^\/?Date\((\-?\d+)/i;a.createFromInputFallback=x("value provided is not in a recognized ISO format. moment construction falls back to js Date(), which is not reliable across all browsers and versions. Non ISO date formats are discouraged and will be removed in an upcoming major release. Please refer to http://momentjs.com/guides/#/warnings/js-date/ for more info.",function(a){a._d=new Date(a._i+(a._useUTC?" UTC":""))}),
// constant that refers to the ISO standard
a.ISO_8601=function(){};var Je=x("moment().min is deprecated, use moment.max instead. http://momentjs.com/guides/#/warnings/min-max/",function(){var a=sb.apply(null,arguments);return this.isValid()&&a.isValid()?a<this?this:a:o()}),Ke=x("moment().max is deprecated, use moment.min instead. http://momentjs.com/guides/#/warnings/min-max/",function(){var a=sb.apply(null,arguments);return this.isValid()&&a.isValid()?a>this?this:a:o()}),Le=function(){return Date.now?Date.now():+new Date};zb("Z",":"),zb("ZZ",""),
// PARSING
Z("Z",Xd),Z("ZZ",Xd),ba(["Z","ZZ"],function(a,b,c){c._useUTC=!0,c._tzm=Ab(Xd,a)});
// HELPERS
// timezone chunker
// '+10:00' > ['10', '00']
// '-1530' > ['-15', '30']
var Me=/([\+\-]|\d\d)/gi;
// HOOKS
// This function will be called whenever a moment is mutated.
// It is intended to keep the offset in sync with the timezone.
a.updateOffset=function(){};
// ASP.NET json date format regex
var Ne=/^(\-)?(?:(\d*)[. ])?(\d+)\:(\d+)(?:\:(\d+)(\.\d*)?)?$/,Oe=/^(-)?P(?:(-?[0-9,.]*)Y)?(?:(-?[0-9,.]*)M)?(?:(-?[0-9,.]*)W)?(?:(-?[0-9,.]*)D)?(?:T(?:(-?[0-9,.]*)H)?(?:(-?[0-9,.]*)M)?(?:(-?[0-9,.]*)S)?)?$/;Ob.fn=wb.prototype;var Pe=Sb(1,"add"),Qe=Sb(-1,"subtract");a.defaultFormat="YYYY-MM-DDTHH:mm:ssZ",a.defaultFormatUtc="YYYY-MM-DDTHH:mm:ss[Z]";var Re=x("moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.",function(a){return void 0===a?this.localeData():this.locale(a)});
// FORMATTING
U(0,["gg",2],0,function(){return this.weekYear()%100}),U(0,["GG",2],0,function(){return this.isoWeekYear()%100}),zc("gggg","weekYear"),zc("ggggg","weekYear"),zc("GGGG","isoWeekYear"),zc("GGGGG","isoWeekYear"),
// ALIASES
J("weekYear","gg"),J("isoWeekYear","GG"),
// PRIORITY
M("weekYear",1),M("isoWeekYear",1),
// PARSING
Z("G",Vd),Z("g",Vd),Z("GG",Od,Kd),Z("gg",Od,Kd),Z("GGGG",Sd,Md),Z("gggg",Sd,Md),Z("GGGGG",Td,Nd),Z("ggggg",Td,Nd),ca(["gggg","ggggg","GGGG","GGGGG"],function(a,b,c,d){b[d.substr(0,2)]=u(a)}),ca(["gg","GG"],function(b,c,d,e){c[e]=a.parseTwoDigitYear(b)}),
// FORMATTING
U("Q",0,"Qo","quarter"),
// ALIASES
J("quarter","Q"),
// PRIORITY
M("quarter",7),
// PARSING
Z("Q",Jd),ba("Q",function(a,b){b[be]=3*(u(a)-1)}),
// FORMATTING
U("D",["DD",2],"Do","date"),
// ALIASES
J("date","D"),
// PRIOROITY
M("date",9),
// PARSING
Z("D",Od),Z("DD",Od,Kd),Z("Do",function(a,b){return a?b._ordinalParse:b._ordinalParseLenient}),ba(["D","DD"],ce),ba("Do",function(a,b){b[ce]=u(a.match(Od)[0],10)});
// MOMENTS
var Se=O("Date",!0);
// FORMATTING
U("DDD",["DDDD",3],"DDDo","dayOfYear"),
// ALIASES
J("dayOfYear","DDD"),
// PRIORITY
M("dayOfYear",4),
// PARSING
Z("DDD",Rd),Z("DDDD",Ld),ba(["DDD","DDDD"],function(a,b,c){c._dayOfYear=u(a)}),
// FORMATTING
U("m",["mm",2],0,"minute"),
// ALIASES
J("minute","m"),
// PRIORITY
M("minute",14),
// PARSING
Z("m",Od),Z("mm",Od,Kd),ba(["m","mm"],ee);
// MOMENTS
var Te=O("Minutes",!1);
// FORMATTING
U("s",["ss",2],0,"second"),
// ALIASES
J("second","s"),
// PRIORITY
M("second",15),
// PARSING
Z("s",Od),Z("ss",Od,Kd),ba(["s","ss"],fe);
// MOMENTS
var Ue=O("Seconds",!1);
// FORMATTING
U("S",0,0,function(){return~~(this.millisecond()/100)}),U(0,["SS",2],0,function(){return~~(this.millisecond()/10)}),U(0,["SSS",3],0,"millisecond"),U(0,["SSSS",4],0,function(){return 10*this.millisecond()}),U(0,["SSSSS",5],0,function(){return 100*this.millisecond()}),U(0,["SSSSSS",6],0,function(){return 1e3*this.millisecond()}),U(0,["SSSSSSS",7],0,function(){return 1e4*this.millisecond()}),U(0,["SSSSSSSS",8],0,function(){return 1e5*this.millisecond()}),U(0,["SSSSSSSSS",9],0,function(){return 1e6*this.millisecond()}),
// ALIASES
J("millisecond","ms"),
// PRIORITY
M("millisecond",16),
// PARSING
Z("S",Rd,Jd),Z("SS",Rd,Kd),Z("SSS",Rd,Ld);var Ve;for(Ve="SSSS";Ve.length<=9;Ve+="S")Z(Ve,Ud);for(Ve="S";Ve.length<=9;Ve+="S")ba(Ve,Ic);
// MOMENTS
var We=O("Milliseconds",!1);
// FORMATTING
U("z",0,0,"zoneAbbr"),U("zz",0,0,"zoneName");var Xe=r.prototype;Xe.add=Pe,Xe.calendar=Vb,Xe.clone=Wb,Xe.diff=bc,Xe.endOf=oc,Xe.format=gc,Xe.from=hc,Xe.fromNow=ic,Xe.to=jc,Xe.toNow=kc,Xe.get=R,Xe.invalidAt=xc,Xe.isAfter=Xb,Xe.isBefore=Yb,Xe.isBetween=Zb,Xe.isSame=$b,Xe.isSameOrAfter=_b,Xe.isSameOrBefore=ac,Xe.isValid=vc,Xe.lang=Re,Xe.locale=lc,Xe.localeData=mc,Xe.max=Ke,Xe.min=Je,Xe.parsingFlags=wc,Xe.set=S,Xe.startOf=nc,Xe.subtract=Qe,Xe.toArray=sc,Xe.toObject=tc,Xe.toDate=rc,Xe.toISOString=ec,Xe.inspect=fc,Xe.toJSON=uc,Xe.toString=dc,Xe.unix=qc,Xe.valueOf=pc,Xe.creationData=yc,
// Year
Xe.year=pe,Xe.isLeapYear=ra,
// Week Year
Xe.weekYear=Ac,Xe.isoWeekYear=Bc,
// Quarter
Xe.quarter=Xe.quarters=Gc,
// Month
Xe.month=ka,Xe.daysInMonth=la,
// Week
Xe.week=Xe.weeks=Ba,Xe.isoWeek=Xe.isoWeeks=Ca,Xe.weeksInYear=Dc,Xe.isoWeeksInYear=Cc,
// Day
Xe.date=Se,Xe.day=Xe.days=Ka,Xe.weekday=La,Xe.isoWeekday=Ma,Xe.dayOfYear=Hc,
// Hour
Xe.hour=Xe.hours=ze,
// Minute
Xe.minute=Xe.minutes=Te,
// Second
Xe.second=Xe.seconds=Ue,
// Millisecond
Xe.millisecond=Xe.milliseconds=We,
// Offset
Xe.utcOffset=Db,Xe.utc=Fb,Xe.local=Gb,Xe.parseZone=Hb,Xe.hasAlignedHourOffset=Ib,Xe.isDST=Jb,Xe.isLocal=Lb,Xe.isUtcOffset=Mb,Xe.isUtc=Nb,Xe.isUTC=Nb,
// Timezone
Xe.zoneAbbr=Jc,Xe.zoneName=Kc,
// Deprecations
Xe.dates=x("dates accessor is deprecated. Use date instead.",Se),Xe.months=x("months accessor is deprecated. Use month instead",ka),Xe.years=x("years accessor is deprecated. Use year instead",pe),Xe.zone=x("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",Eb),Xe.isDSTShifted=x("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",Kb);var Ye=C.prototype;Ye.calendar=D,Ye.longDateFormat=E,Ye.invalidDate=F,Ye.ordinal=G,Ye.preparse=Nc,Ye.postformat=Nc,Ye.relativeTime=H,Ye.pastFuture=I,Ye.set=A,
// Month
Ye.months=fa,Ye.monthsShort=ga,Ye.monthsParse=ia,Ye.monthsRegex=na,Ye.monthsShortRegex=ma,
// Week
Ye.week=ya,Ye.firstDayOfYear=Aa,Ye.firstDayOfWeek=za,
// Day of Week
Ye.weekdays=Fa,Ye.weekdaysMin=Ha,Ye.weekdaysShort=Ga,Ye.weekdaysParse=Ja,Ye.weekdaysRegex=Na,Ye.weekdaysShortRegex=Oa,Ye.weekdaysMinRegex=Pa,
// Hours
Ye.isPM=Va,Ye.meridiem=Wa,$a("en",{ordinalParse:/\d{1,2}(th|st|nd|rd)/,ordinal:function(a){var b=a%10,c=1===u(a%100/10)?"th":1===b?"st":2===b?"nd":3===b?"rd":"th";return a+c}}),
// Side effect imports
a.lang=x("moment.lang is deprecated. Use moment.locale instead.",$a),a.langData=x("moment.langData is deprecated. Use moment.localeData instead.",bb);var Ze=Math.abs,$e=ed("ms"),_e=ed("s"),af=ed("m"),bf=ed("h"),cf=ed("d"),df=ed("w"),ef=ed("M"),ff=ed("y"),gf=gd("milliseconds"),hf=gd("seconds"),jf=gd("minutes"),kf=gd("hours"),lf=gd("days"),mf=gd("months"),nf=gd("years"),of=Math.round,pf={s:45,// seconds to minute
m:45,// minutes to hour
h:22,// hours to day
d:26,// days to month
M:11},qf=Math.abs,rf=wb.prototype;
// Deprecations
// Side effect imports
// FORMATTING
// PARSING
// Side effect imports
return rf.abs=Wc,rf.add=Yc,rf.subtract=Zc,rf.as=cd,rf.asMilliseconds=$e,rf.asSeconds=_e,rf.asMinutes=af,rf.asHours=bf,rf.asDays=cf,rf.asWeeks=df,rf.asMonths=ef,rf.asYears=ff,rf.valueOf=dd,rf._bubble=_c,rf.get=fd,rf.milliseconds=gf,rf.seconds=hf,rf.minutes=jf,rf.hours=kf,rf.days=lf,rf.weeks=hd,rf.months=mf,rf.years=nf,rf.humanize=md,rf.toISOString=nd,rf.toString=nd,rf.toJSON=nd,rf.locale=lc,rf.localeData=mc,rf.toIsoString=x("toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)",nd),rf.lang=Re,U("X",0,0,"unix"),U("x",0,0,"valueOf"),Z("x",Vd),Z("X",Yd),ba("X",function(a,b,c){c._d=new Date(1e3*parseFloat(a,10))}),ba("x",function(a,b,c){c._d=new Date(u(a))}),a.version="2.17.1",b(sb),a.fn=Xe,a.min=ub,a.max=vb,a.now=Le,a.utc=k,a.unix=Lc,a.months=Rc,a.isDate=g,a.locale=$a,a.invalid=o,a.duration=Ob,a.isMoment=s,a.weekdays=Tc,a.parseZone=Mc,a.localeData=bb,a.isDuration=xb,a.monthsShort=Sc,a.weekdaysMin=Vc,a.defineLocale=_a,a.updateLocale=ab,a.locales=cb,a.weekdaysShort=Uc,a.normalizeUnits=K,a.relativeTimeRounding=kd,a.relativeTimeThreshold=ld,a.calendarFormat=Ub,a.prototype=Xe,a});

4
static/assets/js/2.2.4-jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
/*
* @author Mudit Ameta
* @license https://github.com/zeusdeux/isInViewport/blob/master/license.md MIT
*/
!function(a,b){function c(b){var c,d=a("<div></div>").css({width:"100%"});return b.append(d),c=b.width()-d.width(),d.remove(),c}function d(e,f){var g=e.getBoundingClientRect(),h=g.top,i=g.bottom,j=g.left,k=g.right,l=a.extend({tolerance:0,viewport:b},f),m=!1,n=l.viewport.jquery?l.viewport:a(l.viewport);n.length||(console.warn("isInViewport: The viewport selector you have provided matches no element on page."),console.warn("isInViewport: Defaulting to viewport as window"),n=a(b));var o=n.height(),p=n.width(),q=n[0].toString();if(n[0]!==b&&"[object Window]"!==q&&"[object DOMWindow]"!==q){var r=n[0].getBoundingClientRect();h-=r.top,i-=r.top,j-=r.left,k-=r.left,d.scrollBarWidth=d.scrollBarWidth||c(n),p-=d.scrollBarWidth}return l.tolerance=~~Math.round(parseFloat(l.tolerance)),l.tolerance<0&&(l.tolerance=o+l.tolerance),0>=k||j>=p?m:m=l.tolerance?h<=l.tolerance&&i>=l.tolerance:i>0&&o>=h}String.prototype.hasOwnProperty("trim")||(String.prototype.trim=function(){return this.replace(/^\s*(.*?)\s*$/,"$1")});var e=function(b){if(1===arguments.length&&"function"==typeof b&&(b=[b]),!(b instanceof Array))throw new SyntaxError("isInViewport: Argument(s) passed to .do/.run should be a function or an array of functions");for(var c=0;c<b.length;c++)if("function"==typeof b[c])for(var d=0;d<this.length;d++)b[c].call(a(this[d]));else console.warn("isInViewport: Argument(s) passed to .do/.run should be a function or an array of functions"),console.warn("isInViewport: Ignoring non-function values in array and moving on");return this};a.fn["do"]=function(a){return console.warn("isInViewport: .do is deprecated as it causes issues in IE and some browsers since it's a reserved word. Use $.fn.run instead i.e., $(el).run(fn)."),e(a)},a.fn.run=e;var f=function(b){if(b){var c=b.split(",");return 1===c.length&&isNaN(c[0])&&(c[1]=c[0],c[0]=void 0),{tolerance:c[0]?c[0].trim():void 0,viewport:c[1]?a(c[1].trim()):void 0}}return{}};a.extend(a.expr[":"],{"in-viewport":a.expr.createPseudo?a.expr.createPseudo(function(a){return function(b){return d(b,f(a))}}):function(a,b,c){return d(a,f(c[3]))}}),a.fn.isInViewport=function(a){return this.filter(function(b,c){return d(c,a)})}}(jQuery,window);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
static/assets/js/3.9.1-raven.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2302
static/assets/js/5.4-haml.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
2.2.4-jquery.min.js
2.17.1-moment.min.js
0.3.10-favico.min.js
3.3.7-bootstrap.min.js
3.3.2-bootstrap-switch.min.js
0.2.0-nprogress.min.js
0.8.0-bootstrap-tagsinput.min.js
0.11.1-typeahead.bundle.min.js
0.1.2-loaders.css.min.js
2.1.3-js.cookie.min.js
1.8.3-underscore-min.js
2.4.0-underscore.string.min.js
5.4-haml.js
4.1.1-masonry.pkgd.min.js
1.5.16-clipboard.min.js
3.9.1-raven.min.js
0.4.0-sha1.min.js
2.4.2-isInViewport.min.js

File diff suppressed because one or more lines are too long

10
static/assets/js/haml.map Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":3,"sources":["js.cookie.js"],"names":["factory","registeredInModuleLoader","define","amd","exports","module","OldCookies","window","Cookies","api","noConflict","extend","i","result","arguments","length","attributes","key","init","converter","value","document","path","defaults","expires","Date","setMilliseconds","getMilliseconds","JSON","stringify","test","e","write","encodeURIComponent","String","replace","decodeURIComponent","escape","cookie","toUTCString","domain","secure","join","cookies","split","rdecode","parts","slice","charAt","name","read","this","json","parse","set","get","call","getJSON","apply","remove","withConverter"],"mappings":"CAOE,SAAUA,GACX,GAAIC,IAA2B,CAS/B,IARsB,kBAAXC,SAAyBA,OAAOC,MAC1CD,OAAOF,GACPC,GAA2B,GAEL,gBAAZG,WACVC,OAAOD,QAAUJ,IACjBC,GAA2B,IAEvBA,EAA0B,CAC9B,GAAIK,GAAaC,OAAOC,QACpBC,EAAMF,OAAOC,QAAUR,GAC3BS,GAAIC,WAAa,WAEhB,MADAH,QAAOC,QAAUF,EACVG,KAGR,WACD,QAASE,KAGR,IAFA,GAAIC,GAAI,EACJC,KACGD,EAAIE,UAAUC,OAAQH,IAAK,CACjC,GAAII,GAAaF,UAAWF,EAC5B,KAAK,GAAIK,KAAOD,GACfH,EAAOI,GAAOD,EAAWC,GAG3B,MAAOJ,GAGR,QAASK,GAAMC,GACd,QAASV,GAAKQ,EAAKG,EAAOJ,GACzB,GAAIH,EACJ,IAAwB,mBAAbQ,UAAX,CAMA,GAAIP,UAAUC,OAAS,EAAG,CAKzB,GAJAC,EAAaL,GACZW,KAAM,KACJb,EAAIc,SAAUP,GAEiB,gBAAvBA,GAAWQ,QAAsB,CAC3C,GAAIA,GAAU,GAAIC,KAClBD,GAAQE,gBAAgBF,EAAQG,kBAAyC,MAArBX,EAAWQ,SAC/DR,EAAWQ,QAAUA,EAGtB,IACCX,EAASe,KAAKC,UAAUT,GACpB,UAAUU,KAAKjB,KAClBO,EAAQP,GAER,MAAOkB,IAaT,MAPCX,GAJID,EAAUa,MAINb,EAAUa,MAAMZ,EAAOH,GAHvBgB,mBAAmBC,OAAOd,IAChCe,QAAQ,4DAA6DC,oBAKxEnB,EAAMgB,mBAAmBC,OAAOjB,IAChCA,EAAMA,EAAIkB,QAAQ,2BAA4BC,oBAC9CnB,EAAMA,EAAIkB,QAAQ,UAAWE,QAErBhB,SAASiB,QAChBrB,EAAK,IAAKG,EACVJ,EAAWQ,QAAU,aAAeR,EAAWQ,QAAQe,cAAgB,GACvEvB,EAAWM,KAAO,UAAYN,EAAWM,KAAO,GAChDN,EAAWwB,OAAS,YAAcxB,EAAWwB,OAAS,GACtDxB,EAAWyB,OAAS,WAAa,IAChCC,KAAK,IAKHzB,IACJJ,KAUD,KAJA,GAAI8B,GAAUtB,SAASiB,OAASjB,SAASiB,OAAOM,MAAM,SAClDC,EAAU,mBACVjC,EAAI,EAEDA,EAAI+B,EAAQ5B,OAAQH,IAAK,CAC/B,GAAIkC,GAAQH,EAAQ/B,GAAGgC,MAAM,KACzBN,EAASQ,EAAMC,MAAM,GAAGL,KAAK,IAER,OAArBJ,EAAOU,OAAO,KACjBV,EAASA,EAAOS,MAAM,GAAG,GAG1B,KACC,GAAIE,GAAOH,EAAM,GAAGX,QAAQU,EAAST,mBAKrC,IAJAE,EAASnB,EAAU+B,KAClB/B,EAAU+B,KAAKZ,EAAQW,GAAQ9B,EAAUmB,EAAQW,IACjDX,EAAOH,QAAQU,EAAST,oBAErBe,KAAKC,KACR,IACCd,EAASV,KAAKyB,MAAMf,GACnB,MAAOP,IAGV,GAAId,IAAQgC,EAAM,CACjBpC,EAASyB,CACT,OAGIrB,IACJJ,EAAOoC,GAAQX,GAEf,MAAOP,KAGV,MAAOlB,IAsBR,MAnBAJ,GAAI6C,IAAM7C,EACVA,EAAI8C,IAAM,SAAUtC,GACnB,MAAOR,GAAI+C,KAAK/C,EAAKQ,IAEtBR,EAAIgD,QAAU,WACb,MAAOhD,GAAIiD,OACVN,MAAM,MACDL,MAAMS,KAAK1C,aAElBL,EAAIc,YAEJd,EAAIkD,OAAS,SAAU1C,EAAKD,GAC3BP,EAAIQ,EAAK,GAAIN,EAAOK,GACnBQ,SAAS,MAIXf,EAAImD,cAAgB1C,EAEbT,EAGR,MAAOS,GAAK","file":"js.cookie.min.js"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

70
static/autocomplete.js Normal file
View File

@@ -0,0 +1,70 @@
var Autocomplete = (function() {
var autocomplete;
init = function() {
autocomplete = new Bloodhound({
datumTokenizer: Bloodhound.tokenizers.whitespace,
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
url: '/autocomplete.json?term=%QUERY',
wildcard: '%QUERY',
rateLimitBy: 'throttle',
rateLimitWait: 300
}
});
autocomplete.initialize();
}
reset = function() {
autocomplete.clear();
}
getAutocomplete = function() {
return autocomplete;
}
generateHints = function(label_key, label_val) {
var hints = [];
if (label_key == '@silenced') {
// static list of hints for @silenced label
hints.push('@silenced=true');
hints.push('@silenced=false');
} else {
// equal and non-equal hints for everything else
hints.push(label_key + '=' + label_val);
hints.push(label_key + '!=' + label_val);
// if there's space in the label generate regexp hints for partials
if (label_val.toString().indexOf(' ') >= 0) {
$.each(label_val.toString().split(' '), function(l, label_part){
hints.push(label_key + '=~' + label_part);
hints.push(label_key + '!~' + label_part);
});
}
// if value is an int generate less / more hints
if ($.isNumeric(label_val)) {
var valAsNumber = parseInt(label_val);
if (!isNaN(valAsNumber)) {
hints.push(label_key + '>' + label_val);
hints.push(label_key + '<' + label_val);
}
}
}
return hints;
}
return {
Init: init,
Reset: reset,
Autocomplete: getAutocomplete,
GenerateHints: generateHints
}
}());

374
static/base.css Normal file
View File

@@ -0,0 +1,374 @@
* {
font-size: 18px;
}
body.dark {
background-color: #2c3e50;
padding-top: 58px;
}
/* unsee is pretty useless with tiny width, require at least 1180px */
@media (max-width: 1180px) {
body {min-width: 1180px;}
}
/* flatly default height is 60x instead of 50px, leaving 10px space below, override it*/
.navbar-brand {
padding-top: 15px;
padding-bottom: 15px;
min-width: 90px;
}
.navbar-nav > li > a, .navbar-brand {
height: 50px;
}
/* set minimum width and height for navbar */
.navbar {
min-height:50px;
min-width: 1150px;
}
.navbar > .container {
min-width: 1180px;
}
#navbar > form.navbar-form.navbar-nav {
padding-right: 0;
padding-left: 0;
}
.navbar-nav>li>a {
padding-top: 15px;
padding-bottom: 15px;
}
/* add some spacing in menu dropdown */
.dropdown-switch {
margin-left: 10px;
margin-right: 10px;
}
/* we have a popover on header, indicate that with cursor */
.navbar-header {
cursor: help;
}
/* we use big popver, default width limit is too low */
.popover {
max-width: 100%;
}
/* we have wide tooltips for some alert names */
.tooltip-inner {
max-width: 100%;
}
/* custom badge for popover */
.breakdown-badge {
display: inline-block;
min-width: 10px;
padding: 3px 7px;
font-size: 13px;
font-weight: bold;
color: #ffffff;
line-height: 1;
vertical-align: middle;
white-space: nowrap;
text-align: center;
background-color: #2c3e50;
border-radius: 10px;
}
/* fullscreen flash div */
.flash {
display: none;
position: fixed;
z-index: 999999;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: rgba(243, 156, 18, 0.9);
}
/* space between labels */
.label-list {
padding-left: 3px;
padding-right: 3px;
margin-right: 3px;
}
a.label.label-age {
cursor: help;
}
span.label-ts-span {
font-size: inherit;
}
/* used in alert group trim summary for adding label counters */
.label-trim-group {
display: inline-block;
margin-bottom: 2px;
white-space: nowrap;
}
.label-trim-tag {
padding-left: 4px;
padding-right: 4px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.label-trim-count {
margin-left: -3px;
padding-left: 3px;
padding-right: 4px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
[data-label-type="filter"] {
cursor: pointer;
}
/* make filter input take all the space it can */
.filterbar {
width: 950px;
}
.filterbar > .input-group-addon.input-sm {
width: 10px;
}
/* fix padding with flatly */
.bootstrap-tagsinput {
width: 100%;
padding-top: 3px;
padding-bottom: 6px;
line-height: 25px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
/* flatly makes focused input border black, which looks ugly with dark navbar
revert to default color for focused input */
input#filter:focus {
border: 2px solid #dce4ec;
}
/* set min-width on real input so it doesn't need too much space when it's empty */
.bootstrap-tagsinput > .twitter-typeahead > input.tt-input {
min-width: 10px;
}
#filter-help {
cursor: help;
}
#help-labels > code {
white-space: nowrap;
}
/* adjust padding for counter badge in tags input */
.tag-badge {
margin-right: 3px;
margin-top: -2px;
}
.tag {
padding-left: 2px;
}
/* menus uses links with icons instead of buttons, fix focus color
so the link doesn't stay colored after clicking */
#help:focus,
#settings:focus,
#refresh:focus {
color: #fff;
}
#help:hover,
#settings:hover,
#refresh:hover {
color: #18bc9c;
}
/* remove default 15px padding from container-fluid for incident grid */
#container {
padding-left: 5px;
padding-right: 5px;
}
/* but errors should have additional margin */
#errors {
margin-left: 10px;
margin-right: 10px;
}
.panel-title {
overflow: hidden;
}
.panel-title > .label {
font-size: 90%;
line-height: 2em;
}
.panel-heading > .badge {
margin-left: 2px;
font-size: 90%;
margin-top: 5px;
}
/* make silence description smaller */
.silence-comment {
font-size: smaller;
margin-top: 6px;
margin-bottom: 0;
border-left-color: #18bc9c;
padding-top: 5px;
padding-bottom: 5px;
}
.incident .panel {
margin-bottom: 0;
}
.incident {
padding: 8px;
}
.incident>.panel>.panel-heading {
padding-top: 4px;
padding-bottom: 4px;
}
.incident>.panel>.panel-body {
padding: 6px;
overflow: hidden;
}
/* incident indicator is a small circle marking most recent alerts */
.incident-indicator-success {
background-color: #18bc9c;
}
.incident-indicator-danger {
background-color: #e74c3c;
}
.incident-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-left: 4px;
margin-right: 4px;
}
.grid-sizer, .incident { width: 100%; }
@media screen and (min-width: 700px) and (max-width: 1399px) {
.grid-sizer, .incident { width: 50%; }
}
@media screen and (min-width: 1400px) and (max-width: 2099px) {
.grid-sizer, .incident { width: 33.333%; }
}
@media screen and (min-width: 2100px) and (max-width: 2799px) {
.grid-sizer, .incident { width: 25%; }
}
@media screen and (min-width: 2800px) and (max-width: 3499px) {
.grid-sizer, .incident { width: 20%; }
}
@media screen and (min-width: 3500px) and (max-width: 4199px) {
.grid-sizer, .incident { width: 16.666%; }
}
@media screen and (min-width: 4200px) and (max-width: 4899px) {
.grid-sizer, .incident { width: 14.285%; }
}
@media screen and (min-width: 4900px) and (max-width: 5599px) {
.grid-sizer, .incident { width: 14.285%; }
}
@media screen and (min-width: 5600px) {
.grid-sizer, .incident { width: 12.5%; }
}
.incident-group {
word-wrap: break-word;
}
.incident-group-separator {
border-bottom: 1px solid #ecf0f1;
}
.annotation-well {
margin-bottom: 2px;
padding: 4px;
background-color: transparent;
}
.annotation-well > .fa {
cursor: help;
}
/* move the spinner up so it's in the middle of navbar */
#spinner {
margin-top: -9px;
}
/* change spinner color to text-warning when JSON is loaded and it's being parsed */
#spinner > .spinner-success {
background-color: #f39c12;
}
/* and to text-danger when rendering alerts fails */
#spinner > .spinner-error {
background-color: #e74c3c;
}
/* tweak typeahead look for flatly */
.tt-cursor,
.tt-suggestion:hover,
.tt-suggestion:focus {
color: #ffffff;
text-decoration: none;
outline: 0;
background-color: #2c3e50;
}
/* tweak nprogress look for flatly */
#nprogress .bar {
background: #f39c12;
}
#nprogress .peg {
box-shadow: 0 0 10px #f39c12, 0 0 5px #f39c12;
}
#nprogress .spinner-icon {
border-top-color: #f39c12;
border-left-color: #f39c12;
}
/* tweaks for help page tables */
table.table.help {
margin-bottom: 42px;
}
table.table.help > tbody > tr > td,
table.table.help > tbody > tr > th {
border-top: 1px solid #ddd;
}
table.table.help > thead > tr > th {
white-space: nowrap;
}
table.table.examples > tbody > tr > td {
padding: 6px 3px;
}
table.table.examples > tbody > tr td:first-child {
width: 1%;
}
/* margins between bottom action buttons in settings menu */
.btn-dropdown-action {
margin-left: 10px;
margin-right: 10px;
}
.modal-body>table.table>tbody>tr>td.modal-row-filters {
word-break: break-all;
}
.modal-body>table.table>tbody>tr>td.modal-row-filters,
.modal-body>table.table>tbody>tr>td.modal-row-actions {
vertical-align: middle;
}

65
static/colors.js Normal file
View File

@@ -0,0 +1,65 @@
var Colors = (function() {
var colors,
staticColorLabels;
var specialLabels = {
'@silenced: false': 'label-danger',
'@silenced: true': 'label-success'
}
update = function(colorData) {
colors = colorData;
}
getClass = function(key, value) {
var label = key + ': ' + value;
if (key == 'alertname') {
return 'label-primary'; // special case for alertname label, which is the name of alert
} else if (specialLabels[label] != undefined) {
return specialLabels[label];
} else if (Colors.IsStaticLabel(key)) {
return 'label-info';
} else {
return 'label-warning';
}
}
getStyle = function(key, value) {
// get color data, returned as css style string
var style = "";
if (colors[key] != undefined && colors[key][value] != undefined) {
var c = colors[key][value];
style = 'background-color: rgba(' + [c.background.red, c.background.green, c.background.blue, c.background.alpha].join(', ') + '); ';
style += 'color: rgba(' + [c.font.red, c.font.green, c.font.blue, c.font.alpha].join(', ') + '); ';
}
return style;
}
getStaticLabels = function() {
return $('#alerts').data('static-color-labels').split(' ');
}
isStaticLabel = function(key) {
return ($.inArray(key, getStaticLabels()) >= 0);
}
init = function(staticColors) {
staticColorLabels = staticColors;
}
return {
Init: init,
Update: update,
Get: getStyle,
GetClass: getClass,
GetStaticLabels: getStaticLabels,
IsStaticLabel: isStaticLabel
}
})();

191
static/config.js Normal file
View File

@@ -0,0 +1,191 @@
var Option = (function(params) {
class optionClass {
constructor(params) {
this.Cookie = params.Cookie;
this.QueryParam = params.QueryParam;
this.Selector = params.Selector;
this.Get = params.Getter || function() {
return $(this.Selector).is(":checked");
};
this.Set = params.Setter || function(val) {
$(this.Selector).bootstrapSwitch('state', $.parseJSON(val), true);
};
this.Action = params.Action || function(val) {};
this.Init = params.Init || function() {
var elem = this;
$(this.Selector).on('switchChange.bootstrapSwitch', function(event, val) {
elem.Save(val);
elem.Action(val);
});
};
}
Load() {
var currentVal = this.Get();
var val = Cookies.get(this.Cookie);
if (val != undefined) {
this.Set(val);
}
var q = QueryString.Parse();
if (q[this.QueryParam] != undefined) {
this.Set(val);
}
if (currentVal != val) {
this.Action(val);
}
}
Save(val) {
Cookies.set(this.Cookie, val, {
expires: 365,
path: ''
});
}
}
return {
New: optionClass
}
}());
var Config = (function() {
var options = {};
loadFromCookies = function() {
$.each(options, function(name, option) {
var value = option.Load();
if (value != undefined) {
option.Set(value);
}
});
}
reset = function() {
$.each(options, function(name, option) {
Cookies.remove(option.Cookie);
});
}
init = function(params) {
// copy current filter button action
new Clipboard(params.CopySelector, {
text: function(elem) {
var baseUrl = [location.protocol, '//', location.host, location.pathname].join('');
var query = ['q=' + Filters.GetFilters().join(',')];
$.each(options, function(name, option) {
query.push(option.QueryParam + '=' + option.Get().toString());
});
$(elem).finish().fadeOut(100).fadeIn(300);
return baseUrl + '?' + query.join('&');
}
});
// save settings button action
$(params.SaveSelector).on('click', function(elem) {
var filter = Filters.GetFilters().join(',');
Cookies.set('defaultFilter.v2', filter, {
expires: 365,
path: ''
});
$(params.SaveSelector).finish().fadeOut(100).fadeIn(300);
});
// reset settings button action
$(params.ResetSelector).on('click', function(elem) {
reset();
location.reload();
});
// https://github.com/twbs/bootstrap/issues/2097
$(document).on('click', '.dropdown-menu.dropdown-menu-form', function(e) {
e.stopPropagation();
});
Config.NewOption({
Cookie: 'autoRefresh',
QueryParam: 'autorefresh',
Selector: '#autorefresh',
Action: function(val) {
if (val) {
Unsee.WaitForNextReload();
} else {
Unsee.Pause();
}
}
});
Config.NewOption({
Cookie: 'refreshInterval',
QueryParam: 'refresh',
Selector: '#refresh-interval',
Init: function() {
var elem = this;
$(this.Selector).on('change', function() {
var val = elem.Get();
elem.Save(val);
elem.Action(val);
});
},
Getter: function() {
return $(this.Selector).val();
},
Setter: function(val) {
$(this.Selector).val(parseInt(val));
},
Action: function(val) {
Unsee.SetRefreshRate(parseInt(val));
}
});
Config.NewOption({
Cookie: 'showFlash',
QueryParam: 'flash',
Selector: '#show-flash'
});
Config.NewOption({
Cookie: 'appendTop',
QueryParam: 'appendtop',
Selector: '#append-top'
});
}
newOption = function(params) {
var option = new Option.New(params);
option.Init();
options[option.QueryParam] = option;
}
getOption = function(queryParam) {
return options[queryParam];
}
return {
Init: init,
Load: loadFromCookies,
Reset: reset,
NewOption: newOption,
GetOption: getOption
}
}());

83
static/counter.js Normal file
View File

@@ -0,0 +1,83 @@
var Counter = (function(params) {
var selectors = {
counter: '#alert-count',
spinner: '#spinner'
};
var favicon = false;
setCounter = function(val) {
favicon.badge(val);
Counter.Show();
$(selectors.counter).html(val);
// set alert count css based on the number of alerts
if (val == 0) {
$(selectors.counter).removeClass('text-warning text-danger').addClass('text-success');
document.title = "(◕‿◕)";
} else if (val < 10) {
$(selectors.counter).removeClass('text-success text-danger').addClass('text-warning');
document.title = "(◕_◕)";
} else {
$(selectors.counter).removeClass('text-success text-warning').addClass('text-danger');
document.title = "(◕︵◕)";
}
}
setUnknown = function() {
favicon.badge('?');
Counter.Show();
$(selectors.counter).html('?');
$(selectors.counter).removeClass('text-success text-warning text-danger');
}
hide = function() {
$(selectors.counter).hide();
$(selectors.spinner).children().removeClass('spinner-success spinner-error');
$(selectors.spinner).show();
}
show = function() {
$(selectors.spinner).hide();
$(selectors.counter).show();
}
markError = function() {
$(selectors.spinner).children().removeClass('spinner-success').addClass('spinner-error');
}
markSuccess = function() {
$(selectors.spinner).children().addClass('spinner-success');
}
init = function() {
favicon = new Favico({
animation: 'none',
position: 'up',
bgColor: '#333',
textColor: '#ff0'
});
setUnknown();
}
return {
Init: init,
Set: setCounter,
Unknown: setUnknown,
Hide: hide,
Show: show,
Error: markError,
Success: markSuccess
}
})();

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

153
static/filters.js Normal file
View File

@@ -0,0 +1,153 @@
var Filters = (function() {
selectors = {
filter: '#filter',
icon: '#filter-icon'
}
addBadge = function(text) {
$.each($('span.tag'), function(i, tag) {
if ($(tag).text() == text) {
var chksum = sha1(text);
$(tag).prepend('<span class="badge tag-badge" id="tag-counter-' + chksum + '" data-badge-id="' + chksum + '"></span>');
}
});
}
update = function() {
Filters.Updating();
// update location so it's easy to share it
QueryString.Set('q', Filters.GetFilters().join(','));
// reload alerts
Unsee.Reload();
}
init = function() {
var initial_filter;
if ($(selectors.filter).data('default-used') == 'false' || $(selectors.filter).data('default-used') == false) {
// user passed ?q=filter string
initial_filter = $(selectors.filter).val();
} else {
// no ?q=filter string, check if we have default filter cookie
initial_filter = Cookies.get('defaultFilter.v2');
if (initial_filter == undefined) {
// no cookie, use global default
initial_filter = $(selectors.filter).data('default-filter');
}
}
var initial_filter_arr = initial_filter.split(',');
$(selectors.filter).val('');
$('.filterbar :input').tagsinput({
typeaheadjs: {
minLength: 1,
hint: true,
limit: 12,
name: 'autocomplete',
source: Autocomplete.Autocomplete()
}
});
$.each(initial_filter_arr, function(i, filter) {
$(selectors.filter).tagsinput('add', filter);
addBadge(filter);
});
$(selectors.filter).on('itemAdded itemRemoved', function(event) {
Filters.SetFilters();
// add counter badge to new tag
if (event.type == 'itemAdded') {
addBadge(event.item);
}
});
$(selectors.filter).tagsinput('focus');
// stop when user is typing in the filter bar
$('.bootstrap-tagsinput').typing({
start: function(event, elem) {
if (event.keyCode != 8 && event.keyCode != 13) Unsee.Pause();
},
stop: function(event, elem) {
if (event.keyCode != 8 && event.keyCode != 13) Unsee.WaitForNextReload();
},
delay: 1000
});
// fix body padding if needed, input might endup using more than 1 line
$('.bootstrap-tagsinput').bind("DOMSubtreeModified", function() {
$('body').css('padding-top', $('.navbar').height());
});
$('input.tt-input').on("change keypress", function() {
$('body').css('padding-top', $('.navbar').height());
});
$('.filterbar').on('resize', function(){
// hack for fixing padding since input can grow and change height
$('body').css('padding-top', $('.navbar').height());
});
}
getFilters = function() {
return $(selectors.filter).tagsinput('items');
}
reloadBadges = function(filterData) {
$.each(filterData, function(i, filter) {
$.each($('span.tag-badge'), function(j, tag) {
if (sha1(filter.text) == $(tag).data('badge-id')) {
$(tag).html(filter.hits.toString());
if (filter.isValid) {
$(tag).addClass('label-info').removeClass('label-danger');
} else {
$(tag).addClass('label-danger').removeClass('label-info');
}
}
});
});
}
addFilter = function(text) {
$(selectors.filter).tagsinput('add', text);
}
setUpdating = function() {
// visual hint that alerts are reloaded due to filter change
$(selectors.icon).removeClass('fa-search fa-pause').addClass('fa-circle-o-notch fa-spin');
}
updateDone = function() {
$(selectors.icon).removeClass('fa-circle-o-notch fa-spin fa-pause').addClass('fa-search');
}
setPause = function() {
$(selectors.icon).removeClass('fa-circle-o-notch fa-spin fa-search').addClass('fa-pause');
}
return {
Init: init,
AddFilter: addFilter,
SetFilters: update,
GetFilters: getFilters,
ReloadBadges: reloadBadges,
UpdateCompleted: updateDone,
Updating: setUpdating,
Pause: setPause
}
}());

79
static/grid.js Normal file
View File

@@ -0,0 +1,79 @@
var Grid = (function(params) {
var selectors = {
alerts: '#alerts',
incident: '.incident',
gridSizer: '.grid-sizer',
};
var grid;
init = function() {
grid = $(selectors.alerts).masonry({
itemSelector: selectors.incident,
columnWidth: selectors.gridSizer,
percentPosition: true,
transitionDuration: '0.4s',
hiddenStyle: {
opacity: 0
},
visibleStyle: {
opacity: 1
}
});
}
clear = function() {
grid.masonry('remove', $(selectors.incident));
}
redraw = function() {
grid.masonry('layout');
}
remove = function(elem) {
grid.masonry('remove', elem);
}
append = function(elem) {
if (Config.GetOption('appendtop').Get()) {
grid.prepend(elem).masonry('prepended', elem);
} else {
grid.append(elem).masonry('appended', elem);
}
}
items = function() {
return grid.masonry('getItemElements');
}
hide = function() {
$(selectors.alerts).hide();
}
show = function() {
$(selectors.alerts).show();
}
return {
Init: init,
Clear: clear,
Hide: hide,
Show: show,
Redraw: redraw,
Append: append,
Remove: remove,
Items: items
}
})();

View File

@@ -0,0 +1,82 @@
// jQuery-typing
//
// Version: 0.2.0
// Website: http://narf.pl/jquery-typing/
// License: public domain <http://unlicense.org/>
// Author: Maciej Konieczny <hello@narf.pl>
(function ($) {
//--------------------
// jQuery extension
//--------------------
$.fn.typing = function (options) {
return this.each(function (i, elem) {
listenToTyping(elem, options);
});
};
//-------------------
// actual function
//-------------------
function listenToTyping(elem, options) {
// override default settings
var settings = $.extend({
start: null,
stop: null,
delay: 400
}, options);
// create other function-scope variables
var $elem = $(elem),
typing = false,
delayedCallback;
// start typing
function startTyping(event) {
if (!typing) {
// set flag and run callback
typing = true;
if (settings.start) {
settings.start(event, $elem);
}
}
}
// stop typing
function stopTyping(event, delay) {
if (typing) {
// discard previous delayed callback and create new one
clearTimeout(delayedCallback);
delayedCallback = setTimeout(function () {
// set flag and run callback
typing = false;
if (settings.stop) {
settings.stop(event, $elem);
}
}, delay >= 0 ? delay : settings.delay);
}
}
// listen to regular keypresses
$elem.keypress(startTyping);
// listen to backspace and delete presses
$elem.keydown(function (event) {
if (event.keyCode === 8 || event.keyCode === 46) {
startTyping(event);
}
});
// listen to keyups
$elem.keyup(stopTyping);
// listen to blurs
$elem.blur(function (event) {
stopTyping(event, 0);
});
}
})(jQuery);

299
static/lru.js Normal file
View File

@@ -0,0 +1,299 @@
/**
* A doubly linked list-based Least Recently Used (LRU) cache. Will keep most
* recently used items while discarding least recently used items when its limit
* is reached.
*
* Licensed under MIT. Copyright (c) 2010 Rasmus Andersson <http://hunch.se/>
* See README.md for details.
*
* Illustration of the design:
*
* entry entry entry entry
* ______ ______ ______ ______
* | head |.newer => | |.newer => | |.newer => | tail |
* | A | | B | | C | | D |
* |______| <= older.|______| <= older.|______| <= older.|______|
*
* removed <-- <-- <-- <-- <-- <-- <-- <-- <-- <-- <-- added
*/
const NEWER = Symbol('newer');
const OLDER = Symbol('older');
function LRUMap(limit, entries) {
if (typeof limit !== 'number') {
// called as (entries)
entries = limit;
limit = 0;
}
this.size = 0;
this.limit = limit;
this.oldest = this.newest = undefined;
this._keymap = new Map();
if (entries) {
this.assign(entries);
if (limit < 1) {
this.limit = this.size;
}
}
}
function Entry(key, value) {
this.key = key;
this.value = value;
this[NEWER] = undefined;
this[OLDER] = undefined;
}
LRUMap.prototype._markEntryAsUsed = function(entry) {
if (entry === this.newest) {
// Already the most recenlty used entry, so no need to update the list
return;
}
// HEAD--------------TAIL
// <.older .newer>
// <--- add direction --
// A B C <D> E
if (entry[NEWER]) {
if (entry === this.oldest) {
this.oldest = entry[NEWER];
}
entry[NEWER][OLDER] = entry[OLDER]; // C <-- E.
}
if (entry[OLDER]) {
entry[OLDER][NEWER] = entry[NEWER]; // C. --> E
}
entry[NEWER] = undefined; // D --x
entry[OLDER] = this.newest; // D. --> E
if (this.newest) {
this.newest[NEWER] = entry; // E. <-- D
}
this.newest = entry;
};
LRUMap.prototype.assign = function(entries) {
let entry, limit = this.limit || Number.MAX_VALUE;
this._keymap.clear();
let it = entries[Symbol.iterator]();
for (let itv = it.next(); !itv.done; itv = it.next()) {
let e = new Entry(itv.value[0], itv.value[1]);
this._keymap.set(e.key, e);
if (!entry) {
this.oldest = e;
} else {
entry[NEWER] = e;
e[OLDER] = entry;
}
entry = e;
if (limit-- == 0) {
throw new Error('overflow');
}
}
this.newest = entry;
this.size = this._keymap.size;
};
LRUMap.prototype.get = function(key) {
// First, find our cache entry
var entry = this._keymap.get(key);
if (!entry) return; // Not cached. Sorry.
// As <key> was found in the cache, register it as being requested recently
this._markEntryAsUsed(entry);
return entry.value;
};
LRUMap.prototype.set = function(key, value) {
var entry = this._keymap.get(key);
if (entry) {
// update existing
entry.value = value;
this._markEntryAsUsed(entry);
return this;
}
// new entry
this._keymap.set(key, (entry = new Entry(key, value)));
if (this.newest) {
// link previous tail to the new tail (entry)
this.newest[NEWER] = entry;
entry[OLDER] = this.newest;
} else {
// we're first in -- yay
this.oldest = entry;
}
// add new entry to the end of the linked list -- it's now the freshest entry.
this.newest = entry;
++this.size;
if (this.size > this.limit) {
// we hit the limit -- remove the head
this.shift();
}
return this;
};
LRUMap.prototype.shift = function() {
// todo: handle special case when limit == 1
var entry = this.oldest;
if (entry) {
if (this.oldest[NEWER]) {
// advance the list
this.oldest = this.oldest[NEWER];
this.oldest[OLDER] = undefined;
} else {
// the cache is exhausted
this.oldest = undefined;
this.newest = undefined;
}
// Remove last strong reference to <entry> and remove links from the purged
// entry being returned:
entry[NEWER] = entry[OLDER] = undefined;
this._keymap.delete(entry.key);
--this.size;
return [entry.key, entry.value];
}
};
// ----------------------------------------------------------------------------
// Following code is optional and can be removed without breaking the core
// functionality.
LRUMap.prototype.find = function(key) {
let e = this._keymap.get(key);
return e ? e.value : undefined;
};
LRUMap.prototype.has = function(key) {
return this._keymap.has(key);
};
LRUMap.prototype['delete'] = function(key) {
var entry = this._keymap.get(key);
if (!entry) return;
this._keymap.delete(entry.key);
if (entry[NEWER] && entry[OLDER]) {
// relink the older entry with the newer entry
entry[OLDER][NEWER] = entry[NEWER];
entry[NEWER][OLDER] = entry[OLDER];
} else if (entry[NEWER]) {
// remove the link to us
entry[NEWER][OLDER] = undefined;
// link the newer entry to head
this.oldest = entry[NEWER];
} else if (entry[OLDER]) {
// remove the link to us
entry[OLDER][NEWER] = undefined;
// link the newer entry to head
this.newest = entry[OLDER];
} else {// if(entry[OLDER] === undefined && entry.newer === undefined) {
this.oldest = this.newest = undefined;
}
this.size--;
return entry.value;
};
LRUMap.prototype.clear = function() {
// Not clearing links should be safe, as we don't expose live links to user
this.oldest = this.newest = undefined;
this.size = 0;
this._keymap.clear();
};
function EntryIterator(oldestEntry) { this.entry = oldestEntry; }
EntryIterator.prototype[Symbol.iterator] = function() { return this; }
EntryIterator.prototype.next = function() {
let ent = this.entry;
if (ent) {
this.entry = ent[NEWER];
return { done: false, value: [ent.key, ent.value] };
} else {
return { done: true, value: undefined };
}
};
function KeyIterator(oldestEntry) { this.entry = oldestEntry; }
KeyIterator.prototype[Symbol.iterator] = function() { return this; }
KeyIterator.prototype.next = function() {
let ent = this.entry;
if (ent) {
this.entry = ent[NEWER];
return { done: false, value: ent.key };
} else {
return { done: true, value: undefined };
}
};
function ValueIterator(oldestEntry) { this.entry = oldestEntry; }
ValueIterator.prototype[Symbol.iterator] = function() { return this; }
ValueIterator.prototype.next = function() {
let ent = this.entry;
if (ent) {
this.entry = ent[NEWER];
return { done: false, value: ent.value };
} else {
return { done: true, value: undefined };
}
};
LRUMap.prototype.keys = function() {
return new KeyIterator(this.oldest);
};
LRUMap.prototype.values = function() {
return new ValueIterator(this.oldest);
};
LRUMap.prototype.entries = function() {
return this;
};
LRUMap.prototype[Symbol.iterator] = function() {
return new EntryIterator(this.oldest);
};
LRUMap.prototype.forEach = function(fun, thisObj) {
if (typeof thisObj !== 'object') {
thisObj = this;
}
let entry = this.oldest;
while (entry) {
fun.call(thisObj, entry.value, entry.key, this);
entry = entry[NEWER];
}
};
/** Returns a JSON (array) representation */
LRUMap.prototype.toJSON = function() {
var s = new Array(this.size), i = 0, entry = this.oldest;
while (entry) {
s[i++] = { key: entry.key, value: entry.value };
entry = entry[NEWER];
}
return s;
};
/** Returns a String representation */
LRUMap.prototype.toString = function() {
var s = '', entry = this.oldest;
while (entry) {
s += String(entry.key)+':'+entry.value;
entry = entry[NEWER];
if (entry) {
s += ' < ';
}
}
return s;
};
// Export ourselves
if (typeof this === 'object') this.LRUMap = LRUMap;

55
static/progress.js Normal file
View File

@@ -0,0 +1,55 @@
var Progress = (function() {
var timer;
init = function() {
NProgress.configure({
minimum: 0.01,
showSpinner: false,
easing: 'linear'
});
}
resetTimer = function() {
if (timer != false) {
clearInterval(timer);
timer = false;
}
}
complete = function() {
resetTimer();
NProgress.done();
}
pause = function() {
resetTimer();
NProgress.set(0.0);
}
start = function() {
var step_ms = 250; // animation step in ms
var steps = (Unsee.GetRefreshRate() * 1000) / step_ms; // how many steps we have
NProgress.set(0.0);
resetTimer();
timer = setInterval(function() {
NProgress.inc(1.0 / steps);
}, step_ms);
}
return {
Init: init,
Pause: pause,
Complete: complete,
Reset: start,
ResetTimer: resetTimer
}
}());

47
static/querystring.js Normal file
View File

@@ -0,0 +1,47 @@
var QueryString = (function() {
parse = function() {
var vars = [],
hash;
var q = document.URL.split('?')[1];
if (q != undefined) {
q = q.split('&');
for (var i = 0; i < q.length; i++) {
hash = q[i].split('=');
vars.push(hash[1]);
vars[hash[0]] = hash[1];
}
}
return vars;
}
update = function(key, value) {
/* https://gist.github.com/excalq/2961415 */
var baseUrl = [location.protocol, '//', location.host, location.pathname].join(''),
urlQueryString = document.location.search,
newParam = key + '=' + value,
params = '?' + newParam;
// If the "search" string exists, then build params from it
if (urlQueryString) {
keyRegex = new RegExp('([\?&])' + key + '[^&]*');
// If param exists already, update it
if (urlQueryString.match(keyRegex) !== null) {
params = urlQueryString.replace(keyRegex, "$1" + newParam);
} else { // Otherwise, add it to end of query string
params = urlQueryString + '&' + newParam;
}
}
window.history.replaceState({}, "", baseUrl + params);
}
return {
Parse: parse,
Set: update
}
}());

85
static/summary.js Normal file
View File

@@ -0,0 +1,85 @@
var Summary = (function() {
var summary;
render = function() {
var top_tags = [];
$.each(summary, function(k, v) {
top_tags.push({
name: k,
val: v
});
});
top_tags.sort(function(a, b) {
if (a.val > b.val) return 1;
if (a.val < b.val) return -1;
if (a.name > b.name) return -1;
if (a.name < b.name) return 1;
return 0;
}).reverse();
var tags = [];
$.each(top_tags.slice(0, 10), function(i, tag) {
var label_key = tag.name.split(': ')[0];
var label_val = tag.name.split(': ')[1];
tag.style = Colors.Get(label_key, label_val);
tag.cls = Colors.GetClass(label_key, label_val);
tags.push(tag);
});
return haml.compileHaml('breakdown-content')({
tags: tags
});
}
init = function() {
summary = {};
$('.navbar-header').popover({
trigger: 'hover',
container: 'body',
html: true,
placement: 'bottom',
title: 'Top labels',
content: render,
template: haml.compileHaml('breakdown')()
});
}
update = function(data) {
summary = data;
}
reset = function() {
summary = {};
render();
}
push = function(labelKey, labelVal) {
var l = labelKey + ': ' + labelVal;
if (summary[l] == undefined) {
summary[l] = 1;
} else {
summary[l]++;
}
}
getCount = function(labelKey, labelVal) {
var l = labelKey + ': ' + labelVal;
return summary[l];
}
return {
Init: init,
Update: update,
Reset: reset,
Push: push,
Get: getCount
}
}());

276
static/unsee.js Normal file
View File

@@ -0,0 +1,276 @@
var Unsee = (function(params) {
var timer = false;
var version = false;
var refreshInterval = 15;
var selectors = {
refreshButton: '#refresh',
errors: '#errors'
}
init = function() {
Progress.Init();
Config.Init({
CopySelector: '#copy-settings-with-filter',
SaveSelector: '#save-default-filter',
ResetSelector: '#reset-settings'
});
Config.Load();
Counter.Init();
Summary.Init();
Grid.Init();
Autocomplete.Init();
Filters.Init();
Watchdog.Init(10, 300);
$(selectors.refreshButton).click(function() {
if (!$(selectors.refreshButton).prop('disabled')) {
Unsee.Reload();
}
return false;
});
}
getRefreshRate = function() {
return refreshInterval;
}
setRefreshRate = function(seconds) {
var rate = parseInt(seconds);
if (isNaN(rate)) {
// if passed rate is incorrect use select value
rate = Config.GetOption('refresh').Get();
if (isNaN(rate)) {
// if that's also borked use default 15
rate = 15;
}
}
refreshInterval = rate;
Watchdog.UpdateTolerance(rate);
Progress.Reset();
}
needsUpgrade = function(responseVersion) {
if (version == false) {
version = responseVersion;
return false;
}
return version != responseVersion;
}
renderError = function(template, context) {
Counter.Error()
Grid.Clear();
Grid.Hide();
$(selectors.errors).html(haml.compileHaml(template)(context));
$(selectors.errors).show();
Counter.Unknown();
Summary.Update({});
document.title = "(◕ O ◕)";
updateCompleted();
}
handleError = function(err) {
Raven.captureException(err);
if (window.console) {
console.error(err.stack);
}
renderError('internal-error', {
name: err.name,
message: err.message,
raw: err
});
setTimeout(function() {
Unsee.WaitForNextReload();
}, 500);
}
upgrade = function() {
renderError('reload-needed', {});
setTimeout(function() {
location.reload();
}, 3000);
}
triggerReload = function() {
updateIsReady();
$.ajax({
url: 'alerts.json?q=' + Filters.GetFilters().join(','),
success: function(resp) {
Counter.Success();
if (needsUpgrade(resp['version'])) {
upgrade();
} else if (resp['error']) {
Counter.Unknown();
renderError('update-error', {
error: 'Backend error',
message: resp['error'],
last_ts: Watchdog.GetLastUpdate()
});
Unsee.WaitForNextReload();
} else {
// update_alerts() is cpu heavy so it will block browser from applying css changes
// inject tiny delay between addClass() above and update_alerts() so that the browser
// have a chance to reflect those updates
setTimeout(function() {
try {
Summary.Update({});
Filters.ReloadBadges(resp['filters']);
Colors.Update(resp['colors']);
Alerts.Update(resp);
updateCompleted();
Watchdog.Pong(moment(resp['timestamp']).unix());
Unsee.WaitForNextReload();
$(selectors.errors).html('');
$(selectors.errors).hide('');
} catch (err) {
Counter.Unknown();
handleError(err);
Unsee.WaitForNextReload();
}
}, 50);
}
},
error: function(jqXHR, textStatus) {
Counter.Unknown();
// if fatal error was already triggered we have error message
// so don't add new one
if (!Watchdog.IsFatal()) {
renderError('update-error', {
error: 'Backend error',
message: 'AJAX request failed',
last_ts: Watchdog.GetLastUpdate()
})
}
Unsee.WaitForNextReload();
}
})
}
updateIsReady = function() {
Progress.Complete();
$(selectors.refreshButton).prop('disabled', true);
Counter.Hide();
}
updateCompleted = function() {
Counter.Show();
Filters.UpdateCompleted();
Progress.Complete();
$(selectors.refreshButton).prop('disabled', false);
// hack for fixing padding since input can grow and change height
$('body').css('padding-top', $('.navbar').height());
}
pause = function() {
Progress.Pause();
Filters.Pause();
if (timer != false) {
clearInterval(timer);
timer = false;
}
}
resume = function() {
if (Config.GetOption('autorefresh').Get()) {
Filters.UpdateCompleted();
} else {
Filters.Pause();
return false;
}
Progress.Reset();
if (timer != false) {
clearInterval(timer);
}
timer = setTimeout(Unsee.Reload, Unsee.GetRefreshRate() * 1000);
}
flash = function() {
var bg = $('#flash').css('background-color');
$('#flash').css('display', 'block').animate({
backgroundColor: '#fff'
}, 300, function() {
$(this).animate({
backgroundColor: bg
}, 100).css('display', 'none');
});
}
return {
Init: init,
Pause: pause,
WaitForNextReload: resume,
Reload: triggerReload,
GetRefreshRate: getRefreshRate,
SetRefreshRate: setRefreshRate,
Flash: flash
}
})();
$(document).ready(function() {
// init all elements using bootstrapSwitch
$('.toggle').bootstrapSwitch();
// enable tooltips, #settings is a dropdown so it already uses different data-toggle
$('[data-toggle="tooltip"], #settings').tooltip({
trigger: 'hover'
});
$('#labelModal').on('show.bs.modal', function(event) {
Unsee.Pause();
var modal = $(this);
var label = $(event.relatedTarget);
var label_key = label.data('label-key');
var label_val = label.data('label-val');
var attrs = Alerts.GetLabelAttrs(label_key, label_val);
var counter = Summary.Get(label_key, label_val);
modal.find('.modal-title').html(
haml.compileHaml('modal-title')({
attrs: attrs,
counter: counter
})
);
var hints = Autocomplete.GenerateHints(label_key, label_val);
modal.find('.modal-body').html(haml.compileHaml('modal-body')({hints: hints}));
modal.on('click', '.modal-button-filter', function(elem){
var filter = $(elem.target).data('filter-append-value');
$('#labelModal').modal('hide');
Filters.AddFilter(filter);
});
});
$('#labelModal').on('hidden.bs.modal', function(event) {
var modal = $(this);
modal.find('.modal-title').children().remove();
modal.find('.modal-body').children().remove();
Unsee.WaitForNextReload();
});
Unsee.Init();
// delay initial alert load to allow browser finish rendering
setTimeout(function() {
Filters.SetFilters();
}, 100);
});

84
static/watchdog.js Normal file
View File

@@ -0,0 +1,84 @@
var Watchdog = (function() {
var selectors = {
countdown: '#reload-counter'
}
var lastTs = 0;
var maxLag;
var timer = false;
var inCountdown = false;
var fatalCountdown = 60;
var fatalReloadTimer = false;
var fatalCounterTimer = false;
timerTick = function() {
if (lastTs == 0) return;
// don't raise an error if autorefresh is disabled
if (!Config.GetOption('autorefresh').Get()) return;
var now = moment().unix();
if (now - lastTs > maxLag) {
Grid.Clear();
$('#errors').html(haml.compileHaml('fatal-error')({
last_ts: lastTs,
seconds_left: fatalCountdown
}));
Counter.Unknown();
Summary.Reset();
if (!inCountdown) {
fatalCountdown = 60;
fatalReloadTimer = setTimeout(function() {
location.reload();
}, 60 * 1000);
fatalCounterTimer = setInterval(function() {
$(selectors.countdown).text(--fatalCountdown);
}, 1000);
inCountdown = true;
}
} else {
inCountdown = false;
if (fatalReloadTimer) clearTimeout(fatalReloadTimer);
if (fatalCounterTimer) clearTimeout(fatalCounterTimer);
}
}
init = function(interval, tolerance) {
maxLag = tolerance;
setInterval(timerTick, interval * 1000);
}
updateMaxLag = function(interval) {
maxLag = Math.max(interval + 50, 300);
}
updateTs = function(ts) {
lastTs = ts;
}
getTs = function() {
return lastTs;
}
getFatal = function() {
return inCountdown;
}
return {
Init: init,
UpdateTolerance: updateMaxLag,
Pong: updateTs,
GetLastUpdate: getTs,
IsFatal: getFatal
}
}());

45
store/store.go Normal file
View File

@@ -0,0 +1,45 @@
package store
import (
"sync"
"time"
"github.com/cloudflare/unsee/models"
)
type alertStoreType struct {
Store []models.UnseeAlertGroup
Timestamp time.Time
}
type silenceStoreType struct {
Store map[string]models.UnseeSilence
Timestamp time.Time
}
type colorStoreType struct {
Store models.UnseeColorMap
Timestamp time.Time
}
type autocompleteStore struct {
Store []models.UnseeAutocomplete
Timestamp time.Time
}
var (
// StoreLock guards access to all variables storing internal data
// (alerts, silences, colors, ac)
StoreLock = sync.RWMutex{}
// AlertStore holds all alerts retrieved from AlertManager
AlertStore = alertStoreType{}
// SilenceStore holds all silences retrieved from AlertManager
SilenceStore = silenceStoreType{}
// ColorStore holds all color maps generated from alerts
ColorStore = colorStoreType{}
// AutocompleteStore holds all autocomplete data generated from alerts
AutocompleteStore = autocompleteStore{}
)

58
templates/errors.haml Normal file
View File

@@ -0,0 +1,58 @@
<script type="text/template" id="reload-needed">
%div.jumbotron
%h1.text-center
New version detected, reloading ...
%i.fa.fa-refresh.fa-spin
</script>
<script type="text/template" id="update-error">
%div.jumbotron
%h1.text-center
=error
%i.fa.fa-exclamation-circle.text-danger
-if (message) {
%div.text-center
%p
=message
-if (last_ts > 0) {
%div.text-center
%p
Last successful update was
=moment.unix(last_ts).fromNow()
</script>
<script type="text/template" id="fatal-error">
%div.jumbotron
%h1.text-center
Critical error
%i.fa.fa-exclamation-circle.text-danger
%div.text-center
%p
-var msg = 'Auto refresh is enabled but last update was ' + moment.unix(last_ts).fromNow();
=msg
%p
This page will auto reload in
%span#reload-counter
=seconds_left
seconds
</script>
<script type="text/template" id="internal-error">
%div.jumbotron
%h1.text-center
Internal error
%i.fa.fa-exclamation-circle.text-danger
%div.text-center
%p
-if (name) {
=name
-if (message) {
=message
-if (!name && !message) {
-var msg = raw.split('(');
-if (msg.length > 0) {
=msg[0]
</script>

147
templates/groups.haml Normal file
View File

@@ -0,0 +1,147 @@
<script type="text/template" id="groups">
%div.incident{id: group.id, 'data-hash': group.hash}
-var cls_panel = 'panel-success';
-if (group.unsilencedCount > 0) {
-cls_panel = 'panel-danger';
%div.panel{class: cls_panel}
%div.panel-heading.text-center
%span.badge.pull-right
=group.alerts.length
%div.panel-title
-if (Object.keys(group.labels).length > 0) {
-$.each(Alerts.SortMapByKey(group.labels), function(i, label) {
-var attrs = Alerts.GetLabelAttrs(label.key, label.value);
%div.label-list.label{class: attrs.class, style: attrs.style, 'data-label-type': "filter", 'data-label-key': label.key, 'data-label-val': label.value, type: 'button', 'data-toggle': 'modal', 'data-target': '#labelModal'}
=label.text
-else {
%div.label-list.label
-var labelMap = {};
-var skipped = 0;
-$.each(group.alerts, function(i, alert) {
-if (i > alert_limit - 1) {
-skipped++;
-$.each(alert.labels, function(label_key, label_val) {
-var text = label_key + ': ' + label_val;
-if (group.labels[label_key] == undefined) {
-if (labelMap[text] == undefined) {
-labelMap[text] = {key: label_key, value: label_val, hits: 0};
-labelMap[text].hits++;
-var silencedText = '@silenced: false';
-var isSilenced = 'false';
-if (alert.silenced > 0) {
-silencedText = '@silenced: true';
-isSilenced = 'true';
-if (labelMap[silencedText] == undefined) {
-labelMap[silencedText] = {key: '@silenced', value: isSilenced, hits: 0};
-labelMap[silencedText].hits++;
-else {
-var cls_body = '';
-var cls_age = '';
-if (i < group.alerts.length - 1) {
-cls_body = 'incident-group-separator';
%div.panel-body{class: cls_body}
%div.incident-group
-var now = moment();
-$.each(Alerts.SortMapByKey(alert.annotations), function(i, annotation) {
%div.well.well-sm.annotation-well
%i.fa.fa-question-circle.text-muted{title: annotation.key, 'data-toggle': 'tooltip', 'data-placement': 'top'}
-if (annotation.value == '') {
%span.text-muted
[ missing annotation value ]
-else {
=annotation.value
-$.each(Alerts.SortMapByKey(alert.labels), function(i, label) {
-if (group.labels[label.key] == undefined) {
-var attrs = Alerts.GetLabelAttrs(label.key, label.value);
%span{class: attrs.class, style: attrs.style, 'data-label-type': "filter", 'data-label-key': label.key, 'data-label-val': label.value, type: 'button', 'data-toggle': 'modal', 'data-target': '#labelModal'}
=label.text
-var cls_age = '';
-var cls_body = '';
-var cls_indicator = 'incident-indicator-danger';
-if (alert.silenced > 0) {
-cls_indicator = 'incident-indicator-success';
%div
-if (alert.generatorURL) {
%a.label.label-list.label-default{href: alert.generatorURL, target: '_blank'}
%i.fa.fa-external-link
source
-$.each(alert.links, function(k, url) {
%a.label.label-list.label-default{href: url, target: '_blank'}
%i.fa.fa-external-link
=k
-if (alert.silenced > 0) {
%span.label.label-list.label-success{'data-label-type': "filter", 'data-label-key': "@silenced", 'data-label-val': "true", type: 'button', 'data-toggle': 'modal', 'data-target': '#labelModal'}
@silenced: true
-else {
%span.label.label-list.label-danger{'data-label-type': "filter", 'data-label-key': "@silenced", 'data-label-val': "false", type: 'button', 'data-toggle': 'modal', 'data-target': '#labelModal'}
@silenced: false
%a.label.label-list.label-default.label-age.label-ts{'data-toggle': 'tooltip', 'data-placement': 'top', 'data-ts': alert.startsAt}
%span.label-ts-span
=alert.startsAt
%div.incident-indicator.hidden{class: cls_indicator}
-if (alert.silenced > 0) {
%div
-var silence = silences[alert.silenced];
-if(silence) {
%blockquote.silence-comment
-if (silence.jiraURL) {
%a{target: '_blank', href: silence.jiraURL}
%i.fa.fa-external-link
=silence.comment
-else {
=silence.comment
%footer
%cite
%abbr{'data-toggle': 'tooltip', 'data-placement': 'bottom', 'data-ts': silence.startsAt}
=silence.createdBy
-if (!$.isEmptyObject(labelMap)) {
-labelArr = [];
-$.each(labelMap, function(text, label){
-labelArr.push({text: text, key: label.key, value: label.value, hits: label.hits});
-labelArr.sort(function(a, b) {
-if (a.hits < b.hits) return 1;
-if (a.hits > b.hits) return -1;
-if (a.text > b.text) return 1;
-if (a.text < b.text) return -1;
-return 0;
%div.panel-body.incident-group-separator
%div.incident-group
-var skippedLabel = '+' + skipped + " alerts";
%span.badge
=skippedLabel
-var rendered = 0;
-$.each(labelArr, function(i, label) {
-if (rendered > 8 && labelArr.length > 10) {
%span.label.label-list.label-default
-var text = "+" + (labelArr.length - rendered) + " labels";
=text
-return false;
-} else {
-rendered++;
-var attrs = Alerts.GetLabelAttrs(label.key, label.value);
-if (label.hits > 1) {
%div.label-trim-group
%span.label.label-trim-tag{class: attrs.class, style: attrs.style, 'data-label-type': "filter", 'data-label-key': label.key, 'data-label-val': label.value, type: 'button', 'data-toggle': 'modal', 'data-target': '#labelModal'}
=label.text
%span.label.label-default.label-trim-count
=label.hits
-else {
%span{class: attrs.class, style: attrs.style, 'data-label-type': "filter", 'data-label-key': label.key, 'data-label-val': label.value, type: 'button', 'data-toggle': 'modal', 'data-target': '#labelModal'}
=label.text
</script>

274
templates/help.html Normal file
View File

@@ -0,0 +1,274 @@
<!DOCTYPE html>
<html class="full" lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="">
<link rel="shortcut icon" id="favicon" href="/static/favicon.ico">
<title>(◕︵◕)</title>
{{ range .CSSFiles }}
<link rel="stylesheet" href="/static/assets/css/{{ . }}"> {{- end }}
<link rel="stylesheet" href="/static/base.css">
</head>
<body>
<div class="container">
<div class="row">
<div class="page-header text-center">
<h1>
Documentation
</h1>
</div>
<table class="table help">
<caption class="text-center">Filter match types</caption>
<thead>
<tr>
<th>Match</th>
<th>Example</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><kbd>=</kbd></td>
<td><code>$key=$value</code></td>
<td>Exact match. True if compared alert attribute value is equal to <code>$value</code>.</td>
</tr>
<tr>
<td class="text-center"><kbd>!=</kbd></td>
<td><code>$key!=$value</code></td>
<td>Negative match. True if compared alert attribute is missing or have a value that is not equal to <code>$value</code>.</td>
</tr>
<tr>
<td class="text-center"><kbd>=~</kbd></td>
<td><code>$key=~$value</code></td>
<td>Regular expression match. True if compared alert attribute value matches <code>$value</code> regex.</td>
</tr>
<tr>
<td class="text-center"><kbd>!~</kbd></td>
<td><code>$key!~$value</code></td>
<td>Negative regular expression match. False if compared alert attribute value matches <code>$value</code> regex.</td>
</tr>
<tr>
<td class="text-center"><kbd>&gt;</kbd></td>
<td><code>$key&gt;$value</code></td>
<td>Greater than match. True if compared alert attribute value is greater than <code>$value</code>.</td>
</tr>
<tr>
<td class="text-center"><kbd>&lt;</kbd></td>
<td><code>$key&lt;$value</code></td>
<td>Less than match. True if compared alert attribue value is less than <code>$value</code>.</td>
</tr>
</tbody>
</table>
<table class="table help">
<caption class="text-center">Filtering using alert labels</caption>
<thead>
<tr>
<th>Filter</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td id="help-labels">
<code>$key(= != =~ !~ &lt; &gt;)$value</code>
</td>
<td>
<p>Match alerts based on any label.</p>
<table class="table examples">
<tbody>
<tr>
<td><span class="label label-info">hostname=localhost</span></td>
<td>Match alerts with label <em>hostname</em> equal to <em>localhost</em>.</td>
</tr>
<tr>
<td><span class="label label-info">service=apache2</span></td>
<td>Match alerts with label <em>service</em> equal to <em>apache2</em>.</td>
</tr>
<tr>
<td><span class="label label-info">service!=apache3</span></td>
<td>Match alerts with label <em>service</em> missing or not equal to <em>apache3</em>.</td>
</tr>
<tr>
<td><span class="label label-info">service=~apache</span></td>
<td>Match alerts with label <em>service</em> matching regular expression <code>/.*apache.*/</code>.</td>
</tr>
<tr>
<td><span class="label label-info">service=~apache[1-3]</span></td>
<td>Match alerts with label <em>service</em> matching regular expression <code>/.*apache[1-3].*/</code>.</td>
</tr>
<tr>
<td><span class="label label-info">priority>4</span></td>
<td>Match alerts with label <em>priority</em> value > than 4. Value will be casted to integer if possible, string comparision will be used as fallback.</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table class="table help">
<caption class="text-center">Filtering alerts using special filters</caption>
<thead>
<tr>
<th>Filter</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td id="help-silenced">
<code>@silenced=(true false)</code>
</td>
<td>
<p>Match alerts based on silence status.</p>
<table class="table examples">
<tbody>
<tr>
<td><span class="label label-info">@silenced=true</span></td>
<td>Match only silenced alerts.</td>
</tr>
<tr>
<td><span class="label label-info">@silenced=false</span></td>
<td>Match only unsilenced alerts.</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td id="help-silence_author">
<code>@silence_author(= != =~ !~)$value</code>
</td>
<td>
<p>Match alerts based on the author of silence.</p>
<table class="table examples">
<tbody>
<tr>
<td><span class="label label-info">@silence_author=me@domain1.com</span></td>
<td>Match alerts silenced by <em>me@domain1.com</em>.</td>
</tr>
<tr>
<td><span class="label label-info">@silence_author!=me@domain1.com</span></td>
<td>Match alerts not silenced by <em>me@domain1.com</em>.</td>
</tr>
<tr>
<td><span class="label label-info">@silence_author=~@domain2.com</span></td>
<td>Match alerts silenced by username that match regular expression <code>/.*@domain2.com.*/</code>.</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td id="help-silence_jira">
<code>@silence_jira(= != =~ !~)$value</code>
</td>
<td>
<p>Match alerts based on the jira linked in the silence. This only works if JIRA regexp are enabled and able to match JIRA ids in the silence comment body.</p>
<table class="table examples">
<tbody>
<tr>
<td><span class="label label-info">@silence_jira=PROJECT-123</span></td>
<td>Match silenced alerts where detected JIRA issue id is equal to <em>PROJECT-123</em>.</td>
</tr>
<tr>
<td><span class="label label-info">@silence_jira!=PROJECT-123</span></td>
<td>Match silenced alerts where there was no JIRA issue id detected or it was not equal to <em>PROJECT-123</em>.</td>
</tr>
<tr>
<td><span class="label label-info">@silence_jira=~PROJECT</span></td>
<td>Match silenced alerts where dectected JIRA issue id matches regular expression <code>/.*PROJECT.*/</code>.</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td id="help-limit">
<code>@limit=$value</code>
</td>
<td>
<p>Limit number of displayed alerts. Value must be a number &gt;= 1.</p>
<table class="table examples">
<tbody>
<tr>
<td><span class="label label-info">@limit=10</span></td>
<td>Limit number of displayed alerts to 10.</td>
</tr>
<tr>
<td><span class="label label-info">@limit=550</span></td>
<td>Limit number of displayed alerts to 550.</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td id="help-age">
<code>@age(&lt; &gt;)$value</code>
</td>
<td>
<p>Match alerts based on creation timestamp.</p>
<table class="table examples">
<tbody>
<tr>
<td><span class="label label-info">@age&gt;15m</span></td>
<td>Match alerts older than 15 minutes.</td>
</tr>
<tr>
<td><span class="label label-info">@age&gt;1h</span></td>
<td>Match alerts older than 1 hour.</td>
</tr>
<tr>
<td><span class="label label-info">@age&lt;10h30m</span></td>
<td>Match alerts newer than 10 hours and 30 minutes.</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<nav>
<ul class="pager">
<li>
<a href="/">
<i class="fa fa-arrow-circle-left"></i> Back
</a>
</li>
</ul>
</nav>
</div>
</div>
</body>
</html>
{{ if .SentryDSN }}
<script type="application/javascript">
$(document).ready(function () {
var dsn = '{{ .SentryDSN }}'.replace('\/', '/');
Raven.config(dsn, {
release: {{ .Version }}
}).install();
});
</script>
{{ end }}

169
templates/index.html Normal file
View File

@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html class="full" lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="">
<link rel="shortcut icon" id="favicon" href="/static/favicon.ico">
<title>(◕︵◕)</title>
{{ range .CSSFiles }}
<link rel="stylesheet" href="/static/assets/css/{{ . }}">
{{- end }}
<link rel="stylesheet" href="/static/base.css?_={{ .NowQ }}">
</head>
<body class="dark">
<nav class="navbar navbar-default navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<a class="navbar-brand text-center">
<strong id="alert-count">0</strong>
<div id="spinner" class="loader-inner line-scale-pulse-out" style="display: none;">
<div></div>
<div></div>
<div></div>
</div>
</a>
</div>
<div id="navbar" class="navbar-collapse collapse">
<form class="navbar-form navbar-nav">
<div class="form-group">
<div class="input-group filterbar">
<div class="input-group-addon input-sm">
<i class="fa fa-search" id="filter-icon"></i>
</div>
<input id="filter"
class="form-control input-sm"
type="text"
autocomplete="off"
value="{{ .QFilter }}"
data-default-used="{{ .DefaultUsed }}"
data-default-filter="{{ .Config.DefaultFilter }}"
autofocus>
</div>
</div>
</form>
<ul class="nav navbar-nav navbar-right">
<li>
<a href="/help" id="help" role="button" title="Filter documentation" data-toggle="tooltip" data-placement="auto">
<i class="fa fa-question-circle"></i>
</a>
</li>
<li>
<a href="#" id="refresh" role="button" title="Refresh" data-toggle="tooltip" data-placement="auto">
<i class="fa fa-refresh"></i>
</a>
</li>
<li class="dropdown">
<a href="#" id="settings" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"
title="Settings" data-toggle="tooltip" data-placement="auto">
<i id="menu" class="fa fa-bars"></i>
</a>
<ul class="dropdown-menu dropdown-menu-form">
<li class="dropdown-header text-center">
Settings
</li>
<li class="text-nowrap dropdown-switch">
<div class="checkbox">
<input type="checkbox" class="toggle" id="autorefresh"
data-label-text="Auto Refresh" checked="checked">
</div>
</li>
<li class="text-nowrap dropdown-switch">
<select class="form-control" id="refresh-interval">
<option value="10">10s refresh</option>
<option value="15" selected="selected">15s refresh</option>
<option value="20">20s refresh</option>
<option value="30">30s refresh</option>
<option value="45">45s refresh</option>
<option value="60">1m refresh</option>
<option value="120">2m refresh</option>
<option value="300">5m refresh</option>
</select>
</li>
<li class="text-nowrap dropdown-switch">
<div class="checkbox">
<input type="checkbox" class="toggle" id="show-flash"
data-label-text="Flash on changes" checked="checked">
</div>
</li>
<li class="text-nowrap dropdown-switch">
<div class="checkbox">
<input type="checkbox" class="toggle" id="append-top"
data-label-text="New alerts on top" checked="checked">
</div>
</li>
<li role="separator" class="divider"></li>
<li class="text-nowrap dropdown-switch text-center">
<button class="btn btn-success btn-sm btn-dropdown-action"
id="save-default-filter"
title="Save current filter as default"
data-toggle="tooltip"
data-placement="auto">
<i class="fa fa-save"></i>
</button>
<button class="btn btn-primary btn-sm btn-dropdown-action"
id="copy-settings-with-filter"
title="Copy link with current settings and filters to clipboard"
data-toggle="tooltip"
data-placement="auto">
<i class="fa fa-clipboard"></i>
</button>
<button class="btn btn-danger btn-sm btn-dropdown-action"
id="reset-settings"
title="Reset all settings to default value"
data-toggle="tooltip"
data-placement="auto">
<i class="fa fa-undo"></i>
</button>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid" id="container">
<div id="errors"></div>
<div id="alerts" data-static-color-labels="{{ .StaticColorLabels }}">
<div class="grid-sizer"></div>
</div>
</div>
<div class="flash" id="flash">
</div>
{{ range .JSFiles }}
<script type="text/javascript" src="/static/assets/js/{{ . }}"></script>
{{- end }}
{{ template "js.html" .}}
<div class="modal fade" id="labelModal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header text-center">
<div class="modal-title"></div>
</div>
<div class="modal-body"></div>
</div>
</div>
</div>
</body>
</html>
{{ template "groups.haml"}}
{{ template "summary.haml" }}
{{ template "errors.haml" }}
{{ template "modal.haml"}}

26
templates/js.html Normal file
View File

@@ -0,0 +1,26 @@
<script type="text/javascript" src="/static/jquery.typing-0.2.0.js"></script>
<script type="text/javascript" src="/static/lru.js"></script>
<script type="text/javascript" src="/static/alerts.js?_={{ .NowQ }}"></script>
<script type="text/javascript" src="/static/autocomplete.js?_={{ .NowQ }}"></script>
<script type="text/javascript" src="/static/config.js?_={{ .NowQ }}"></script>
<script type="text/javascript" src="/static/colors.js?_={{ .NowQ }}"></script>
<script type="text/javascript" src="/static/counter.js?_={{ .NowQ }}"></script>
<script type="text/javascript" src="/static/filters.js?_={{ .NowQ }}"></script>
<script type="text/javascript" src="/static/grid.js?_={{ .NowQ }}"></script>
<script type="text/javascript" src="/static/progress.js?_={{ .NowQ }}"></script>
<script type="text/javascript" src="/static/summary.js?_={{ .NowQ }}"></script>
<script type="text/javascript" src="/static/watchdog.js?_={{ .NowQ }}"></script>
<script type="text/javascript" src="/static/querystring.js?_={{ .NowQ }}"></script>
<script type="text/javascript" src="/static/unsee.js?_={{ .NowQ }}"></script>
{{ if .SentryDSN }}
<script type="application/javascript">
$(document).ready(function () {
var dsn = '{{ .SentryDSN }}'.replace('\/', '/');
Raven.config(dsn, {
release: {{ .Version }}
}).install();
});
</script>
{{ end }}

22
templates/modal.haml Normal file
View File

@@ -0,0 +1,22 @@
<script type="text/template" id="modal-title">
%button.close{type: "button", "data-dismiss": "modal"}
%i.fa.fa-close
%div.label-list.label{class: attrs.class, style: attrs.style}
=attrs.text
%span.badge.badge-primary
=counter
</script>
<script type="text/template" id="modal-body">
%table.table.table-striped
%caption.text-center
Quick filters
%tbody
-$.each(hints, function(i, hint){
%tr
%td.modal-row-filters
=hint
%td.modal-row-actions
%button.btn.btn-sm.btn-primary.pull-right.modal-button-filter{type: "button", 'data-filter-append-value': hint}
Apply
</script>

22
templates/summary.haml Normal file
View File

@@ -0,0 +1,22 @@
<script type="text/template" id="breakdown">
%div.popover
%div.arrow
%h1.popover-title.text-center
%div.popover-content{id: 'breakdown-content'}
</script>
<script type="text/template" id="breakdown-content">
-if (tags.length > 0) {
%table.table
-$.each(tags, function(i, tag) {
%tr
%td
%span.label{class: tag.cls, style: tag.style}
=tag.name
%td{align: 'center'}
%span.breakdown-badge
=tag.val
-else {
%p.text-muted
Empty
</script>

174
timer.go Normal file
View File

@@ -0,0 +1,174 @@
package main
import (
"crypto/sha1"
"fmt"
"io"
"runtime"
"sort"
"strconv"
"time"
"github.com/cloudflare/unsee/alertmanager"
"github.com/cloudflare/unsee/config"
"github.com/cloudflare/unsee/models"
"github.com/cloudflare/unsee/store"
"github.com/cloudflare/unsee/transform"
log "github.com/Sirupsen/logrus"
"github.com/cnf/structhash"
"github.com/prometheus/client_golang/prometheus"
)
// PullFromAlertManager will try to fetch latest alerts and silences
// from AlertManager API, it's called by Ticker timer
func PullFromAlertManager() {
log.Info("Pulling latest alerts and silences from AlertManager")
silenceResponse := alertmanager.SilenceAPIResponse{}
err := silenceResponse.Get()
if err != nil {
log.Error(err.Error())
errorLock.Lock()
alertManagerError = err.Error()
errorLock.Unlock()
metricAlertManagerErrors.With(prometheus.Labels{"endpoint": "silences"}).Inc()
return
}
alertGroups := alertmanager.AlertGroupsAPIResponse{}
err = alertGroups.Get()
if err != nil {
log.Error(err.Error())
errorLock.Lock()
alertManagerError = err.Error()
errorLock.Unlock()
metricAlertManagerErrors.With(prometheus.Labels{"endpoint": "alerts"}).Inc()
return
}
silenceStore := make(map[string]models.UnseeSilence)
for _, silence := range silenceResponse.Data.Silences {
jiraID, jiraLink := transform.DetectJIRAs(&silence)
silenceStore[strconv.Itoa(silence.ID)] = models.UnseeSilence{
AlertManagerSilence: silence,
JiraID: jiraID,
JiraURL: jiraLink,
}
}
store.StoreLock.Lock()
store.SilenceStore.Store = silenceStore
store.SilenceStore.Timestamp = time.Now()
store.StoreLock.Unlock()
alertStore := []models.UnseeAlertGroup{}
colorStore := make(models.UnseeColorMap)
acMap := map[string]models.UnseeAutocomplete{}
// counters used to update metrics
var counterAlertsSilenced float64
var counterAlertsUnsilenced float64
for _, alertGroup := range alertGroups.Groups {
if len(alertGroup.Blocks) == 0 {
// skip groups with empty blocks
continue
}
// used to generate group content hash
agHasher := sha1.New()
ag := models.UnseeAlertGroup{
Labels: alertGroup.Labels,
Alerts: []models.UnseeAlert{},
}
alerts := map[string]models.UnseeAlert{}
ignoredLabels := []string{}
for _, il := range config.Config.StripLabels {
ignoredLabels = append(ignoredLabels, il)
}
for _, alertBlock := range alertGroup.Blocks {
for _, alert := range alertBlock.Alerts {
apiAlert := models.UnseeAlert{AlertManagerAlert: alert}
apiAlert.Annotations, apiAlert.Links = transform.DetectLinks(apiAlert.Annotations)
apiAlert.Labels = transform.StripLables(ignoredLabels, apiAlert.Labels)
hash := fmt.Sprintf("%x", structhash.Sha1(apiAlert, 1))
// add alert to map if not yet present
if _, found := alerts[hash]; !found {
alerts[hash] = apiAlert
io.WriteString(agHasher, hash) // alert group hasher
}
for k, v := range alert.Labels {
transform.ColorLabel(colorStore, k, v)
}
}
}
for _, alert := range alerts {
ag.Alerts = append(ag.Alerts, alert)
if alert.Silenced > 0 {
counterAlertsSilenced++
} else {
counterAlertsUnsilenced++
}
}
for _, hint := range transform.BuildAutocomplete(ag.Alerts) {
acMap[hint.Value] = hint
}
sort.Sort(&ag.Alerts)
// ID is unique to each group
ag.ID = fmt.Sprintf("%x", structhash.Sha1(ag.Labels, 1))
// Hash is a checksum of all alerts, used to tell when any alert in the group changed
ag.Hash = fmt.Sprintf("%x", agHasher.Sum(nil))
alertStore = append(alertStore, ag)
}
acStore := []models.UnseeAutocomplete{}
for _, hint := range acMap {
acStore = append(acStore, hint)
}
errorLock.Lock()
alertManagerError = ""
errorLock.Unlock()
metricAlerts.With(prometheus.Labels{"silenced": "true"}).Set(counterAlertsSilenced)
metricAlerts.With(prometheus.Labels{"silenced": "false"}).Set(counterAlertsUnsilenced)
metricAlertGroups.Set(float64(len(alertStore)))
now := time.Now()
store.StoreLock.Lock()
store.AlertStore.Store = alertStore
store.AlertStore.Timestamp = now
store.ColorStore.Store = colorStore
store.ColorStore.Timestamp = now
store.AutocompleteStore.Store = acStore
store.AutocompleteStore.Timestamp = now
store.StoreLock.Unlock()
log.Info("Pull completed")
apiCache.Flush()
runtime.GC()
}
// Tick is the background timer used to call PullFromAlertManager
func Tick() {
for {
select {
case <-ticker.C:
PullFromAlertManager()
}
}
}

24
transform/autocomplete.go Normal file
View File

@@ -0,0 +1,24 @@
package transform
import (
"github.com/cloudflare/unsee/filters"
"github.com/cloudflare/unsee/models"
)
// BuildAutocomplete takes an alert object and generates list of autocomplete
// strings for it
func BuildAutocomplete(alerts []models.UnseeAlert) []models.UnseeAutocomplete {
acHints := map[string]models.UnseeAutocomplete{}
for _, filterConfig := range filters.AllFilters {
if filterConfig.Autocomplete != nil {
for _, hint := range filterConfig.Autocomplete(filterConfig.Label, filterConfig.SupportedOperators, alerts) {
acHints[hint.Value] = hint
}
}
}
acHintsSlice := []models.UnseeAutocomplete{}
for _, hint := range acHints {
acHintsSlice = append(acHintsSlice, hint)
}
return acHintsSlice
}

71
transform/colors.go Normal file
View File

@@ -0,0 +1,71 @@
package transform
import (
"crypto/sha1"
"io"
"math/rand"
"github.com/cloudflare/unsee/config"
"github.com/cloudflare/unsee/models"
"github.com/hansrodtang/randomcolor"
)
func labelToSeed(key string, val string) int64 {
h := sha1.New()
io.WriteString(h, key)
io.WriteString(h, val)
var seed int64
for _, i := range h.Sum(nil) {
seed += int64(i)
}
return seed
}
// ColorLabel update UnseeColorMap object with a color object generated
// from label key and value passed here
// It's used to generate unique colors for configured labels
func ColorLabel(colorStore models.UnseeColorMap, key string, val string) {
if stringInSlice(config.Config.ColorLabels, key) == true {
if _, found := colorStore[key]; !found {
colorStore[key] = make(map[string]models.UnseeLabelColor)
}
if _, found := colorStore[key][val]; !found {
rand.Seed(labelToSeed(key, val))
color := randomcolor.New(randomcolor.Random, randomcolor.LIGHT)
red, green, blue, alpha := color.RGBA()
bc := models.UnseeColor{
Red: uint8(red >> 8),
Green: uint8(green >> 8),
Blue: uint8(blue >> 8),
Alpha: uint8(alpha >> 8),
}
// check if color is bright or dark and pick the right background
// uses https://www.w3.org/WAI/ER/WD-AERT/#color-contrast method
var brightness int32
brightness = ((int32(bc.Red) * 299) + (int32(bc.Green) * 587) + (int32(bc.Blue) * 114)) / 1000
var fc models.UnseeColor
if brightness <= 125 {
// background color is dark, use white font
fc = models.UnseeColor{
Red: 255,
Green: 255,
Blue: 255,
Alpha: 255,
}
} else {
// background color is bright, use dark font
fc = models.UnseeColor{
Red: 44,
Green: 62,
Blue: 80,
Alpha: 255,
}
}
colorStore[key][val] = models.UnseeLabelColor{
Font: fc,
Background: bc,
}
}
}
}

48
transform/jira.go Normal file
View File

@@ -0,0 +1,48 @@
package transform
import (
"fmt"
"log"
"regexp"
"strings"
"github.com/cloudflare/unsee/models"
)
type jiraDetectRule struct {
Regexp *regexp.Regexp
URL string
}
var jiraDetectRules = []jiraDetectRule{}
// ParseRules will parse and validate list of JIRA detection rules provided
// from config, valid rules will be stored for future use in DetectJIRAs() calls
func ParseRules(rules []string) {
for _, s := range rules {
ss := strings.SplitN(s, "@", 2)
re := ss[0]
url := ss[1]
if re == "" || url == "" {
log.Fatalf("Invalid JIRA rule '%s', regexp part is '%s', url is '%s'", s, re, url)
}
jdr := jiraDetectRule{
Regexp: regexp.MustCompile(re),
URL: url,
}
jiraDetectRules = append(jiraDetectRules, jdr)
}
}
// DetectJIRAs will try to find JIRA links in AlertManager silence objects
// using regexp rules from configuration that were parsed and populated
// by ParseRules call
func DetectJIRAs(silence *models.AlertManagerSilence) (jiraID, jiraLink string) {
for _, jdr := range jiraDetectRules {
jiraID := jdr.Regexp.FindString(silence.Comment)
if jiraID != "" {
jiraLink := fmt.Sprintf("%s/browse/%s", jdr.URL, jiraID)
return jiraID, jiraLink
}
}
return "", ""
}

21
transform/links.go Normal file
View File

@@ -0,0 +1,21 @@
package transform
import "github.com/asaskevich/govalidator"
// DetectLinks takes alert annotation dict and returns two dicts:
// first with regular annotations
// secondd with annotations where values are URLs
func DetectLinks(sourceAnnotations map[string]string) (map[string]string, map[string]string) {
links := make(map[string]string)
annotations := make(map[string]string)
for k, v := range sourceAnnotations {
if govalidator.IsURL(v) {
links[k] = v
} else {
annotations[k] = v
}
}
return annotations, links
}

10
transform/slices.go Normal file
View File

@@ -0,0 +1,10 @@
package transform
func stringInSlice(stringArray []string, value string) bool {
for _, s := range stringArray {
if s == value {
return true
}
}
return false
}

20
transform/strip.go Normal file
View File

@@ -0,0 +1,20 @@
package transform
import (
"strings"
)
// StripLables allows filtering out some labels from alerts
// it takes the list of label keys to ignore and alert label map
// it will return label map without labels found on the ignore list
func StripLables(ignoredLabels []string, sourceLabels map[string]string) map[string]string {
labels := map[string]string{}
for label, value := range sourceLabels {
if !stringInSlice(ignoredLabels, label) {
// strip leading and trailung space in label value
// this is to normalize values in case space is added by AlertManager rules
labels[label] = strings.TrimSpace(value)
}
}
return labels
}

Some files were not shown because too many files have changed in this diff Show More