mirror of
https://github.com/prymitive/karma
synced 2026-02-13 20:59:53 +00:00
Import code from internal repository (#1)
Import code from internal repository
This commit is contained in:
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
Dockerfile
|
||||
Makefile
|
||||
unsee
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
unsee
|
||||
75
.gitmodules
vendored
Normal file
75
.gitmodules
vendored
Normal 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
16
Dockerfile
Normal 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
13
LICENSE.md
Normal 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
123
Makefile
Normal 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
170
README.md
Normal 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
40
alertmanager/alerts.go
Normal 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
66
alertmanager/remote.go
Normal 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
48
alertmanager/silences.go
Normal 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
32
alerts.go
Normal 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
35
assets.go
Normal 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
50
config/config.go
Normal 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
16
filters/autocomplete.go
Normal 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
99
filters/filter.go
Normal 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
72
filters/filter_age.go
Normal 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
63
filters/filter_fuzzy.go
Normal 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
10
filters/filter_invalid.go
Normal 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
84
filters/filter_label.go
Normal 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
69
filters/filter_limit.go
Normal 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
|
||||
}
|
||||
65
filters/filter_silence_author.go
Normal file
65
filters/filter_silence_author.go
Normal 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
|
||||
}
|
||||
65
filters/filter_silence_jira.go
Normal file
65
filters/filter_silence_jira.go
Normal 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
|
||||
}
|
||||
73
filters/filter_silenced.go
Normal file
73
filters/filter_silenced.go
Normal 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
385
filters/filter_test.go
Normal 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
131
filters/matcher.go
Normal 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
155
filters/matcher_test.go
Normal 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
72
filters/registry.go
Normal 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: ¬EqualMatcher{},
|
||||
moreThanOperator: &moreThanMatcher{},
|
||||
lessThanOperator: &lessThanMatcher{},
|
||||
regexpOperator: ®expMatcher{},
|
||||
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
119
main.go
Normal 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
133
models/models.go
Normal 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
205
static/alerts.js
Normal 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
|
||||
}
|
||||
|
||||
}());
|
||||
1
static/assets/css/0.1.2-loaders.min.css
vendored
Normal file
1
static/assets/css/0.1.2-loaders.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/assets/css/0.2.0-nprogress.min.css
vendored
Normal file
1
static/assets/css/0.2.0-nprogress.min.css
vendored
Normal 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)}}
|
||||
54
static/assets/css/0.8.0-bootstrap-tagsinput-typeahead.css
Normal file
54
static/assets/css/0.8.0-bootstrap-tagsinput-typeahead.css
Normal 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;
|
||||
}
|
||||
60
static/assets/css/0.8.0-bootstrap-tagsinput.css
Normal file
60
static/assets/css/0.8.0-bootstrap-tagsinput.css
Normal 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);
|
||||
}
|
||||
22
static/assets/css/3.3.2-bootstrap-switch.min.css
vendored
Normal file
22
static/assets/css/3.3.2-bootstrap-switch.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
11
static/assets/css/3.3.7-bootstrap.min.css
vendored
Normal file
11
static/assets/css/3.3.7-bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
static/assets/css/4.7.0-font-awesome.min.css
vendored
Normal file
4
static/assets/css/4.7.0-font-awesome.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
7
static/assets/css/assets.txt
Normal file
7
static/assets/css/assets.txt
Normal 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
|
||||
BIN
static/assets/fonts/FontAwesome.otf
Normal file
BIN
static/assets/fonts/FontAwesome.otf
Normal file
Binary file not shown.
BIN
static/assets/fonts/fontawesome-webfont.eot
Normal file
BIN
static/assets/fonts/fontawesome-webfont.eot
Normal file
Binary file not shown.
2671
static/assets/fonts/fontawesome-webfont.svg
Normal file
2671
static/assets/fonts/fontawesome-webfont.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 434 KiB |
BIN
static/assets/fonts/fontawesome-webfont.ttf
Normal file
BIN
static/assets/fonts/fontawesome-webfont.ttf
Normal file
Binary file not shown.
BIN
static/assets/fonts/fontawesome-webfont.woff
Normal file
BIN
static/assets/fonts/fontawesome-webfont.woff
Normal file
Binary file not shown.
BIN
static/assets/fonts/fontawesome-webfont.woff2
Normal file
BIN
static/assets/fonts/fontawesome-webfont.woff2
Normal file
Binary file not shown.
1
static/assets/js/0.1.2-loaders.css.min.js
vendored
Normal file
1
static/assets/js/0.1.2-loaders.css.min.js
vendored
Normal 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);
|
||||
8
static/assets/js/0.11.1-typeahead.bundle.min.js
vendored
Normal file
8
static/assets/js/0.11.1-typeahead.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/assets/js/0.2.0-nprogress.min.js
vendored
Normal file
1
static/assets/js/0.2.0-nprogress.min.js
vendored
Normal 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
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
9
static/assets/js/0.4.0-sha1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
static/assets/js/0.8.0-bootstrap-tagsinput.min.js
vendored
Normal file
7
static/assets/js/0.8.0-bootstrap-tagsinput.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
static/assets/js/1.5.16-clipboard.min.js
vendored
Normal file
7
static/assets/js/1.5.16-clipboard.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
static/assets/js/1.8.3-underscore-min.js
vendored
Normal file
6
static/assets/js/1.8.3-underscore-min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
static/assets/js/2.1.3-js.cookie.min.js
vendored
Normal file
2
static/assets/js/2.1.3-js.cookie.min.js
vendored
Normal 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
551
static/assets/js/2.17.1-moment.min.js
vendored
Normal 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
4
static/assets/js/2.2.4-jquery.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/assets/js/2.4.0-underscore.string.min.js
vendored
Normal file
1
static/assets/js/2.4.0-underscore.string.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
5
static/assets/js/2.4.2-isInViewport.min.js
vendored
Normal file
5
static/assets/js/2.4.2-isInViewport.min.js
vendored
Normal 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);
|
||||
22
static/assets/js/3.3.2-bootstrap-switch.min.js
vendored
Normal file
22
static/assets/js/3.3.2-bootstrap-switch.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
static/assets/js/3.3.7-bootstrap.min.js
vendored
Normal file
7
static/assets/js/3.3.7-bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
static/assets/js/3.9.1-raven.min.js
vendored
Normal file
3
static/assets/js/3.9.1-raven.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
9
static/assets/js/4.1.1-masonry.pkgd.min.js
vendored
Normal file
9
static/assets/js/4.1.1-masonry.pkgd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2302
static/assets/js/5.4-haml.js
Normal file
2302
static/assets/js/5.4-haml.js
Normal file
File diff suppressed because it is too large
Load Diff
18
static/assets/js/assets.txt
Normal file
18
static/assets/js/assets.txt
Normal 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
|
||||
1
static/assets/js/bootstrap-tagsinput.min.js.map
Normal file
1
static/assets/js/bootstrap-tagsinput.min.js.map
Normal file
File diff suppressed because one or more lines are too long
10
static/assets/js/haml.map
Normal file
10
static/assets/js/haml.map
Normal file
File diff suppressed because one or more lines are too long
1
static/assets/js/jquery.min.map
Normal file
1
static/assets/js/jquery.min.map
Normal file
File diff suppressed because one or more lines are too long
1
static/assets/js/js.cookie.min.js.map
Normal file
1
static/assets/js/js.cookie.min.js.map
Normal 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"}
|
||||
1
static/assets/js/raven.min.js.map
Normal file
1
static/assets/js/raven.min.js.map
Normal file
File diff suppressed because one or more lines are too long
1
static/assets/js/underscore-min.map
Normal file
1
static/assets/js/underscore-min.map
Normal file
File diff suppressed because one or more lines are too long
70
static/autocomplete.js
Normal file
70
static/autocomplete.js
Normal 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
374
static/base.css
Normal 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
65
static/colors.js
Normal 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
191
static/config.js
Normal 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
83
static/counter.js
Normal 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
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
153
static/filters.js
Normal file
153
static/filters.js
Normal 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
79
static/grid.js
Normal 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
|
||||
}
|
||||
|
||||
})();
|
||||
82
static/jquery.typing-0.2.0.js
Normal file
82
static/jquery.typing-0.2.0.js
Normal 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
299
static/lru.js
Normal 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
55
static/progress.js
Normal 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
47
static/querystring.js
Normal 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
85
static/summary.js
Normal 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
276
static/unsee.js
Normal 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
84
static/watchdog.js
Normal 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
45
store/store.go
Normal 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
58
templates/errors.haml
Normal 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
147
templates/groups.haml
Normal 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
274
templates/help.html
Normal 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>></kbd></td>
|
||||
<td><code>$key>$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><</kbd></td>
|
||||
<td><code>$key<$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(= != =~ !~ < >)$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 >= 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(< >)$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>15m</span></td>
|
||||
<td>Match alerts older than 15 minutes.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="label label-info">@age>1h</span></td>
|
||||
<td>Match alerts older than 1 hour.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="label label-info">@age<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
169
templates/index.html
Normal 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
26
templates/js.html
Normal 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
22
templates/modal.haml
Normal 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
22
templates/summary.haml
Normal 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
174
timer.go
Normal 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
24
transform/autocomplete.go
Normal 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
71
transform/colors.go
Normal 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
48
transform/jira.go
Normal 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
21
transform/links.go
Normal 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
10
transform/slices.go
Normal 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
20
transform/strip.go
Normal 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
|
||||
}
|
||||
1
vendor/github.com/DeanThompson/ginpprof
generated
vendored
Submodule
1
vendor/github.com/DeanThompson/ginpprof
generated
vendored
Submodule
Submodule vendor/github.com/DeanThompson/ginpprof added at 8c0e31bfea
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user