mirror of
https://github.com/prymitive/karma
synced 2026-02-13 20:59:53 +00:00
feat(ui): new UI written in React
This commit is contained in:
125
.bootstraprc
125
.bootstraprc
@@ -1,125 +0,0 @@
|
||||
---
|
||||
# Output debugging info
|
||||
# loglevel: debug
|
||||
|
||||
# Major version of Bootstrap: 3 or 4
|
||||
bootstrapVersion: 3
|
||||
|
||||
# If Bootstrap version 3 is used - turn on/off custom icon font path
|
||||
useCustomIconFontPath: false
|
||||
|
||||
# Webpack loaders, order matters
|
||||
styleLoaders:
|
||||
- style-loader
|
||||
- css-loader
|
||||
- sass-loader
|
||||
|
||||
# Extract styles to stand-alone css file
|
||||
# Different settings for different environments can be used,
|
||||
# It depends on value of NODE_ENV environment variable
|
||||
# This param can also be set in webpack config:
|
||||
# entry: 'bootstrap-loader/extractStyles'
|
||||
extractStyles: false
|
||||
# env:
|
||||
# development:
|
||||
# extractStyles: false
|
||||
# production:
|
||||
# extractStyles: true
|
||||
|
||||
# Customize Bootstrap variables that get imported before the original Bootstrap variables.
|
||||
# Thus original Bootstrap variables can depend on values from here. All the bootstrap
|
||||
# variables are configured with !default, and thus, if you define the variable here, then
|
||||
# that value is used, rather than the default. However, many bootstrap variables are derived
|
||||
# from other bootstrap variables, and thus, you want to set this up before we load the
|
||||
# official bootstrap versions.
|
||||
# For example, _variables.scss contains:
|
||||
# $input-color: $gray !default;
|
||||
# This means you can define $input-color before we load _variables.scss
|
||||
preBootstrapCustomizations: ./node_modules/bootswatch/flatly/_variables.scss
|
||||
|
||||
# This gets loaded after bootstrap/variables is loaded and before bootstrap is loaded.
|
||||
# A good example of this is when you want to override a bootstrap variable to be based
|
||||
# on the default value of bootstrap. This is pretty specialized case. Thus, you normally
|
||||
# just override bootstrap variables in preBootstrapCustomizations so that derived
|
||||
# variables will use your definition.
|
||||
#
|
||||
# For example, in _variables.scss:
|
||||
# $input-height: (($font-size-base * $line-height) + ($input-padding-y * 2) + ($border-width * 2)) !default;
|
||||
# This means that you could define this yourself in preBootstrapCustomizations. Or you can do
|
||||
# this in bootstrapCustomizations to make the input height 10% bigger than the default calculation.
|
||||
# Thus you can leverage the default calculations.
|
||||
# $input-height: $input-height * 1.10;
|
||||
#bootstrapCustomizations: ./app/styles/bootstrap/customizations.scss
|
||||
|
||||
# Import your custom styles here. You have access to all the bootstrap variables. If you require
|
||||
# your sass files separately, you will not have access to the bootstrap variables, mixins, clases, etc.
|
||||
# Usually this endpoint-file contains list of @imports of your application styles.
|
||||
appStyles: ./node_modules/bootswatch/flatly/_bootswatch.scss
|
||||
|
||||
### Bootstrap styles
|
||||
styles:
|
||||
|
||||
# Mixins
|
||||
mixins: true
|
||||
|
||||
# Reset and dependencies
|
||||
normalize: true
|
||||
print: true
|
||||
glyphicons: true
|
||||
|
||||
# Core CSS
|
||||
scaffolding: true
|
||||
type: true
|
||||
code: true
|
||||
grid: true
|
||||
tables: true
|
||||
forms: true
|
||||
buttons: true
|
||||
|
||||
# Components
|
||||
component-animations: true
|
||||
dropdowns: true
|
||||
button-groups: true
|
||||
input-groups: true
|
||||
navs: true
|
||||
navbar: true
|
||||
breadcrumbs: true
|
||||
pagination: true
|
||||
pager: true
|
||||
labels: true
|
||||
badges: true
|
||||
jumbotron: true
|
||||
thumbnails: true
|
||||
alerts: true
|
||||
progress-bars: true
|
||||
media: true
|
||||
list-group: true
|
||||
panels: true
|
||||
wells: true
|
||||
responsive-embed: true
|
||||
close: true
|
||||
|
||||
# Components w/ JavaScript
|
||||
modals: true
|
||||
tooltip: true
|
||||
popovers: true
|
||||
carousel: true
|
||||
|
||||
# Utility classes
|
||||
utilities: true
|
||||
responsive-utilities: true
|
||||
|
||||
### Bootstrap scripts
|
||||
scripts:
|
||||
transition: true
|
||||
alert: true
|
||||
button: true
|
||||
carousel: true
|
||||
collapse: true
|
||||
dropdown: true
|
||||
modal: true
|
||||
tooltip: true
|
||||
popover: true
|
||||
scrollspy: true
|
||||
tab: true
|
||||
affix: true
|
||||
@@ -1 +0,0 @@
|
||||
.gitignore
|
||||
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.build
|
||||
.coverage
|
||||
.tests
|
||||
bindata_assetfs.go
|
||||
unsee
|
||||
ui/build
|
||||
ui/coverage
|
||||
ui/node_modules
|
||||
vendor
|
||||
Dockerfile
|
||||
Dockerfile.*
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,8 +1,10 @@
|
||||
.build
|
||||
unsee
|
||||
vendor
|
||||
assets/static/dist
|
||||
node_modules
|
||||
.coverage
|
||||
.tests
|
||||
bindata_assetfs.go
|
||||
unsee
|
||||
ui/build
|
||||
ui/coverage
|
||||
ui/node_modules
|
||||
vendor
|
||||
Dockerfile.web
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM node:8-alpine as nodejs-builder
|
||||
RUN apk add --update make git
|
||||
COPY . /unsee
|
||||
RUN make -C /unsee webpack
|
||||
RUN make -C /unsee ui
|
||||
|
||||
FROM golang:1.10.3-alpine as go-builder
|
||||
COPY --from=nodejs-builder /unsee /go/src/github.com/prymitive/unsee
|
||||
|
||||
59
Makefile
59
Makefile
@@ -8,15 +8,11 @@ ALERTMANAGER_URI := "file://$(MOCK_PATH)"
|
||||
PORT := 8080
|
||||
|
||||
SOURCES := $(wildcard *.go) $(wildcard */*.go) $(wildcard */*/*.go)
|
||||
ASSET_SOURCES := $(wildcard assets/*/* assets/*/*/*)
|
||||
ASSET_SOURCES := $(wildcard ui/public/* ui/src/* ui/src/*/*)
|
||||
|
||||
GO_BINDATA_MODE := prod
|
||||
GIN_DEBUG := false
|
||||
ifdef DEBUG
|
||||
GO_BINDATA_FLAGS = -debug
|
||||
GO_BINDATA_MODE = debug
|
||||
GIN_DEBUG = true
|
||||
DOCKER_ARGS = -v $(CURDIR)/assets:$(CURDIR)/assets:ro
|
||||
endif
|
||||
|
||||
.DEFAULT_GOAL := $(NAME)
|
||||
@@ -33,9 +29,9 @@ endif
|
||||
go get -u github.com/golang/lint/golint
|
||||
touch $@
|
||||
|
||||
.build/deps-build-node.ok: package.json package-lock.json
|
||||
.build/deps-build-node.ok: ui/package.json ui/package-lock.json
|
||||
@mkdir -p .build
|
||||
npm install
|
||||
cd ui && npm install
|
||||
touch $@
|
||||
|
||||
.build/artifacts-bindata_assetfs.%:
|
||||
@@ -43,13 +39,13 @@ endif
|
||||
rm -f .build/artifacts-bindata_assetfs.*
|
||||
touch $@
|
||||
|
||||
.build/artifacts-webpack.ok: .build/deps-build-node.ok $(ASSET_SOURCES) webpack.config.js
|
||||
.build/artifacts-ui.ok: .build/deps-build-node.ok $(ASSET_SOURCES)
|
||||
@mkdir -p .build
|
||||
$(CURDIR)/node_modules/.bin/webpack
|
||||
cd ui && npm run build
|
||||
touch $@
|
||||
|
||||
bindata_assetfs.go: .build/deps-build-go.ok .build/artifacts-bindata_assetfs.$(GO_BINDATA_MODE) .build/vendor.ok .build/artifacts-webpack.ok
|
||||
go-bindata-assetfs $(GO_BINDATA_FLAGS) -o bindata_assetfs.go -prefix assets -nometadata assets/templates/... assets/static/dist/...
|
||||
bindata_assetfs.go: .build/deps-build-go.ok .build/artifacts-bindata_assetfs.$(GO_BINDATA_MODE) .build/vendor.ok .build/artifacts-ui.ok
|
||||
go-bindata-assetfs -o bindata_assetfs.go -nometadata ui/build/...
|
||||
|
||||
$(NAME): .build/deps-build-go.ok .build/vendor.ok bindata_assetfs.go $(SOURCES)
|
||||
go build -ldflags "-X main.version=$(VERSION)"
|
||||
@@ -66,19 +62,15 @@ vendor: .build/deps-build-go.ok
|
||||
vendor-update: .build/deps-build-go.ok
|
||||
dep ensure -update
|
||||
|
||||
.PHONY: webpack
|
||||
webpack: .build/artifacts-webpack.ok
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -fr .build bindata_assetfs.go $(NAME)
|
||||
rm -fr .build bindata_assetfs.go $(NAME) ui/build ui/node_modules
|
||||
|
||||
.PHONY: run
|
||||
run: $(NAME)
|
||||
ALERTMANAGER_URI=$(ALERTMANAGER_URI) \
|
||||
LABELS_COLOR_UNIQUE="@receiver instance cluster" \
|
||||
LABELS_COLOR_STATIC="job" \
|
||||
DEBUG="$(GIN_DEBUG)" \
|
||||
FILTERS_DEFAULT="@state=active" \
|
||||
PORT=$(PORT) \
|
||||
./$(NAME)
|
||||
@@ -92,13 +84,11 @@ run-docker: docker-image
|
||||
@docker rm -f $(NAME) || true
|
||||
docker run \
|
||||
--name $(NAME) \
|
||||
$(DOCKER_ARGS) \
|
||||
-v $(MOCK_PATH):$(MOCK_PATH) \
|
||||
-e ALERTMANAGER_URI=$(ALERTMANAGER_URI) \
|
||||
-e LABELS_COLOR_UNIQUE="instance cluster" \
|
||||
-e LABELS_COLOR_STATIC="job" \
|
||||
-e DEBUG="$(GIN_DEBUG)" \
|
||||
-e FILTERS_DEFAULT="@state=active" \
|
||||
-e FILTERS_DEFAULT="@state=active" \
|
||||
-e PORT=$(PORT) \
|
||||
-p $(PORT):$(PORT) \
|
||||
$(NAME):$(VERSION)
|
||||
@@ -109,34 +99,22 @@ lint-go: .build/deps-lint-go.ok
|
||||
|
||||
.PHONY: lint-js
|
||||
lint-js: .build/deps-build-node.ok
|
||||
$(CURDIR)/node_modules/.bin/eslint --quiet assets/static/*.js
|
||||
cd ui && ./node_modules/.bin/eslint --quiet src
|
||||
|
||||
.PHONY: lint-docs
|
||||
lint-docs: .build/deps-build-node.ok
|
||||
$(CURDIR)/node_modules/.bin/markdownlint *.md docs
|
||||
$(CURDIR)/ui/node_modules/.bin/markdownlint *.md docs
|
||||
|
||||
.PHONY: lint
|
||||
lint: lint-go lint-js lint-docs
|
||||
|
||||
# Creates mock bindata_assetfs.go with source assets rather than webpack generated ones
|
||||
.PHONY: mock-assets
|
||||
mock-assets: .build/deps-build-go.ok
|
||||
mkdir -p $(CURDIR)/assets/static/dist/templates
|
||||
cp $(CURDIR)/assets/static/*.* $(CURDIR)/assets/static/dist/
|
||||
touch $(CURDIR)/assets/static/dist/templates/loader_unsee.html
|
||||
touch $(CURDIR)/assets/static/dist/templates/loader_shared.html
|
||||
touch $(CURDIR)/assets/static/dist/templates/loader_help.html
|
||||
go-bindata-assetfs -prefix assets -nometadata assets/templates/... assets/static/dist/...
|
||||
# force assets rebuild on next make run
|
||||
rm -f .build/bindata_assetfs.*
|
||||
|
||||
.PHONY: test-go
|
||||
test-go: .build/vendor.ok
|
||||
go test -v -bench=. -cover `go list ./... | grep -v /vendor/`
|
||||
|
||||
.PHONY: test-js
|
||||
test-js: .build/deps-build-node.ok
|
||||
npm test
|
||||
cd ui && CI=true npm test -- --coverage
|
||||
|
||||
.PHONY: test
|
||||
test: lint test-go test-js
|
||||
@@ -144,3 +122,16 @@ test: lint test-go test-js
|
||||
.PHONY: show-version
|
||||
show-version:
|
||||
@echo $(VERSION)
|
||||
|
||||
# Creates mock bindata_assetfs.go with source assets
|
||||
.PHONY: mock-assets
|
||||
mock-assets: .build/deps-build-go.ok
|
||||
rm -fr ui/build
|
||||
mkdir ui/build
|
||||
cp ui/public/* ui/build/
|
||||
go-bindata-assetfs -o bindata_assetfs.go -nometadata ui/build/...
|
||||
# force assets rebuild on next make run
|
||||
rm -f .build/bindata_assetfs.*
|
||||
|
||||
.PHONY: ui
|
||||
ui: .build/artifacts-ui.ok
|
||||
|
||||
63
assets.go
63
assets.go
@@ -1,13 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
assetfs "github.com/elazarl/go-bindata-assetfs"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type binaryFileSystem struct {
|
||||
@@ -21,10 +21,13 @@ func (b *binaryFileSystem) Open(name string) (http.File, error) {
|
||||
func (b *binaryFileSystem) Exists(prefix string, filepath string) bool {
|
||||
if p := strings.TrimPrefix(filepath, prefix); len(p) < len(filepath) {
|
||||
if _, err := b.fs.Open(p); err != nil {
|
||||
// file does not exist
|
||||
return false
|
||||
}
|
||||
// file exist
|
||||
return true
|
||||
}
|
||||
// file path doesn't start with fs prefix, so this file isn't stored here
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -32,42 +35,30 @@ func newBinaryFileSystem(root string) *binaryFileSystem {
|
||||
fs := &assetfs.AssetFS{
|
||||
Asset: Asset,
|
||||
// Don't render directory index, return 404 for /static/ requests)
|
||||
AssetDir: func(path string) ([]string, error) { return nil, errors.New("Not found") },
|
||||
Prefix: root,
|
||||
}
|
||||
return &binaryFileSystem{
|
||||
fs,
|
||||
AssetDir: func(path string) ([]string, error) {
|
||||
return nil, errors.New("Not found")
|
||||
},
|
||||
Prefix: root,
|
||||
}
|
||||
return &binaryFileSystem{fs}
|
||||
}
|
||||
|
||||
// load all templates from binary asset resource
|
||||
// this function will iterate all files with given prefix (e.g. /templates/)
|
||||
// and return Template instance with all templates loaded
|
||||
func loadTemplates(t *template.Template, prefix string) *template.Template {
|
||||
for _, filename := range AssetNames() {
|
||||
if strings.HasPrefix(filename, prefix) {
|
||||
templateContent, err := Asset(filename)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
var tmpl *template.Template
|
||||
if t == nil {
|
||||
// if template wasn't yet initialized do it here
|
||||
t = template.New(filename)
|
||||
}
|
||||
if filename == t.Name() {
|
||||
tmpl = t
|
||||
} else {
|
||||
// if we already have an instance of template.Template then
|
||||
// add a new file to it
|
||||
tmpl = t.New(filename)
|
||||
}
|
||||
_, err = tmpl.Parse(string(templateContent))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
func responseFromStaticFile(c *gin.Context, filepath string, contentType string) {
|
||||
if !staticFileSystem.Exists("/", filepath) {
|
||||
c.String(404, "Not found")
|
||||
return
|
||||
}
|
||||
return t
|
||||
|
||||
file, err := staticFileSystem.Open(filepath)
|
||||
if err != nil {
|
||||
c.AbortWithError(500, err)
|
||||
return
|
||||
}
|
||||
buf := bytes.NewBuffer(nil)
|
||||
_, err = buf.ReadFrom(file)
|
||||
if err != nil {
|
||||
c.AbortWithError(500, err)
|
||||
return
|
||||
}
|
||||
c.Data(200, contentType, buf.Bytes())
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
const mockXHR = require("mock-xhr");
|
||||
|
||||
function createServer(status, payload) {
|
||||
var server = new mockXHR.server();
|
||||
server.handle = function (request) {
|
||||
request.setResponseHeader("Content-Type", "application/json");
|
||||
request.receive(status, JSON.stringify(payload));
|
||||
};
|
||||
return server;
|
||||
}
|
||||
|
||||
exports.createServer = createServer;
|
||||
@@ -1,27 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const mockXHR = require("mock-xhr");
|
||||
|
||||
function getAlertsJSON() {
|
||||
const alertsPath = path.join(__dirname, "../../../.tests/alerts.json");
|
||||
if (fs.existsSync(alertsPath)) {
|
||||
return fs.readFileSync(alertsPath, {encoding: "utf-8"});
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function mockAlertsJSONserver() {
|
||||
var alertsData = getAlertsJSON();
|
||||
if (alertsData === "") {
|
||||
// we don't have alerts data, return false
|
||||
return false;
|
||||
}
|
||||
var server = new mockXHR.server();
|
||||
server.handle = function (request) {
|
||||
request.setResponseHeader("Content-Type", "application/json");
|
||||
request.receive(200, alertsData);
|
||||
};
|
||||
return server;
|
||||
}
|
||||
|
||||
exports.mockAlertsJSON = mockAlertsJSONserver;
|
||||
@@ -1,6 +0,0 @@
|
||||
class MockFavicon {
|
||||
badge() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MockFavicon;
|
||||
@@ -1,11 +0,0 @@
|
||||
const $ = require("jquery");
|
||||
|
||||
// always returns true, indicating that every tested element is in viewport
|
||||
function isInViewport() {
|
||||
return true;
|
||||
}
|
||||
$.extend($.expr[":"], {
|
||||
"in-viewport": $.expr.createPseudo
|
||||
? $.expr.createPseudo(argsString => currElement => isInViewport(currElement, argsString))
|
||||
: (currObj, index, meta) => isInViewport(currObj, meta)
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
class LocalStorageMock {
|
||||
|
||||
constructor() {
|
||||
this.store = {};
|
||||
}
|
||||
|
||||
getItem(key) {
|
||||
return this.store[key] || null;
|
||||
}
|
||||
|
||||
setItem(key, value) {
|
||||
this.store[key] = value.toString();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.store = {};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = new LocalStorageMock();
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = {};
|
||||
@@ -1,21 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function loadTemplates() {
|
||||
var templatesArr = [];
|
||||
const templateFiles = [
|
||||
"alertgroup.html",
|
||||
"errors.html",
|
||||
"modal.html",
|
||||
"silence.html",
|
||||
"summary.html",
|
||||
"history.html",
|
||||
];
|
||||
templateFiles.forEach(function(filename){
|
||||
var templatePath = path.join(__dirname, "../../templates/", filename);
|
||||
templatesArr.push(fs.readFileSync(templatePath, {encoding: "utf-8"}));
|
||||
});
|
||||
return templatesArr;
|
||||
}
|
||||
|
||||
exports.loadTemplates = loadTemplates;
|
||||
@@ -1,406 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`appended filtes should be present in history 1`] = `
|
||||
"<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default
|
||||
</span>
|
||||
<i class=\\"fa fa-home\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>"
|
||||
`;
|
||||
|
||||
exports[`appended filtes should be present in history 2`] = `
|
||||
"<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default
|
||||
</span>
|
||||
<i class=\\"fa fa-search\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li role=\\"separator\\" class=\\"divider\\"></li>
|
||||
|
||||
|
||||
|
||||
<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default
|
||||
</span>
|
||||
<i class=\\"fa fa-home\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>"
|
||||
`;
|
||||
|
||||
exports[`appended filtes should be present in history 3`] = `
|
||||
"<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default,bar
|
||||
</span>
|
||||
<i class=\\"fa fa-search\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
bar
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default
|
||||
</span>
|
||||
<i class=\\"fa fa-search\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li role=\\"separator\\" class=\\"divider\\"></li>
|
||||
|
||||
|
||||
|
||||
<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default
|
||||
</span>
|
||||
<i class=\\"fa fa-home\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>"
|
||||
`;
|
||||
|
||||
exports[`appended filtes should be present in history 4`] = `
|
||||
"<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default,bar,@state=active
|
||||
</span>
|
||||
<i class=\\"fa fa-search\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
bar
|
||||
</span>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
@state=active
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default,bar
|
||||
</span>
|
||||
<i class=\\"fa fa-search\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
bar
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default
|
||||
</span>
|
||||
<i class=\\"fa fa-search\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li role=\\"separator\\" class=\\"divider\\"></li>
|
||||
|
||||
|
||||
|
||||
<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default
|
||||
</span>
|
||||
<i class=\\"fa fa-home\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>"
|
||||
`;
|
||||
|
||||
exports[`appended filtes should be present in history 5`] = `
|
||||
"<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default,bar,@state=active
|
||||
</span>
|
||||
<i class=\\"fa fa-search\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
bar
|
||||
</span>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
@state=active
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default,bar
|
||||
</span>
|
||||
<i class=\\"fa fa-search\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
bar
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default
|
||||
</span>
|
||||
<i class=\\"fa fa-search\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li role=\\"separator\\" class=\\"divider\\"></li>
|
||||
|
||||
|
||||
|
||||
<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default
|
||||
</span>
|
||||
<i class=\\"fa fa-home\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>"
|
||||
`;
|
||||
|
||||
exports[`appended filtes should be present in history 6`] = `
|
||||
"<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
@state=active
|
||||
</span>
|
||||
<i class=\\"fa fa-search\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
@state=active
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default,bar,@state=active
|
||||
</span>
|
||||
<i class=\\"fa fa-search\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
bar
|
||||
</span>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
@state=active
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default,bar
|
||||
</span>
|
||||
<i class=\\"fa fa-search\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
bar
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li role=\\"separator\\" class=\\"divider\\"></li>
|
||||
|
||||
|
||||
|
||||
<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default
|
||||
</span>
|
||||
<i class=\\"fa fa-home\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>"
|
||||
`;
|
||||
|
||||
exports[`default filter should be in history after setting filter to foo 1`] = `
|
||||
"<li class=\\"history-menu\\">
|
||||
<a class=\\"cursor-pointer history-menu-item\\">
|
||||
<span class=\\"rawFilter hidden\\">
|
||||
default
|
||||
</span>
|
||||
<i class=\\"fa fa-home\\"></i>
|
||||
|
||||
<span class=\\"label-list label label-info\\">
|
||||
default
|
||||
</span>
|
||||
|
||||
</a>
|
||||
</li>"
|
||||
`;
|
||||
File diff suppressed because one or more lines are too long
@@ -1,13 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`linkify kibana link 1`] = `"<a href=\\"https://kibana/app/kibana#/dashboard/dashboard_name?_g=(time:(from:now-1h,mode:quick,to:now))&_a=(filters:!((query:(match:(host:(query:hostname,type:phrase))),meta:(alias:!n,disabled:!f,index:'logstash-*',key:host,negate:!f,value:hostname)),(meta:(alias:!n,disabled:!f,index:'logstash-*',key:program,negate:!f,value:puppet-agent),query:(match:(program:(query:puppet-agent,type:phrase)))),(meta:(alias:!n,disabled:!f,index:'logstash-*',key:level,negate:!f,value:ERROR),query:(match:(level:(query:ERROR,type:phrase))))))\\" title=\\"https://kibana/app/kibana#/dashboard/dashboard_name?_g=(time:(from:now-1h,mode:quick,to:now))&_a=(filters:!((query:(match:(host:(query:hostname,type:phrase))),meta:(alias:!n,disabled:!f,index:'logstash-*',key:host,negate:!f,value:hostname)),(meta:(alias:!n,disabled:!f,index:'logstash-*',key:program,negate:!f,value:puppet-agent),query:(match:(program:(query:puppet-agent,type:phrase)))),(meta:(alias:!n,disabled:!f,index:'logstash-*',key:level,negate:!f,value:ERROR),query:(match:(level:(query:ERROR,type:phrase))))))\\">https://kibana/app/kibana#/dashboard/dashboard_name?_g=(time:(from:now-1h,mode:quick,to:now))&_a=(filters:!((query:(match:(host:(query:hostname,type:phrase))),meta:(alias:!n,disabled:!f,index:'logstash-*',key:host,negate:!f,value:hostname)),(meta:(alias:!n,disabled:!f,index:'logstash-*',key:program,negate:!f,value:puppet-agent),query:(match:(program:(query:puppet-agent,type:phrase)))),(meta:(alias:!n,disabled:!f,index:'logstash-*',key:level,negate:!f,value:ERROR),query:(match:(level:(query:ERROR,type:phrase))))))</a>"`;
|
||||
|
||||
exports[`linkify kibana link 2`] = `"foo <a href=\\"https://kibana/app/kibana#/dashboard/dashboard_name?_g=(time:(from:now-1h,mode:quick,to:now))&_a=(filters:!((query:(match:(host:(query:hostname,type:phrase))),meta:(alias:!n,disabled:!f,index:'logstash-*',key:host,negate:!f,value:hostname)),(meta:(alias:!n,disabled:!f,index:'logstash-*',key:program,negate:!f,value:puppet-agent),query:(match:(program:(query:puppet-agent,type:phrase)))),(meta:(alias:!n,disabled:!f,index:'logstash-*',key:level,negate:!f,value:ERROR),query:(match:(level:(query:ERROR,type:phrase))))))\\" title=\\"https://kibana/app/kibana#/dashboard/dashboard_name?_g=(time:(from:now-1h,mode:quick,to:now))&_a=(filters:!((query:(match:(host:(query:hostname,type:phrase))),meta:(alias:!n,disabled:!f,index:'logstash-*',key:host,negate:!f,value:hostname)),(meta:(alias:!n,disabled:!f,index:'logstash-*',key:program,negate:!f,value:puppet-agent),query:(match:(program:(query:puppet-agent,type:phrase)))),(meta:(alias:!n,disabled:!f,index:'logstash-*',key:level,negate:!f,value:ERROR),query:(match:(level:(query:ERROR,type:phrase))))))\\">https://kibana/app/kibana#/dashboard/dashboard_name?_g=(time:(from:now-1h,mode:quick,to:now))&_a=(filters:!((query:(match:(host:(query:hostname,type:phrase))),meta:(alias:!n,disabled:!f,index:'logstash-*',key:host,negate:!f,value:hostname)),(meta:(alias:!n,disabled:!f,index:'logstash-*',key:program,negate:!f,value:puppet-agent),query:(match:(program:(query:puppet-agent,type:phrase)))),(meta:(alias:!n,disabled:!f,index:'logstash-*',key:level,negate:!f,value:ERROR),query:(match:(level:(query:ERROR,type:phrase))))))</a> bar"`;
|
||||
|
||||
exports[`linkify simple link 1`] = `"<a href=\\"http://localhost\\" title=\\"http://localhost\\">http://localhost</a>"`;
|
||||
|
||||
exports[`linkify simple link 2`] = `"<a href=\\"http://localhost:8080/abc\\" title=\\"http://localhost:8080/abc\\">http://localhost:8080/abc</a>"`;
|
||||
|
||||
exports[`linkify simple link 3`] = `"<a href=\\"http://localhost:8080/abc#foo\\" title=\\"http://localhost:8080/abc#foo\\">http://localhost:8080/abc#foo</a>"`;
|
||||
|
||||
exports[`linkify simple link 4`] = `"<a href=\\"http://localhost:8080/abc?foo\\" title=\\"http://localhost:8080/abc?foo\\">http://localhost:8080/abc?foo</a>"`;
|
||||
@@ -1,192 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const LRUMap = require("lru");
|
||||
const moment = require("moment");
|
||||
const $ = require("jquery");
|
||||
require("is-in-viewport");
|
||||
|
||||
const autocomplete = require("./autocomplete");
|
||||
const colors = require("./colors");
|
||||
const config = require("./config");
|
||||
const counter = require("./counter");
|
||||
const grid = require("./grid");
|
||||
const summary = require("./summary");
|
||||
const templates = require("./templates");
|
||||
const ui = require("./ui");
|
||||
const unsee = require("./unsee");
|
||||
|
||||
var labelCache = new LRUMap(1000);
|
||||
|
||||
function AlertGroup(groupData) {
|
||||
$.extend(this, groupData);
|
||||
}
|
||||
|
||||
AlertGroup.prototype.Render = function() {
|
||||
return templates.renderTemplate("alertGroup", {
|
||||
group: this,
|
||||
alertLimit: 5
|
||||
});
|
||||
};
|
||||
|
||||
// called after group was rendered for the first time
|
||||
AlertGroup.prototype.Added = function() {
|
||||
var elem = $("#" + this.id);
|
||||
ui.setupGroupTooltips(elem);
|
||||
ui.setupGroupLinkHover(elem);
|
||||
ui.setupGroupAnnotationToggles(elem);
|
||||
};
|
||||
|
||||
AlertGroup.prototype.Update = function() {
|
||||
// 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);
|
||||
};
|
||||
|
||||
|
||||
function destroyGroup(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));
|
||||
}
|
||||
|
||||
function sortMapByKey(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;
|
||||
}
|
||||
|
||||
function getLabelAttrs(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.getStyle(key, value)
|
||||
};
|
||||
labelCache.set(label, attrs);
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function humanizeTimestamps() {
|
||||
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 tsAge = now.diff(ts, "minutes");
|
||||
if (tsAge >= 0 && tsAge < 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);
|
||||
}
|
||||
|
||||
function updateAlerts(apiResponse) {
|
||||
var alertCount = 0;
|
||||
var groups = {};
|
||||
|
||||
var summaryData = {};
|
||||
$.each(apiResponse.counters, function(labelKey, counters){
|
||||
$.each(counters, function(labelVal, hits){
|
||||
summaryData[labelKey + ": " + labelVal] = hits;
|
||||
});
|
||||
});
|
||||
summary.update(summaryData);
|
||||
|
||||
$.each(apiResponse.groups, function(i, groupData) {
|
||||
var alertGroup = new AlertGroup(groupData);
|
||||
groups[alertGroup.id] = alertGroup;
|
||||
alertCount += alertGroup.alerts.length;
|
||||
});
|
||||
|
||||
counter.setCounter(alertCount);
|
||||
grid.show();
|
||||
|
||||
var dirty = false;
|
||||
|
||||
// handle already existing groups
|
||||
$.each(grid.items(), function(i, existingGroup) {
|
||||
var group = groups[existingGroup.id];
|
||||
if (group !== undefined && existingGroup.dataset !== 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
exports.updateAlerts = updateAlerts;
|
||||
exports.sortMapByKey = sortMapByKey;
|
||||
exports.getLabelAttrs = getLabelAttrs;
|
||||
@@ -1,24 +0,0 @@
|
||||
test("alerts getLabelAttrs()", () => {
|
||||
window.jQuery = require("jquery");
|
||||
const alerts = require("./alerts");
|
||||
expect(alerts.getLabelAttrs("foo", "bar")).toEqual({
|
||||
"class": "label label-list label-warning",
|
||||
"style": "",
|
||||
"text": "foo: bar"
|
||||
});
|
||||
expect(alerts.getLabelAttrs("@state", "active")).toEqual({
|
||||
"class": "label label-list label-danger",
|
||||
"style": "",
|
||||
"text": "@state: active"
|
||||
});
|
||||
expect(alerts.getLabelAttrs("@state", "suppressed")).toEqual({
|
||||
"class": "label label-list label-success",
|
||||
"style": "",
|
||||
"text": "@state: suppressed"
|
||||
});
|
||||
expect(alerts.getLabelAttrs("@state", "unprocessed")).toEqual({
|
||||
"class": "label label-list label-default",
|
||||
"style": "",
|
||||
"text": "@state: unprocessed"
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const Bloodhound = require("corejs-typeahead/dist/bloodhound");
|
||||
const $ = require("jquery");
|
||||
|
||||
var autocomplete;
|
||||
|
||||
function init() {
|
||||
autocomplete = new Bloodhound({
|
||||
datumTokenizer: Bloodhound.tokenizers.whitespace,
|
||||
queryTokenizer: Bloodhound.tokenizers.whitespace,
|
||||
remote: {
|
||||
url: "autocomplete.json?term=%QUERY",
|
||||
wildcard: "%QUERY",
|
||||
rateLimitBy: "throttle",
|
||||
rateLimitWait: 300
|
||||
},
|
||||
sufficient: 12
|
||||
});
|
||||
}
|
||||
|
||||
function reset() {
|
||||
autocomplete.clear();
|
||||
}
|
||||
|
||||
function getAutocomplete() {
|
||||
return autocomplete;
|
||||
}
|
||||
|
||||
// this is used to generate quick filters for label modal
|
||||
function generateHints(labelKey, labelVal) {
|
||||
var hints = [];
|
||||
if (labelKey == "@state") {
|
||||
// static list of hints for @silenced label
|
||||
hints.push("@state=active");
|
||||
hints.push("@state=suppressed");
|
||||
hints.push("@state=unprocessed");
|
||||
hints.push("@state!=active");
|
||||
hints.push("@state!=suppressed");
|
||||
hints.push("@state!=unprocessed");
|
||||
} else {
|
||||
// equal and non-equal hints for everything else
|
||||
hints.push(labelKey + "=" + labelVal);
|
||||
hints.push(labelKey + "!=" + labelVal);
|
||||
|
||||
// if there's space in the label generate regexp hints for partials
|
||||
if (labelVal.toString().indexOf(" ") >= 0) {
|
||||
$.each(labelVal.toString().split(" "), function(l, labelPart){
|
||||
hints.push(labelKey + "=~" + labelPart);
|
||||
hints.push(labelKey + "!~" + labelPart);
|
||||
});
|
||||
}
|
||||
|
||||
// if value is an int generate less / more hints
|
||||
if ($.isNumeric(labelVal)) {
|
||||
var valAsNumber = parseInt(labelVal);
|
||||
if (!isNaN(valAsNumber)) {
|
||||
hints.push(labelKey + ">" + labelVal);
|
||||
hints.push(labelKey + "<" + labelVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
exports.init = init;
|
||||
exports.reset = reset;
|
||||
exports.getAutocomplete = getAutocomplete;
|
||||
exports.generateHints = generateHints;
|
||||
@@ -1,62 +0,0 @@
|
||||
const Bloodhound = require("corejs-typeahead/dist/bloodhound");
|
||||
const autocomplete = require("./autocomplete");
|
||||
|
||||
test("autocomplete init()", () => {
|
||||
autocomplete.init();
|
||||
});
|
||||
|
||||
test("autocomplete getAutocomplete()", () => {
|
||||
expect(autocomplete.getAutocomplete()).toBeInstanceOf(Bloodhound);
|
||||
});
|
||||
|
||||
test("autocomplete reset()", () => {
|
||||
autocomplete.reset();
|
||||
});
|
||||
|
||||
test("autocomplete generateHints(@state, ...)", () => {
|
||||
[ "active", "suppressed", "unprocessed" ].forEach(function (state) {
|
||||
expect(
|
||||
JSON.stringify(autocomplete.generateHints("@state", state))
|
||||
).toBe(JSON.stringify([
|
||||
"@state=active",
|
||||
"@state=suppressed",
|
||||
"@state=unprocessed",
|
||||
"@state!=active",
|
||||
"@state!=suppressed",
|
||||
"@state!=unprocessed"
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
test("autocomplete generateHints(foo, bar)", () => {
|
||||
expect(
|
||||
JSON.stringify(autocomplete.generateHints("foo", "bar"))
|
||||
).toBe(
|
||||
JSON.stringify([ "foo=bar", "foo!=bar" ])
|
||||
);
|
||||
});
|
||||
|
||||
test("autocomplete generateHints(foo, bar with spaces)", () => {
|
||||
expect(
|
||||
JSON.stringify(autocomplete.generateHints("foo", "bar with spaces"))
|
||||
).toBe(
|
||||
JSON.stringify([
|
||||
"foo=bar with spaces",
|
||||
"foo!=bar with spaces",
|
||||
"foo=~bar",
|
||||
"foo!~bar",
|
||||
"foo=~with",
|
||||
"foo!~with",
|
||||
"foo=~spaces",
|
||||
"foo!~spaces"
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
test("autocomplete generateHints(number, 1)", () => {
|
||||
expect(
|
||||
JSON.stringify(autocomplete.generateHints("number", "1"))
|
||||
).toBe(
|
||||
JSON.stringify([ "number=1", "number!=1", "number>1", "number<1" ])
|
||||
);
|
||||
});
|
||||
@@ -1,515 +0,0 @@
|
||||
* {
|
||||
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;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cursor-help {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* make filter input take all the space it can */
|
||||
div.filterbar {
|
||||
width: 905px;
|
||||
}
|
||||
div.filterbar > .input-group-addon.input-sm {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
/* fix padding with flatly */
|
||||
div.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 */
|
||||
div.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 */
|
||||
#history:focus,
|
||||
#help:focus,
|
||||
#settings:focus,
|
||||
#refresh:focus {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#history:hover,
|
||||
#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;
|
||||
}
|
||||
|
||||
#instance-errors > .alert {
|
||||
padding: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.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-bottom: 0;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
blockquote.silence-comment {
|
||||
border-left-color: #18bc9c;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.silence-comment-title {
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
.silence-block {
|
||||
margin-bottom: 2px;
|
||||
padding-left: 0;
|
||||
padding-right: 2px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.silence-block.well {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* button is an icon inside a well, it gets white color
|
||||
make it dark gray by default, so it's visible but doesn't stand out
|
||||
and black on hover */
|
||||
button.silence-delete {
|
||||
color: inherit;
|
||||
margin-top: 2px;
|
||||
}
|
||||
button.silence-delete:hover,
|
||||
button.silence-delete:focus {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.incident .panel {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.incident {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.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 #bbb;
|
||||
}
|
||||
|
||||
.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 nprogress look for flatly */
|
||||
#nprogress .nprogress-flatly.bar {
|
||||
background: #f39c12;
|
||||
}
|
||||
#nprogress .nprogress-flatly.peg {
|
||||
box-shadow: 0 0 10px #f39c12, 0 0 5px #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;
|
||||
}
|
||||
|
||||
.panel-heading > span.alert-group-link {
|
||||
margin-left: -8px;
|
||||
}
|
||||
span.alert-group-link > a {
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.silence-result-icon {
|
||||
font-size: 12em;
|
||||
}
|
||||
|
||||
.silence-label-select {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.bootstrap-select > button {
|
||||
padding: 0;
|
||||
}
|
||||
.bootstrap-select > .dropdown-toggle {
|
||||
padding-right: 6px;
|
||||
}
|
||||
table.silence-label-selects {
|
||||
width: auto;
|
||||
}
|
||||
table.table.silence-label-selects > tbody > tr > td {
|
||||
padding-left: 0;
|
||||
padding-right: 4px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.silence-label-select > .bs-caret {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.silence-label-select:hover, .silence-label-picker:hover,
|
||||
.silence-label-select:active, .silence-label-picker:active,
|
||||
.silence-label-select:focus, .silence-label-picker:focus {
|
||||
color: inherit;
|
||||
}
|
||||
.select-label-badge {
|
||||
cursor: pointer;
|
||||
}
|
||||
.bootstrap-select > .dropdown-menu > .dropdown-menu > li > a > .label {
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.datetime-picker > .bootstrap-datetimepicker-widget > .row > .datepicker {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.silence-duration-table {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.silence-duration-text {
|
||||
font-size: 2em;
|
||||
}
|
||||
.silence-duration-btn:hover {
|
||||
color: #fff;
|
||||
background: #eee;
|
||||
}
|
||||
.silence-duration-btn:focus {
|
||||
color: #18bc9c;
|
||||
}
|
||||
.silence-duration-btn:focus:hover {
|
||||
color: #fff;
|
||||
}
|
||||
.silence-duration-btn > i {
|
||||
pointer-events: none;
|
||||
}
|
||||
table.silence-duration-table > tbody > tr > td.silence-separator {
|
||||
width: 10px;
|
||||
}
|
||||
table.silence-duration-table > tbody > tr > td.silence-duration-label {
|
||||
padding-left: 0;
|
||||
vertical-align: bottom;
|
||||
line-height: 2em;
|
||||
}
|
||||
#silenceJSON {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
a[aria-expanded=true] .fa-chevron-right {
|
||||
display: none;
|
||||
}
|
||||
a[aria-expanded=false] .fa-chevron-down {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* adjust size and padding to look like bootstrap button but without all background tweaks */
|
||||
.icon-as-button {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
padding: 10px 15px;
|
||||
font-size: 15px;
|
||||
line-height: 1.42857143;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.alert-static-elements {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
li.history-menu > a.history-menu-item {
|
||||
padding-left: 10px;
|
||||
padding-right: 4px;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 6px;
|
||||
max-width: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
53
assets/static/bootstrap-tagsinput.less
vendored
53
assets/static/bootstrap-tagsinput.less
vendored
@@ -1,53 +0,0 @@
|
||||
// lessified version of https://github.com/bootstrap-tagsinput/bootstrap-tagsinput/blob/master/src/bootstrap-tagsinput-typeahead.css
|
||||
|
||||
@import "bootstrap/less/variables.less";
|
||||
|
||||
.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: @zindex-dropdown;
|
||||
display: none;
|
||||
float: left;
|
||||
min-width: 160px;
|
||||
padding: 5px 0;
|
||||
margin: 2px 0 0;
|
||||
list-style: none;
|
||||
font-size: @font-size-base;
|
||||
background-color: @dropdown-bg;
|
||||
border: 1px solid @dropdown-fallback-border;
|
||||
border: 1px solid @dropdown-border;
|
||||
border-radius: @border-radius-base;
|
||||
-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: @line-height-base;
|
||||
color: @dropdown-link-color;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tt-cursor,
|
||||
.tt-suggestion:hover,
|
||||
.tt-suggestion:focus {
|
||||
color: @dropdown-link-hover-color;
|
||||
text-decoration: none;
|
||||
outline: 0;
|
||||
background-color: @dropdown-link-hover-bg;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
|
||||
var colors = {},
|
||||
staticColorLabels = [];
|
||||
|
||||
var specialLabels = {
|
||||
"@state: unprocessed": "label-default",
|
||||
"@state: active": "label-danger",
|
||||
"@state: suppressed": "label-success",
|
||||
};
|
||||
|
||||
function init(staticColors) {
|
||||
staticColorLabels = staticColors;
|
||||
}
|
||||
|
||||
function getStaticLabels() {
|
||||
return staticColorLabels;
|
||||
}
|
||||
|
||||
function isStaticLabel(key) {
|
||||
return ($.inArray(key, getStaticLabels()) >= 0);
|
||||
}
|
||||
|
||||
function update(colorData) {
|
||||
colors = colorData;
|
||||
}
|
||||
|
||||
function merge(colorData) {
|
||||
$.extend(colors, colorData);
|
||||
}
|
||||
|
||||
function getClass(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 (isStaticLabel(key)) {
|
||||
return "label-info";
|
||||
} else {
|
||||
return "label-warning";
|
||||
}
|
||||
}
|
||||
|
||||
function getStyle(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;
|
||||
}
|
||||
|
||||
exports.init = init;
|
||||
exports.update = update;
|
||||
exports.merge = merge;
|
||||
exports.getStyle = getStyle;
|
||||
exports.getClass = getClass;
|
||||
exports.getStaticLabels = getStaticLabels;
|
||||
exports.isStaticLabel = isStaticLabel;
|
||||
@@ -1,87 +0,0 @@
|
||||
const colors = require("./colors");
|
||||
|
||||
test("colors init([])", () => {
|
||||
colors.init([]);
|
||||
expect(colors.getStaticLabels()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("colors init([foo, bar])", () => {
|
||||
colors.init([ "foo", "bar" ]);
|
||||
expect(colors.getStaticLabels()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("colors isStaticLabel()", () => {
|
||||
colors.init([]);
|
||||
expect(colors.isStaticLabel("foo")).toBe(false);
|
||||
expect(colors.isStaticLabel("bar")).toBe(false);
|
||||
expect(colors.isStaticLabel("foobar")).toBe(false);
|
||||
colors.init([ "foo", "bar" ]);
|
||||
expect(colors.isStaticLabel("foo")).toBe(true);
|
||||
expect(colors.isStaticLabel("bar")).toBe(true);
|
||||
expect(colors.isStaticLabel("foobar")).toBe(false);
|
||||
});
|
||||
|
||||
test("colors getClass()", () => {
|
||||
colors.init([ "foo" ]);
|
||||
// hardcoded value
|
||||
expect(colors.getClass("alertname", "foo")).toBe("label-primary");
|
||||
// special case
|
||||
expect(colors.getClass("@state", "unprocessed")).toBe("label-default");
|
||||
expect(colors.getClass("@state", "active")).toBe("label-danger");
|
||||
expect(colors.getClass("@state", "suppressed")).toBe("label-success");
|
||||
// static label passed via init()
|
||||
expect(colors.getClass("foo", "bar")).toBe("label-info");
|
||||
// anything else
|
||||
expect(colors.getClass("bar", "foo")).toBe("label-warning");
|
||||
expect(colors.getClass("key", "val")).toBe("label-warning");
|
||||
expect(colors.getClass("", "")).toBe("label-warning");
|
||||
});
|
||||
|
||||
test("colors getStyle()", () => {
|
||||
colors.init([]);
|
||||
expect(colors.getStyle("foo", "bar")).toEqual("");
|
||||
var c = {
|
||||
"foo": {
|
||||
"bar": {
|
||||
"background": {
|
||||
"red": 0,
|
||||
"green": 1,
|
||||
"blue": 2,
|
||||
"alpha": 255
|
||||
},
|
||||
"font": {
|
||||
"red": 3,
|
||||
"green": 4,
|
||||
"blue": 5,
|
||||
"alpha": 128
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
colors.update(c);
|
||||
expect(colors.getStyle("foo", "bar")).toEqual(
|
||||
"background-color: rgba(0, 1, 2, 255); color: rgba(3, 4, 5, 128); "
|
||||
);
|
||||
var d = {
|
||||
"foo": {
|
||||
"bar2": {
|
||||
"background": {
|
||||
"red": 1,
|
||||
"green": 1,
|
||||
"blue": 2,
|
||||
"alpha": 255
|
||||
},
|
||||
"font": {
|
||||
"red": 3,
|
||||
"green": 4,
|
||||
"blue": 5,
|
||||
"alpha": 128
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
colors.merge(d);
|
||||
expect(colors.getStyle("foo", "bar2")).toEqual(
|
||||
"background-color: rgba(1, 1, 2, 255); color: rgba(3, 4, 5, 128); "
|
||||
);
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const Cookies = require("js-cookie");
|
||||
const Clipboard = require("clipboard");
|
||||
|
||||
const filters = require("./filters");
|
||||
const Option = require("./option");
|
||||
const unsee = require("./unsee");
|
||||
const querystring = require("./querystring");
|
||||
|
||||
var options = {};
|
||||
|
||||
function newOption(params) {
|
||||
var opt = new Option(params);
|
||||
opt.Init();
|
||||
options[opt.QueryParam] = opt;
|
||||
}
|
||||
|
||||
function getOption(queryParam) {
|
||||
return options[queryParam];
|
||||
}
|
||||
|
||||
function loadFromCookies() {
|
||||
$.each(options, function(name, option) {
|
||||
var value = option.Load();
|
||||
if (value !== undefined) {
|
||||
option.Set(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function reset() {
|
||||
// this is not part of options map
|
||||
Cookies.remove("defaultFilter.v2");
|
||||
$.each(options, function(name, option) {
|
||||
Cookies.remove(option.Cookie);
|
||||
});
|
||||
}
|
||||
|
||||
function init(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() {
|
||||
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() {
|
||||
reset();
|
||||
querystring.remove("q");
|
||||
location.reload();
|
||||
});
|
||||
|
||||
// https://github.com/twbs/bootstrap/issues/2097
|
||||
$(document).on("click", ".dropdown-menu.dropdown-menu-form", function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
newOption({
|
||||
Cookie: "autoRefresh",
|
||||
QueryParam: "autorefresh",
|
||||
Selector: "#autorefresh",
|
||||
Action: function(val) {
|
||||
if (val) {
|
||||
unsee.resume();
|
||||
} else {
|
||||
unsee.pause();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
newOption({
|
||||
Cookie: "showFlash",
|
||||
QueryParam: "flash",
|
||||
Selector: "#show-flash"
|
||||
});
|
||||
|
||||
newOption({
|
||||
Cookie: "appendTop",
|
||||
QueryParam: "appendtop",
|
||||
Selector: "#append-top"
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
exports.init = init;
|
||||
exports.reset = reset;
|
||||
exports.loadFromCookies = loadFromCookies;
|
||||
exports.newOption = newOption;
|
||||
exports.getOption = getOption;
|
||||
@@ -1,9 +0,0 @@
|
||||
test("config init()", () => {
|
||||
window.jQuery = require("jquery");
|
||||
const config = require("./config");
|
||||
config.init({
|
||||
CopySelector: "#copy-settings-with-filter",
|
||||
SaveSelector: "#save-default-filter",
|
||||
ResetSelector: "#reset-settings"
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const Favico = require("favico.js");
|
||||
const $ = require("jquery");
|
||||
|
||||
var selectors = {
|
||||
counter: "#alert-count",
|
||||
spinner: "#spinner"
|
||||
};
|
||||
|
||||
var favicon = false;
|
||||
|
||||
function hide() {
|
||||
$(selectors.counter).hide();
|
||||
$(selectors.spinner).children().removeClass("spinner-success spinner-error");
|
||||
$(selectors.spinner).show();
|
||||
}
|
||||
|
||||
function show() {
|
||||
$(selectors.spinner).hide();
|
||||
$(selectors.counter).show();
|
||||
}
|
||||
|
||||
function setCounter(val) {
|
||||
favicon.badge(val);
|
||||
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 = "(◕︵◕)";
|
||||
}
|
||||
}
|
||||
|
||||
function markUnknown() {
|
||||
favicon.badge("?");
|
||||
show();
|
||||
$(selectors.counter).html("?");
|
||||
$(selectors.counter).removeClass("text-success text-warning text-danger");
|
||||
}
|
||||
|
||||
function markError() {
|
||||
$(selectors.spinner).children().removeClass("spinner-success").addClass("spinner-error");
|
||||
}
|
||||
|
||||
function markSuccess() {
|
||||
$(selectors.spinner).children().addClass("spinner-success");
|
||||
}
|
||||
|
||||
function init() {
|
||||
favicon = new Favico({
|
||||
animation: "none",
|
||||
position: "up",
|
||||
bgColor: "#333",
|
||||
textColor: "#ff0"
|
||||
});
|
||||
markUnknown();
|
||||
}
|
||||
|
||||
exports.init = init;
|
||||
exports.hide = hide;
|
||||
exports.show = show;
|
||||
exports.setCounter = setCounter;
|
||||
exports.markError = markError;
|
||||
exports.markSuccess = markSuccess;
|
||||
exports.markUnknown = markUnknown;
|
||||
@@ -1,84 +0,0 @@
|
||||
const $ = require("jquery");
|
||||
|
||||
const counter = require("./counter");
|
||||
|
||||
const mockHTML =
|
||||
"<div>" +
|
||||
" <div id='alert-count' ></div>" +
|
||||
" <div id='spinner' >" +
|
||||
" <span id='spinner-child'></span>" +
|
||||
" </div>" +
|
||||
"</div>";
|
||||
|
||||
test("counter & spinner visibility after hide() & show()", () => {
|
||||
document.body.innerHTML = mockHTML;
|
||||
|
||||
counter.init();
|
||||
expect($("#alert-count").css("display")).not.toEqual("none");
|
||||
expect($("#spinner").css("display")).toEqual("none");
|
||||
|
||||
counter.hide();
|
||||
expect($("#alert-count").css("display")).toEqual("none");
|
||||
expect($("#spinner").css("display")).not.toEqual("none");
|
||||
|
||||
counter.show();
|
||||
expect($("#alert-count").css("display")).not.toEqual("none");
|
||||
expect($("#spinner").css("display")).toEqual("none");
|
||||
|
||||
counter.hide();
|
||||
expect($("#alert-count").css("display")).toEqual("none");
|
||||
expect($("#spinner").css("display")).not.toEqual("none");
|
||||
|
||||
counter.setCounter(0);
|
||||
expect($("#alert-count").css("display")).not.toEqual("none");
|
||||
expect($("#spinner").css("display")).toEqual("none");
|
||||
});
|
||||
|
||||
test("counter colors are correct", () => {
|
||||
document.body.innerHTML = mockHTML;
|
||||
|
||||
counter.init();
|
||||
expect($("#alert-count").hasClass("text-success")).toBe(false);
|
||||
expect($("#alert-count").hasClass("text-warning")).toBe(false);
|
||||
expect($("#alert-count").hasClass("text-danger")).toBe(false);
|
||||
|
||||
counter.setCounter(0);
|
||||
expect(document.title).toBe("(◕‿◕)");
|
||||
expect($("#alert-count").hasClass("text-success")).toBe(true);
|
||||
expect($("#alert-count").hasClass("text-warning")).toBe(false);
|
||||
expect($("#alert-count").hasClass("text-danger")).toBe(false);
|
||||
|
||||
for (var i = 1; i < 10; i++) {
|
||||
counter.setCounter(i);
|
||||
expect(document.title).toBe("(◕_◕)");
|
||||
expect($("#alert-count").hasClass("text-success")).toBe(false);
|
||||
expect($("#alert-count").hasClass("text-warning")).toBe(true);
|
||||
expect($("#alert-count").hasClass("text-danger")).toBe(false);
|
||||
}
|
||||
|
||||
for (i = 10; i < 20; i++) {
|
||||
counter.setCounter(i);
|
||||
expect(document.title).toBe("(◕︵◕)");
|
||||
expect($("#alert-count").hasClass("text-success")).toBe(false);
|
||||
expect($("#alert-count").hasClass("text-warning")).toBe(false);
|
||||
expect($("#alert-count").hasClass("text-danger")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("spinner children is red after error", () => {
|
||||
document.body.innerHTML = mockHTML;
|
||||
|
||||
counter.init();
|
||||
counter.markError();
|
||||
expect($("#spinner-child").hasClass("spinner-success")).toBe(false);
|
||||
expect($("#spinner-child").hasClass("spinner-error")).toBe(true);
|
||||
});
|
||||
|
||||
test("spinner children is green after success", () => {
|
||||
document.body.innerHTML = mockHTML;
|
||||
|
||||
counter.init();
|
||||
counter.markSuccess();
|
||||
expect($("#spinner-child").hasClass("spinner-success")).toBe(true);
|
||||
expect($("#spinner-child").hasClass("spinner-error")).toBe(false);
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,242 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = window.$ = window.jQuery = require("jquery");
|
||||
const sha1 = require("js-sha1");
|
||||
const Cookies = require("js-cookie");
|
||||
|
||||
require("./jquery.typing-0.3.2.js");
|
||||
require("corejs-typeahead");
|
||||
require("bootstrap-tagsinput");
|
||||
require("bootstrap-tagsinput/dist/bootstrap-tagsinput.css");
|
||||
require("./bootstrap-tagsinput.less");
|
||||
|
||||
const autocomplete = require("./autocomplete");
|
||||
const unsee = require("./unsee");
|
||||
const querystring = require("./querystring");
|
||||
const templates = require("./templates");
|
||||
|
||||
var selectors = {
|
||||
filter: "#filter",
|
||||
icon: "#filter-icon",
|
||||
historyMenu: "#historyMenu"
|
||||
};
|
||||
var appendsEnabled = true;
|
||||
var historyStorage;
|
||||
const historyKey = "filterHistory";
|
||||
|
||||
function addBadge(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>");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getFilters() {
|
||||
return $(selectors.filter).tagsinput("items");
|
||||
}
|
||||
|
||||
function reloadBadges(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 === true) {
|
||||
$(tag).parent().addClass("label-info").removeClass("label-danger");
|
||||
} else {
|
||||
$(tag).parent().addClass("label-danger").removeClass("label-info");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addFilter(text) {
|
||||
$(selectors.filter).tagsinput("add", text);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
$(selectors.filter).tagsinput("removeAll");
|
||||
}
|
||||
|
||||
function setUpdating() {
|
||||
// visual hint that alerts are reloaded due to filter change
|
||||
$(selectors.icon).removeClass("fa-search fa-pause").addClass("fa-circle-o-notch fa-spin");
|
||||
}
|
||||
|
||||
function updateDone() {
|
||||
$(selectors.icon).removeClass("fa-circle-o-notch fa-spin fa-pause").addClass("fa-search");
|
||||
}
|
||||
|
||||
function setPause() {
|
||||
$(selectors.icon).removeClass("fa-circle-o-notch fa-spin fa-search").addClass("fa-pause");
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
var historicFilters = [];
|
||||
|
||||
const currentFilterText = getFilters().join(",");
|
||||
|
||||
const history = historyStorage.getItem(historyKey);
|
||||
if (history) {
|
||||
historicFilters = history.split("\n");
|
||||
}
|
||||
|
||||
var historyMenuHTML = templates.renderTemplate("historyMenu", {
|
||||
activeFilter: currentFilterText,
|
||||
defaultFilter: $(selectors.filter).data("default-filter"),
|
||||
savedFilter: Cookies.get("defaultFilter.v2"),
|
||||
filters: historicFilters
|
||||
});
|
||||
$(selectors.historyMenu).html(historyMenuHTML);
|
||||
}
|
||||
|
||||
function appendFilterToHistory(text) {
|
||||
// require non empty text and enabled appends
|
||||
if (!text || !appendsEnabled) return false;
|
||||
|
||||
// final filter list we'll save to storage
|
||||
var filterList = new Set([ text ]);
|
||||
|
||||
// get current history list from storage and append it to our final list
|
||||
// of filters, but avoid duplicates
|
||||
const history = historyStorage.getItem(historyKey);
|
||||
if (history) {
|
||||
const historyArr = history.split("\n");
|
||||
for (var i = 0; i < historyArr.length; i++) {
|
||||
filterList.add(historyArr[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// truncate the history to up to 11 elements
|
||||
const filterListTrunc = Array.from(filterList).slice(0, 10);
|
||||
|
||||
historyStorage.setItem(historyKey, filterListTrunc.join("\n"));
|
||||
}
|
||||
|
||||
function setFilters() {
|
||||
setUpdating();
|
||||
|
||||
// update location so it's easy to share it
|
||||
querystring.update("q", getFilters().join(","));
|
||||
|
||||
// append filter to the history and render it
|
||||
appendFilterToHistory(getFilters().join(","));
|
||||
renderHistory();
|
||||
|
||||
// reload alerts
|
||||
unsee.triggerReload();
|
||||
}
|
||||
|
||||
function applyFilterList(filterList) {
|
||||
// we need to add filters one by one, this would reload alerts on every
|
||||
// add() so let's pause reloads and resume once we're done with updating
|
||||
// filters
|
||||
unsee.pause();
|
||||
// disable history appends as it would record each new filter in the
|
||||
// history
|
||||
appendsEnabled = false;
|
||||
$(selectors.filter).tagsinput("removeAll");
|
||||
for (var i = 0; i < filterList.length; i++) {
|
||||
$(selectors.filter).tagsinput("add", filterList[i]);
|
||||
}
|
||||
// enable everything again
|
||||
appendsEnabled = true;
|
||||
setFilters();
|
||||
unsee.resume();
|
||||
}
|
||||
|
||||
function init(historyStore) {
|
||||
historyStorage = historyStore;
|
||||
var initialFilter;
|
||||
|
||||
if ($(selectors.filter).data("default-used") == "false" || $(selectors.filter).data("default-used") === false) {
|
||||
// user passed ?q=filter string
|
||||
initialFilter = $(selectors.filter).val();
|
||||
} else {
|
||||
// no ?q=filter string, check if we have default filter cookie
|
||||
initialFilter = Cookies.get("defaultFilter.v2");
|
||||
if (initialFilter === undefined) {
|
||||
// no cookie, use global default
|
||||
initialFilter = $(selectors.filter).data("default-filter");
|
||||
}
|
||||
}
|
||||
|
||||
var initialFilterArr = initialFilter.split(",");
|
||||
$(selectors.filter).val("");
|
||||
$(".filterbar :input").tagsinput({
|
||||
typeaheadjs: {
|
||||
minLength: 1,
|
||||
hint: true,
|
||||
limit: 12,
|
||||
name: "autocomplete",
|
||||
source: autocomplete.getAutocomplete()
|
||||
}
|
||||
});
|
||||
$.each(initialFilterArr, function(i, filter) {
|
||||
$(selectors.filter).tagsinput("add", filter);
|
||||
addBadge(filter);
|
||||
});
|
||||
|
||||
$(selectors.filter).on("itemAdded itemRemoved", function(event) {
|
||||
if (appendsEnabled) {
|
||||
// if history appends are disabled then don't set filters yet
|
||||
// we disable appends to have batch filter updates so we shouldn't
|
||||
// set filters yet
|
||||
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) {
|
||||
// ignore backspace & enter
|
||||
if (event.keyCode != 8 && event.keyCode != 13) unsee.pause();
|
||||
},
|
||||
stop: function(event) {
|
||||
// ignore enter
|
||||
if (event.keyCode != 13) unsee.resume();
|
||||
},
|
||||
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());
|
||||
});
|
||||
|
||||
renderHistory();
|
||||
$(selectors.historyMenu).on("click", "a.history-menu-item", function(event) {
|
||||
var elem = $(event.target).parents("li.history-menu");
|
||||
const filtersList = elem.find(".rawFilter").text().trim().split(",");
|
||||
applyFilterList(filtersList);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
exports.init = init;
|
||||
exports.addFilter = addFilter;
|
||||
exports.clearFilters = clearFilters;
|
||||
exports.setFilters = setFilters;
|
||||
exports.getFilters = getFilters;
|
||||
exports.addBadge = addBadge;
|
||||
exports.reloadBadges = reloadBadges;
|
||||
exports.updateDone = updateDone;
|
||||
exports.setUpdating = setUpdating;
|
||||
exports.setPause = setPause;
|
||||
exports.renderHistory = renderHistory;
|
||||
@@ -1,133 +0,0 @@
|
||||
const $ = window.jQuery = require("jquery");
|
||||
const LocalStorageMock = require("./__mocks__/localStorageMock");
|
||||
|
||||
test("filters addBadge()", () => {
|
||||
const filters = require("./filters");
|
||||
filters.addBadge("foo=bar");
|
||||
});
|
||||
|
||||
test("default filter should be in history after setting filter to foo", () => {
|
||||
LocalStorageMock.clear();
|
||||
document.body.innerHTML =
|
||||
"<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' type='text' value='foo' data-default-used='false' data-default-filter='default'>" +
|
||||
"</div>" +
|
||||
"<div id='historyMenu'></div>";
|
||||
|
||||
const templatesMock = require("./__mocks__/templatesMock");
|
||||
document.body.innerHTML += templatesMock.loadTemplates();
|
||||
const templates = require("./templates");
|
||||
templates.init();
|
||||
|
||||
const autocomplete = require("./autocomplete");
|
||||
const filters = require("./filters");
|
||||
|
||||
autocomplete.init();
|
||||
filters.init(LocalStorageMock);
|
||||
filters.setFilters();
|
||||
filters.renderHistory();
|
||||
|
||||
// use snapshot to check that generated HTML is what we expect
|
||||
const historyMenu = $("#historyMenu").html().trim();
|
||||
expect(historyMenu).toMatchSnapshot();
|
||||
|
||||
// we set foo, so that what should be in history
|
||||
expect(LocalStorageMock.getItem("filterHistory")).toBe("foo");
|
||||
});
|
||||
|
||||
test("appended filtes should be present in history", () => {
|
||||
LocalStorageMock.clear();
|
||||
document.body.innerHTML =
|
||||
"<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' type='text' value='default' data-default-used='true' data-default-filter='default'>" +
|
||||
"</div>" +
|
||||
"<div id='historyMenu'></div>";
|
||||
|
||||
const templatesMock = require("./__mocks__/templatesMock");
|
||||
document.body.innerHTML += templatesMock.loadTemplates();
|
||||
const templates = require("./templates");
|
||||
templates.init();
|
||||
|
||||
const autocomplete = require("./autocomplete");
|
||||
const filters = require("./filters");
|
||||
|
||||
autocomplete.init();
|
||||
filters.init(LocalStorageMock);
|
||||
filters.setFilters();
|
||||
filters.renderHistory();
|
||||
|
||||
// we only used default, so there should be a single (default) entry
|
||||
let historyMenu = $("#historyMenu").html().trim();
|
||||
expect(historyMenu).toMatchSnapshot();
|
||||
// and that's what history should have
|
||||
expect(LocalStorageMock.getItem("filterHistory")).toBe("default");
|
||||
|
||||
// now we append more filters, so q=default becomes q=default,bar
|
||||
filters.addFilter("bar");
|
||||
filters.setFilters();
|
||||
// now we got non-default filter as active, so we should have 2 entries
|
||||
// both for default (as recent and as global default)
|
||||
historyMenu = $("#historyMenu").html().trim();
|
||||
expect(historyMenu).toMatchSnapshot();
|
||||
expect(
|
||||
LocalStorageMock.getItem("filterHistory").split("\n")
|
||||
).toMatchObject(
|
||||
[ "default,bar", "default" ]
|
||||
);
|
||||
|
||||
// append another filter, so we now have: q=default,bar,@state=active
|
||||
filters.addFilter("@state=active");
|
||||
filters.setFilters();
|
||||
// now we should have 3 entries, 2x default + default,bar
|
||||
historyMenu = $("#historyMenu").html().trim();
|
||||
expect(historyMenu).toMatchSnapshot();
|
||||
expect(
|
||||
LocalStorageMock.getItem("filterHistory").split("\n")
|
||||
).toMatchObject(
|
||||
[ "default,bar,@state=active", "default,bar", "default" ]
|
||||
);
|
||||
|
||||
// clear filters, so now we have: q=
|
||||
filters.clearFilters();
|
||||
filters.setFilters();
|
||||
// now we should have 4 entries, 2x default + default,bar + default,bar,@state=active
|
||||
historyMenu = $("#historyMenu").html().trim();
|
||||
expect(historyMenu).toMatchSnapshot();
|
||||
expect(
|
||||
LocalStorageMock.getItem("filterHistory").split("\n")
|
||||
).toMatchObject(
|
||||
[ "default,bar,@state=active", "default,bar", "default" ]
|
||||
);
|
||||
|
||||
// now add a filter back, so now we have: q=@state=active
|
||||
filters.addFilter("@state=active");
|
||||
filters.setFilters();
|
||||
// we should have same filters as before
|
||||
historyMenu = $("#historyMenu").html().trim();
|
||||
expect(historyMenu).toMatchSnapshot();
|
||||
expect(
|
||||
LocalStorageMock.getItem("filterHistory").split("\n")
|
||||
).toMatchObject(
|
||||
[ "@state=active", "default,bar,@state=active", "default,bar", "default" ]
|
||||
);
|
||||
|
||||
// as a last test add default back to have @state=active rendered
|
||||
filters.clearFilters();
|
||||
filters.addFilter("default");
|
||||
filters.setFilters();
|
||||
// we should have same filters as before
|
||||
historyMenu = $("#historyMenu").html().trim();
|
||||
expect(historyMenu).toMatchSnapshot();
|
||||
// default should move from the bottom to to top of the list
|
||||
expect(
|
||||
LocalStorageMock.getItem("filterHistory").split("\n")
|
||||
).toMatchObject(
|
||||
[ "default", "@state=active", "default,bar,@state=active", "default,bar" ]
|
||||
);
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const Masonry = require("masonry-layout");
|
||||
const jQueryBridget = require("jquery-bridget");
|
||||
const $ = require("jquery");
|
||||
|
||||
const config = require("./config");
|
||||
|
||||
var selectors = {
|
||||
alerts: "#alerts",
|
||||
incident: ".incident",
|
||||
gridSizer: ".grid-sizer",
|
||||
};
|
||||
|
||||
var grid;
|
||||
|
||||
function init() {
|
||||
jQueryBridget( "masonry", Masonry, $ );
|
||||
grid = $(selectors.alerts).masonry({
|
||||
itemSelector: selectors.incident,
|
||||
columnWidth: selectors.gridSizer,
|
||||
percentPosition: true,
|
||||
transitionDuration: "0.4s",
|
||||
hiddenStyle: {
|
||||
opacity: 0
|
||||
},
|
||||
visibleStyle: {
|
||||
opacity: 1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clear() {
|
||||
grid.masonry("remove", $(selectors.incident));
|
||||
$(selectors.incident).remove();
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
grid.masonry("layout");
|
||||
}
|
||||
|
||||
function remove(elem) {
|
||||
grid.masonry("remove", elem);
|
||||
}
|
||||
|
||||
function append(elem) {
|
||||
if (config.getOption("appendtop").Get()) {
|
||||
grid.prepend(elem).masonry("prepended", elem);
|
||||
} else {
|
||||
grid.append(elem).masonry("appended", elem);
|
||||
}
|
||||
}
|
||||
|
||||
function items() {
|
||||
return grid.masonry("getItemElements");
|
||||
}
|
||||
|
||||
function hide() {
|
||||
$(selectors.alerts).hide();
|
||||
}
|
||||
|
||||
function show() {
|
||||
$(selectors.alerts).show();
|
||||
}
|
||||
|
||||
exports.init = init;
|
||||
exports.clear = clear;
|
||||
exports.redraw = redraw;
|
||||
exports.hide = hide;
|
||||
exports.show = show;
|
||||
exports.append = append;
|
||||
exports.remove = remove;
|
||||
exports.items = items;
|
||||
@@ -1,5 +0,0 @@
|
||||
const grid = require("./grid");
|
||||
|
||||
test("grid hide()", () => {
|
||||
grid.hide();
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
require("bootstrap-loader");
|
||||
require("font-awesome-webpack");
|
||||
|
||||
require("./favicon.ico");
|
||||
require("./base.css");
|
||||
@@ -1,4 +0,0 @@
|
||||
test("help imports", () => {
|
||||
window.jQuery = require("jquery");
|
||||
require("./help");
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
// jQuery-typing
|
||||
//
|
||||
// Version: 0.3.2
|
||||
// Website: http://tnajdek.github.io/jquery-typing/
|
||||
// License: public domain <http://unlicense.org/>
|
||||
// Author: Maciej Konieczny <hello@narf.pl>
|
||||
// Author (Events & data-api): Tom Najdek <tom@doppnet.com>
|
||||
|
||||
(function ($) {
|
||||
|
||||
//--------------------
|
||||
// jQuery extension
|
||||
//--------------------
|
||||
|
||||
$.fn.typing = function (options) {
|
||||
return this.each(function (i, elem) {
|
||||
var $elem = $(elem),
|
||||
api;
|
||||
|
||||
if(!$elem.data('typing')) {
|
||||
api = new Typing(elem, options);
|
||||
$elem.data('typing', api);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
//-------------------
|
||||
// actual function
|
||||
//-------------------
|
||||
|
||||
var Typing = function(elem, options) {
|
||||
// create other function-scope variables
|
||||
var $elem = $(elem),
|
||||
typing = false,
|
||||
delayedCallback,
|
||||
// override default settings
|
||||
settings = $.extend({
|
||||
start: null,
|
||||
stop: null,
|
||||
delay: 400
|
||||
}, options);
|
||||
|
||||
//export settings to the api
|
||||
this.settings = settings;
|
||||
|
||||
|
||||
// start typing
|
||||
function startTyping(event) {
|
||||
if (!typing) {
|
||||
// set flag and run callback
|
||||
typing = true;
|
||||
$elem.trigger('typing:start');
|
||||
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;
|
||||
$elem.trigger('typing:stop');
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
//provide data-api bootstrap style (http://rc.getbootstrap.com/javascript.html)
|
||||
$(document).on('focus.typing.data-api', '[data-provide=typing]', function (e) {
|
||||
var $this = $(this),
|
||||
delay = $this.data('typingDelay');
|
||||
$this.typing( {
|
||||
delay: delay
|
||||
});
|
||||
});
|
||||
|
||||
})(jQuery);
|
||||
@@ -1,57 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const Cookies = require("js-cookie");
|
||||
|
||||
require("bootstrap-switch");
|
||||
require("bootstrap-switch/dist/css/bootstrap3/bootstrap-switch.css");
|
||||
|
||||
const querystring = require("./querystring");
|
||||
|
||||
function Option(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() {};
|
||||
this.Init = params.Init || function() {
|
||||
var elem = this;
|
||||
$(this.Selector).on("switchChange.bootstrapSwitch", function(event, val) {
|
||||
elem.Save(val);
|
||||
elem.Action(val);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
Option.prototype.Load = function() {
|
||||
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(q[this.QueryParam]);
|
||||
val = q[this.QueryParam];
|
||||
}
|
||||
|
||||
if (currentVal != val) {
|
||||
this.Action(val);
|
||||
}
|
||||
};
|
||||
|
||||
Option.prototype.Save = function(val) {
|
||||
Cookies.set(this.Cookie, val, {
|
||||
expires: 365,
|
||||
path: ""
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = Option;
|
||||
@@ -1,12 +0,0 @@
|
||||
const Option = require("./option");
|
||||
|
||||
test("new Option()", () => {
|
||||
var opt = new Option({
|
||||
Cookie: "myCookie",
|
||||
QueryParam: "myQuery",
|
||||
Selector: "#toggle"
|
||||
});
|
||||
expect(opt.Cookie).toBe("myCookie");
|
||||
expect(opt.QueryParam).toBe("myQuery");
|
||||
expect(opt.Selector).toBe("#toggle")
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const NProgress = require("nprogress");
|
||||
require("nprogress/nprogress.css");
|
||||
|
||||
const unsee = require("./unsee");
|
||||
|
||||
var timer;
|
||||
|
||||
function init() {
|
||||
NProgress.configure({
|
||||
minimum: 0.01,
|
||||
showSpinner: false,
|
||||
easing: "linear",
|
||||
template: "<div class='bar nprogress-flatly' role='bar'><div class='peg nprogress-flatly'></div></div>"
|
||||
});
|
||||
}
|
||||
|
||||
function resetTimer() {
|
||||
if (timer !== false) {
|
||||
clearInterval(timer);
|
||||
timer = false;
|
||||
}
|
||||
}
|
||||
|
||||
function complete() {
|
||||
resetTimer();
|
||||
NProgress.done();
|
||||
}
|
||||
|
||||
function pause() {
|
||||
resetTimer();
|
||||
NProgress.set(0.0);
|
||||
}
|
||||
|
||||
function start() {
|
||||
var stepMs = 250; // animation step in ms
|
||||
var steps = (unsee.getRefreshRate() * 1000) / stepMs; // how many steps we have
|
||||
NProgress.set(0.0);
|
||||
resetTimer();
|
||||
timer = setInterval(function() {
|
||||
NProgress.inc(1.0 / steps);
|
||||
}, stepMs);
|
||||
}
|
||||
|
||||
exports.init = init;
|
||||
exports.pause = pause;
|
||||
exports.complete = complete;
|
||||
exports.start = start;
|
||||
exports.resetTimer = resetTimer;
|
||||
@@ -1,17 +0,0 @@
|
||||
const progress = require("./progress");
|
||||
|
||||
test("progress init()", () => {
|
||||
progress.init();
|
||||
});
|
||||
|
||||
test("progress resetTimer()", () => {
|
||||
progress.resetTimer();
|
||||
});
|
||||
|
||||
test("progress complete()", () => {
|
||||
progress.complete();
|
||||
});
|
||||
|
||||
test("progress pause()", () => {
|
||||
progress.pause();
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
|
||||
function parse() {
|
||||
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[hash[0]] = hash.slice(1).join("=");
|
||||
}
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
function update(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) {
|
||||
var 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);
|
||||
}
|
||||
|
||||
function remove(key) {
|
||||
var baseUrl = [ location.protocol, "//", location.host, location.pathname ].join(""),
|
||||
q = parse();
|
||||
if (q[key] !== undefined) {
|
||||
delete q[key];
|
||||
window.history.replaceState({}, "", baseUrl + "?" + $.param(q));
|
||||
}
|
||||
}
|
||||
|
||||
exports.parse = parse;
|
||||
exports.update = update;
|
||||
exports.remove = remove;
|
||||
@@ -1,23 +0,0 @@
|
||||
const querystring = require("./querystring");
|
||||
|
||||
test("querystring parse()", () => {
|
||||
expect(querystring.parse()).toEqual({});
|
||||
Object.defineProperty(document, "URL", {
|
||||
value: "http://example.com?foo=bar",
|
||||
configurable: true,
|
||||
});
|
||||
expect(querystring.parse()).toEqual({"foo": "bar"});
|
||||
});
|
||||
|
||||
test("querystring update()", () => {
|
||||
Object.defineProperty(document, "URL", {
|
||||
value: "http://example.com?foo=bar",
|
||||
configurable: true,
|
||||
});
|
||||
expect(querystring.parse()).toEqual({"foo": "bar"});
|
||||
/*
|
||||
FIXME disabled as it requires mocking browser history
|
||||
querystring.update("foo", "notbar");
|
||||
expect(querystring.parse()).toEqual({"foo": "notbar"});
|
||||
*/
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const Raven = require("raven-js");
|
||||
const $ = require("jquery");
|
||||
|
||||
// init sentry client if sentry dsn is set
|
||||
if ($("body").data("raven-dsn")) {
|
||||
var dsn = $("body").data("raven-dsn");
|
||||
// raven itself can fail if invalid DSN is passed
|
||||
try {
|
||||
Raven.config(dsn, {
|
||||
release: $("body").data("unsee-version")
|
||||
}).install();
|
||||
} catch (error) {
|
||||
var msg = "Sentry error: " + error.message;
|
||||
$("#raven-error").text(msg).removeClass("hidden");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
test("sentry loaded", () => {
|
||||
document.body.setAttribute("data-raven-dsn", "123");
|
||||
document.body.setAttribute("data-unsee-version", "0.1.2");
|
||||
require("./sentry");
|
||||
const Raven = require("raven-js");
|
||||
expect(Raven.lastEventId()).toBeNull();
|
||||
});
|
||||
@@ -1,331 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const moment = require("moment");
|
||||
|
||||
const alerts = require("./alerts");
|
||||
const colors = require("./colors");
|
||||
const templates = require("./templates");
|
||||
const ui = require("./ui");
|
||||
const unsee = require("./unsee");
|
||||
|
||||
function alertmanagerSilencesAPIUrl(prefix) {
|
||||
return prefix + "/api/v1/silences";
|
||||
}
|
||||
|
||||
function silenceFormData() {
|
||||
var values = $("#newSilenceForm").serializeArray();
|
||||
var payload = {
|
||||
matchers: [],
|
||||
startsAt: "",
|
||||
endsAt: "",
|
||||
createdBy: "",
|
||||
comment: ""
|
||||
};
|
||||
$.each(values, function(i, elem){
|
||||
switch (elem.name) {
|
||||
case "comment": case "createdBy":
|
||||
payload[elem.name] = elem.value;
|
||||
break;
|
||||
}
|
||||
});
|
||||
if ($("#startsAt").data("DateTimePicker")) {
|
||||
payload.startsAt = $("#startsAt").data("DateTimePicker").date();
|
||||
}
|
||||
if ($("#endsAt").data("DateTimePicker")) {
|
||||
payload.endsAt = $("#endsAt").data("DateTimePicker").date();
|
||||
}
|
||||
$.each($("#newSilenceForm select.silence-label-picker"), function(i, elem) {
|
||||
var labelKey = $(elem).data("label-key");
|
||||
var values = $(elem).selectpicker("val");
|
||||
if (values && values.length > 0) {
|
||||
var pval;
|
||||
var isRegex = false;
|
||||
if (values.length > 1) {
|
||||
pval = "^(?:" + values.join("|") + ")$";
|
||||
isRegex = true;
|
||||
} else {
|
||||
pval = values[0];
|
||||
}
|
||||
payload.matchers.push({
|
||||
name: labelKey,
|
||||
value: pval,
|
||||
isRegex: isRegex
|
||||
});
|
||||
}
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
function silenceFormCalculateDuration() {
|
||||
// skip if datetimepicker isn't ready yet
|
||||
if (!$("#startsAt").data("DateTimePicker") || !$("#endsAt").data("DateTimePicker")) return false;
|
||||
|
||||
var startsAt = $("#startsAt").data("DateTimePicker").date();
|
||||
var endsAt = $("#endsAt").data("DateTimePicker").date();
|
||||
|
||||
var totalDays = (endsAt.diff(startsAt, "days"));
|
||||
var totalHours = (endsAt.diff(startsAt, "hours")) % 24;
|
||||
var totalMinutes = endsAt.diff(startsAt, "minutes") % 60;
|
||||
$("#silence-duration-days").html(totalDays);
|
||||
$("#silence-duration-hours").html(totalHours);
|
||||
$("#silence-duration-minutes").html(totalMinutes);
|
||||
|
||||
var startsAtDesc = moment().to(startsAt);
|
||||
startsAtDesc = startsAtDesc.replace("in a few seconds", "now");
|
||||
startsAtDesc = startsAtDesc.replace("a few seconds ago", "now");
|
||||
$("#silence-start-description").html(startsAtDesc);
|
||||
|
||||
var endsAtDesc = moment().to(endsAt);
|
||||
endsAtDesc = endsAtDesc.replace("in a few seconds", "now");
|
||||
endsAtDesc = endsAtDesc.replace("a few seconds ago", "now");
|
||||
$("#silence-end-description").html(endsAtDesc);
|
||||
}
|
||||
|
||||
function silenceFormAlertmanagerURL() {
|
||||
return $("#newSilenceForm .silence-alertmanager-picker").selectpicker("val");
|
||||
}
|
||||
|
||||
function silenceFormJSONRender() {
|
||||
var d = [];
|
||||
$.each(silenceFormAlertmanagerURL(), function(i, uri) {
|
||||
if (i > 0) {
|
||||
d.push("\n");
|
||||
}
|
||||
d.push("curl " + alertmanagerSilencesAPIUrl(uri));
|
||||
});
|
||||
d.push("\n -X POST --data ");
|
||||
d.push(JSON.stringify(silenceFormData(), undefined, 2));
|
||||
$("#silenceJSONBlob").html(d.join(""));
|
||||
}
|
||||
|
||||
function silenceFormUpdateDuration(event) {
|
||||
// skip if datetimepicker isn't ready yet
|
||||
if (!$("#startsAt").data("DateTimePicker") || !$("#endsAt").data("DateTimePicker")) return false;
|
||||
|
||||
var startsAt = $("#startsAt").data("DateTimePicker").date();
|
||||
var endsAt = $("#endsAt").data("DateTimePicker").date();
|
||||
var endsAtMinDate = $("#endsAt").data("DateTimePicker").minDate();
|
||||
var action = $(event.target).data("duration-action");
|
||||
var unit = $(event.target).data("duration-unit");
|
||||
var step = parseInt($(event.target).data("duration-step"));
|
||||
|
||||
// re-calculate step for low values
|
||||
// if we have 5 minute step and current duration is 1 minute than clicking
|
||||
// on the increment should give us 5 minute, not 6 minute duration
|
||||
var totalValue = (endsAt.diff(startsAt, unit));
|
||||
switch (unit) {
|
||||
case "hours":
|
||||
totalValue = totalValue % 24;
|
||||
break;
|
||||
case "minutes":
|
||||
totalValue = totalValue % 60;
|
||||
break;
|
||||
}
|
||||
|
||||
if (action == "increment") {
|
||||
// if step is 5 minute and current value is 3 than set 5 minutes, not 8
|
||||
if (step > 1 && totalValue < step) {
|
||||
step = step - totalValue;
|
||||
}
|
||||
endsAt.add(step, unit);
|
||||
} else {
|
||||
// if step is 5 minute and current value is 3 than set 0 minutes
|
||||
if (totalValue > 0 && step > 1 && totalValue < step) {
|
||||
step = totalValue;
|
||||
}
|
||||
endsAt.subtract(step, unit);
|
||||
if (endsAt < endsAtMinDate) {
|
||||
// if decrement would result in a timestamp lower than allowed minimum
|
||||
// then just reset it to the minimum
|
||||
endsAt = endsAtMinDate;
|
||||
}
|
||||
}
|
||||
$("#endsAt").data("DateTimePicker").date(endsAt);
|
||||
silenceFormCalculateDuration();
|
||||
}
|
||||
|
||||
function sendSilencePOST(url, payload) {
|
||||
var elem = $(".silence-post-result[data-uri='" + url + "']");
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: alertmanagerSilencesAPIUrl(url),
|
||||
data: JSON.stringify(payload),
|
||||
error: function(xhr, textStatus) {
|
||||
var err = unsee.parseAJAXError(xhr, textStatus);
|
||||
var errContent = templates.renderTemplate("silenceFormError", {error: err});
|
||||
$(elem).html(errContent);
|
||||
},
|
||||
success: function(data) {
|
||||
if (data.status == "success") {
|
||||
$(elem).html(templates.renderTemplate("silenceFormSuccess", {
|
||||
silenceID: data.data.silenceId
|
||||
}));
|
||||
} else {
|
||||
var err = "Invalid response from Alertmanager API: " + JSON.stringify(data);
|
||||
var errContent = templates.renderTemplate("silenceFormError", {error: err});
|
||||
$(elem).html(errContent);
|
||||
}
|
||||
},
|
||||
dataType: "json"
|
||||
});
|
||||
}
|
||||
|
||||
// modal form for creating new silences
|
||||
function setupSilenceForm() {
|
||||
var modal = $("#silenceModal");
|
||||
modal.on("show.bs.modal", function(event) {
|
||||
var elem = $(event.relatedTarget);
|
||||
// hide tooltip for button that triggers this modal
|
||||
elem.find("[data-toggle]").tooltip("hide");
|
||||
unsee.pause();
|
||||
modal.find(".modal-body").html(
|
||||
templates.renderTemplate("silenceFormLoading", {})
|
||||
);
|
||||
var elemLabels = {};
|
||||
$.each(elem.data("labels").split(","), function(i, l) {
|
||||
elemLabels[l.split("=")[0]] = l.split("=")[1];
|
||||
});
|
||||
$.ajax({
|
||||
url: "alerts.json?q=alertname=" + elem.data("alertname"),
|
||||
error: function(xhr, textStatus) {
|
||||
var err = unsee.parseAJAXError(xhr, textStatus);
|
||||
modal.find(".modal-body").html(
|
||||
templates.renderTemplate("silenceFormFatal", {error: err})
|
||||
);
|
||||
},
|
||||
success: function(data) {
|
||||
// add colors from the response to global color set
|
||||
colors.merge(data.colors);
|
||||
var modal = $("#silenceModal");
|
||||
var labels = {};
|
||||
var alertmanagers = {};
|
||||
$.each(data.groups, function(i, group) {
|
||||
$.each(group.alerts, function(j, alert) {
|
||||
$.each(alert.labels, function(labelKey, labelVal) {
|
||||
if (labels[labelKey] === undefined) {
|
||||
labels[labelKey] = {};
|
||||
}
|
||||
if (labels[labelKey][labelVal] === undefined) {
|
||||
labels[labelKey][labelVal] = {
|
||||
key: labelKey,
|
||||
value: labelVal,
|
||||
attrs: alerts.getLabelAttrs(labelKey, labelVal),
|
||||
selected: elemLabels[labelKey] == labelVal
|
||||
};
|
||||
}
|
||||
});
|
||||
$.each(alert.alertmanager, function(i, alertmanager){
|
||||
alertmanagers[alertmanager.name] = alertmanager;
|
||||
});
|
||||
});
|
||||
});
|
||||
modal.find(".modal-body").html(
|
||||
templates.renderTemplate("silenceForm", {
|
||||
labels: labels,
|
||||
alertmanagers: alertmanagers,
|
||||
selectedAlertmanagers: elem.data("alertmanagers").split(",")
|
||||
})
|
||||
);
|
||||
$.each($(".silence-alertmanager-picker"), function(i, elem) {
|
||||
$(elem).selectpicker();
|
||||
});
|
||||
$.each($(".silence-label-picker"), function(i, elem) {
|
||||
$(elem).selectpicker({
|
||||
noneSelectedText: "<span class='label label-list label-default'>" + $(this).data("label-key") + ": </span>",
|
||||
countSelectedText: function (numSelected) {
|
||||
return "<span class='label label-list label-warning'>" +
|
||||
$(elem).data("label-key") + ": " + numSelected + " values selected</span>";
|
||||
}
|
||||
});
|
||||
});
|
||||
$(".datetime-picker").datetimepicker({
|
||||
format: "YYYY-MM-DD HH:mm",
|
||||
icons: {
|
||||
time: "fa fa-clock-o",
|
||||
date: "fa fa-calendar",
|
||||
up: "fa fa-chevron-up",
|
||||
down: "fa fa-chevron-down",
|
||||
previous: "fa fa-chevron-left",
|
||||
next: "fa fa-chevron-right",
|
||||
today: "fa fa-asterisk",
|
||||
clear: "fa fa-undo",
|
||||
close: "fa fa-close"
|
||||
},
|
||||
minDate: moment(),
|
||||
sideBySide: true,
|
||||
inline: true
|
||||
});
|
||||
ui.setupGroupTooltips($("#newSilenceForm"));
|
||||
$(".select-label-badge").on("click", function() {
|
||||
var select = $(this).parent().parent().find("select");
|
||||
if (select.selectpicker("val").length) {
|
||||
// if there's anything selected deselect all
|
||||
select.selectpicker("deselectAll");
|
||||
} else {
|
||||
// else select all
|
||||
select.selectpicker("selectAll");
|
||||
}
|
||||
});
|
||||
// set endsAt minDate to now + 1 minute
|
||||
$("#endsAt").data("DateTimePicker").minDate(moment().add(1, "minute"));
|
||||
// set endsAt time to +1 hour
|
||||
$("#endsAt").data("DateTimePicker").date(moment().add(1, "hours"));
|
||||
// whenever startsAt changes set it as the minDate for endsAt
|
||||
// we can't have endsAt < startsAt
|
||||
$("#newSilenceForm").on("dp.change", "#startsAt", function(){
|
||||
if (!$("#startsAt").data("DateTimePicker")) return false;
|
||||
var startsAt = $("#startsAt").data("DateTimePicker").date();
|
||||
// endsAt needs to be at least 1 minute after startsAt
|
||||
startsAt.add(1, "minute");
|
||||
$("#endsAt").data("DateTimePicker").minDate(startsAt);
|
||||
});
|
||||
$("#newSilenceForm").on("click", "a.silence-duration-btn", silenceFormUpdateDuration);
|
||||
$("#newSilenceForm").on("show.bs.collapse, dp.change", function () {
|
||||
silenceFormJSONRender();
|
||||
silenceFormCalculateDuration();
|
||||
});
|
||||
$("#newSilenceForm").on("change", function () {
|
||||
silenceFormJSONRender();
|
||||
});
|
||||
$("#newSilenceForm").submit(function(event) {
|
||||
var payload = silenceFormData();
|
||||
if (payload.matchers.length === 0) {
|
||||
var errContent = templates.renderTemplate("silenceFormValidationError", {error: "Select at least on label"});
|
||||
$("#newSilenceAlert").html(errContent).removeClass("hidden");
|
||||
return false;
|
||||
}
|
||||
$("#newSilenceAlert").addClass("hidden");
|
||||
|
||||
var selectedAMURIs = silenceFormAlertmanagerURL();
|
||||
var selectedAMs = [];
|
||||
$.each(alertmanagers, function(i, am) {
|
||||
if ($.inArray(am.uri, selectedAMURIs) >= 0) {
|
||||
selectedAMs.push(am);
|
||||
}
|
||||
});
|
||||
modal.find(".modal-body").html(
|
||||
templates.renderTemplate("silenceFormResults", {alertmanagers: selectedAMs})
|
||||
);
|
||||
|
||||
$.each(selectedAMURIs, function(i, uri){
|
||||
sendSilencePOST(uri, payload);
|
||||
});
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
silenceFormCalculateDuration();
|
||||
silenceFormJSONRender();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
modal.on("hidden.bs.modal", function() {
|
||||
var modal = $(this);
|
||||
modal.find(".modal-body").children().remove();
|
||||
unsee.resume();
|
||||
});
|
||||
}
|
||||
|
||||
exports.setupSilenceForm = setupSilenceForm;
|
||||
exports.sendSilencePOST = sendSilencePOST;
|
||||
@@ -1,173 +0,0 @@
|
||||
const $ = window.jQuery = require("jquery");
|
||||
const moment = require("moment");
|
||||
const templatesMock = require("./__mocks__/templatesMock");
|
||||
const ajaxMock = require("./__mocks__/ajaxMock");
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
test("silence form", () => {
|
||||
let body = templatesMock.loadTemplates();
|
||||
body.push(
|
||||
"<div class='modal' id='silenceModal' role='dialog'>" +
|
||||
" <div class='modal-dialog' role='document'>" +
|
||||
" <div class='modal-content'>" +
|
||||
" <div class='modal-body'></div>" +
|
||||
" </div>" +
|
||||
" </div>" +
|
||||
"</div>" +
|
||||
"<span type='button' id='silenceButton'" +
|
||||
" data-labels='@state=active,foo=bar'" +
|
||||
" data-alertmanagers='mock'" +
|
||||
" data-alertname='fakeAlert'" +
|
||||
" data-toggle='modal'" +
|
||||
" data-target='#silenceModal'>" +
|
||||
"</span>"
|
||||
);
|
||||
document.body.innerHTML = body;
|
||||
|
||||
const templates = require("./templates");
|
||||
templates.init();
|
||||
|
||||
const config = require("./config");
|
||||
config.init({
|
||||
CopySelector: "#copy-settings-with-filter",
|
||||
SaveSelector: "#save-default-filter",
|
||||
ResetSelector: "#reset-settings"
|
||||
});
|
||||
|
||||
const silence = require("./silence");
|
||||
silence.setupSilenceForm();
|
||||
|
||||
require("bootstrap/js/tooltip.js");
|
||||
require("bootstrap/js/modal.js");
|
||||
|
||||
// rendering silence form requires AJAX call to pull data
|
||||
// first check failed request
|
||||
let ajaxServer = ajaxMock.createServer(500, {
|
||||
"status": "error",
|
||||
"errorType": "server_error",
|
||||
"error": "request failed"
|
||||
});
|
||||
ajaxServer.start();
|
||||
// click on the button, modal should show and render via ajax call
|
||||
$("#silenceButton").click();
|
||||
jest.runOnlyPendingTimers();
|
||||
let silenceModal = $("#silenceModal").html().trim();
|
||||
expect(silenceModal).toMatchSnapshot();
|
||||
ajaxServer.stop();
|
||||
|
||||
// hide the form
|
||||
$("#silenceModal").modal("hide");
|
||||
|
||||
// next try successful request
|
||||
ajaxServer = ajaxMock.createServer(200, {
|
||||
"groups": [ {
|
||||
"receiver": "default",
|
||||
"labels": {"alertname": "fakeAlert"},
|
||||
"alerts": [ {
|
||||
"annotations": {},
|
||||
"labels": {
|
||||
"alertname": "fakeAlert",
|
||||
"cluster": "prod",
|
||||
"foo": "bar"
|
||||
},
|
||||
"startsAt": "2017-07-22T01:07:54.32189391Z",
|
||||
"endsAt": "0001-01-01T00:00:00Z",
|
||||
"state": "active",
|
||||
"alertmanager": [ {
|
||||
"name": "mock",
|
||||
"uri": "http://localhost",
|
||||
"state": "active",
|
||||
"startsAt": "2017-07-22T01:07:54.32189391Z",
|
||||
"endsAt": "0001-01-01T00:00:00Z",
|
||||
"source": "localhost/prometheus",
|
||||
"silences": {}
|
||||
} ],
|
||||
"receiver": "default",
|
||||
"links": {}
|
||||
} ],
|
||||
"id": "12345",
|
||||
"hash": "abcdef",
|
||||
"stateCount": {"active": 1, "suppressed": 0, "unprocessed": 0}
|
||||
} ]
|
||||
});
|
||||
ajaxServer.start();
|
||||
// click on the button, modal should show and render via ajax call
|
||||
$("#silenceButton").click();
|
||||
jest.runOnlyPendingTimers();
|
||||
// default times are relative to current time, use fixed values
|
||||
let startsAt = moment("2050-01-01T01:00:00.000Z").utc();
|
||||
let endsAt = moment("2050-01-01T02:00:00.000Z").utc();
|
||||
$("#endsAt").data("DateTimePicker").date(endsAt);
|
||||
$("#startsAt").data("DateTimePicker").date(startsAt);
|
||||
// compare html to a snapshot
|
||||
silenceModal = $("#silenceModal").html().trim();
|
||||
expect(silenceModal).toMatchSnapshot();
|
||||
ajaxServer.stop();
|
||||
|
||||
// submit silence
|
||||
ajaxServer = ajaxMock.createServer(200, {
|
||||
"status": "success",
|
||||
"data": {"silenceId": "abcdef"}
|
||||
});
|
||||
ajaxServer.start();
|
||||
$("#newSilenceForm").submit();
|
||||
ajaxServer.stop();
|
||||
silenceModal = $("#silenceModal").html().trim();
|
||||
expect(silenceModal).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("successful sendSilencePOST()", () => {
|
||||
var body = templatesMock.loadTemplates();
|
||||
body.push(
|
||||
"<span class='silence-post-result' " +
|
||||
" data-uri='http://localhost'>" +
|
||||
"</span>"
|
||||
);
|
||||
document.body.innerHTML = body;
|
||||
|
||||
const templates = require("./templates");
|
||||
templates.init();
|
||||
|
||||
const ajaxServer = ajaxMock.createServer(200, {
|
||||
"status": "success",
|
||||
"data": {"silenceId": "abcdef"}
|
||||
});
|
||||
ajaxServer.start();
|
||||
|
||||
const silence = require("./silence");
|
||||
silence.sendSilencePOST("http://localhost", {});
|
||||
|
||||
let resultElem = $(".silence-post-result").html().trim();
|
||||
expect(resultElem).toMatchSnapshot();
|
||||
|
||||
ajaxServer.stop();
|
||||
});
|
||||
|
||||
test("failed sendSilencePOST()", () => {
|
||||
var body = templatesMock.loadTemplates();
|
||||
body.push(
|
||||
"<span class='silence-post-result' " +
|
||||
" data-uri='http://localhost'>" +
|
||||
"</span>"
|
||||
);
|
||||
document.body.innerHTML = body;
|
||||
|
||||
const templates = require("./templates");
|
||||
templates.init();
|
||||
|
||||
const ajaxServer = ajaxMock.createServer(500, {
|
||||
"status": "error",
|
||||
"errorType": "server_error",
|
||||
"error": "request failed"
|
||||
});
|
||||
ajaxServer.start();
|
||||
|
||||
const silence = require("./silence");
|
||||
silence.sendSilencePOST("http://localhost", {});
|
||||
|
||||
let resultElem = $(".silence-post-result").html().trim();
|
||||
expect(resultElem).toMatchSnapshot();
|
||||
|
||||
ajaxServer.stop();
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
|
||||
const colors = require("./colors");
|
||||
const templates = require("./templates");
|
||||
|
||||
var summary = {};
|
||||
|
||||
function render() {
|
||||
var topTags = [];
|
||||
$.each(summary, function(k, v) {
|
||||
topTags.push({
|
||||
name: k,
|
||||
val: v
|
||||
});
|
||||
});
|
||||
topTags.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(topTags.slice(0, 10), function(i, tag) {
|
||||
var labelKey = tag.name.split(": ")[0];
|
||||
var labelVal = tag.name.split(": ")[1];
|
||||
tag.style = colors.getStyle(labelKey, labelVal);
|
||||
tag.cls = colors.getClass(labelKey, labelVal);
|
||||
tags.push(tag);
|
||||
});
|
||||
|
||||
return templates.renderTemplate("breakdownContent", {tags: tags});
|
||||
}
|
||||
|
||||
function init() {
|
||||
summary = {};
|
||||
$(".navbar-header").popover({
|
||||
trigger: "hover",
|
||||
delay: {
|
||||
"show": 500,
|
||||
"hide": 100
|
||||
},
|
||||
container: "body",
|
||||
html: true,
|
||||
placement: "bottom",
|
||||
title: "Top labels",
|
||||
content: render,
|
||||
template: templates.renderTemplate("breakdown", {})
|
||||
});
|
||||
}
|
||||
|
||||
function update(data) {
|
||||
summary = data;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
summary = {};
|
||||
render();
|
||||
}
|
||||
|
||||
function push(labelKey, labelVal) {
|
||||
var l = labelKey + ": " + labelVal;
|
||||
if (summary[l] === undefined) {
|
||||
summary[l] = 1;
|
||||
} else {
|
||||
summary[l]++;
|
||||
}
|
||||
}
|
||||
|
||||
function getCount(labelKey, labelVal) {
|
||||
var l = labelKey + ": " + labelVal;
|
||||
return summary[l];
|
||||
}
|
||||
|
||||
exports.init = init;
|
||||
exports.update = update;
|
||||
exports.reset = reset;
|
||||
exports.push = push;
|
||||
exports.getCount = getCount;
|
||||
exports.render = render;
|
||||
@@ -1,52 +0,0 @@
|
||||
const $ = require("jquery");
|
||||
|
||||
const summary = require("./summary");
|
||||
const templates = require("./templates");
|
||||
|
||||
test("summary", () => {
|
||||
// mock templates
|
||||
var elems = [];
|
||||
$.each(templates.getConfig(), function(name, selector) {
|
||||
elems.push(
|
||||
"<script type='application/json' id='" + selector.slice(1) + "'>" +
|
||||
"<% _.each(tags, function(tag) { %>" +
|
||||
"name=<%= tag.name %> val=<%= tag.val %> " +
|
||||
"<% }) %>" +
|
||||
"</script>"
|
||||
);
|
||||
});
|
||||
document.body.innerHTML = elems.join("\n");
|
||||
templates.init();
|
||||
|
||||
// load bootstrap, but first set global jQuery object it needs
|
||||
global.jQuery = $;
|
||||
require("bootstrap");
|
||||
|
||||
summary.init();
|
||||
|
||||
// should be empty with no data
|
||||
expect(summary.render()).toBe("");
|
||||
|
||||
// update data and re-test
|
||||
summary.update({"foo": 1});
|
||||
expect(summary.render()).toBe("name=foo val=1 ");
|
||||
|
||||
summary.update({"foo": 1, "bar": 22});
|
||||
expect(summary.render()).toBe("name=bar val=22 name=foo val=1 ");
|
||||
|
||||
// try pushing individual values
|
||||
expect(summary.getCount("xx", "y")).toBeUndefined();
|
||||
summary.push("xx", "y");
|
||||
expect(summary.getCount("xx", "y")).toBe(1);
|
||||
summary.push("xx", "y");
|
||||
expect(summary.getCount("xx", "y")).toBe(2);
|
||||
summary.push("xx", "y");
|
||||
expect(summary.getCount("xx", "y")).toBe(3);
|
||||
|
||||
expect(summary.render()).toBe("name=bar val=22 name=xx: y val=3 name=foo val=1 ");
|
||||
|
||||
// reset values
|
||||
summary.reset();
|
||||
expect(summary.getCount("xx", "y")).toBeUndefined();
|
||||
expect(summary.render()).toBe("");
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const _ = require("underscore");
|
||||
const moment = require("moment");
|
||||
require("javascript-linkify");
|
||||
|
||||
const alerts = require("./alerts");
|
||||
|
||||
var templates = {},
|
||||
config = {
|
||||
// popover with the list of most common labels
|
||||
breakdown: "#breakdown",
|
||||
breakdownContent: "#breakdown-content",
|
||||
|
||||
// reload message if backend version bump is detected
|
||||
reloadNeeded: "#reload-needed",
|
||||
|
||||
// errors
|
||||
fatalError: "#fatal-error",
|
||||
internalError: "#internal-error",
|
||||
updateError: "#update-error",
|
||||
instanceError: "#instance-error",
|
||||
configError: "#configuration-error",
|
||||
|
||||
// modal popup with label filters
|
||||
modalTitle: "#modal-title",
|
||||
modalBody: "#modal-body",
|
||||
|
||||
// modal popup with silence form
|
||||
silenceForm: "#silence-form",
|
||||
silenceFormValidationError: "#silence-form-validation-error",
|
||||
silenceFormResults: "#silence-form-results",
|
||||
silenceFormSuccess: "#silence-form-success",
|
||||
silenceFormError: "#silence-form-error",
|
||||
silenceFormFatal: "#silence-form-fatal",
|
||||
silenceFormLoading: "#silence-form-loading",
|
||||
|
||||
// alert partials
|
||||
buttonLabel: "#label-button-filter",
|
||||
alertAnnotation: "#alert-annotation",
|
||||
|
||||
// alert group
|
||||
alertGroup: "#alert-group",
|
||||
alertGroupTitle: "#alert-group-title",
|
||||
alertGroupAnnotations: "#alert-group-annotations",
|
||||
alertGroupLabels: "#alert-group-labels",
|
||||
alertGroupElements: "#alert-group-elements",
|
||||
alertGroupSilence: "#alert-group-silence",
|
||||
alertGroupLabelMap: "#alert-group-label-map",
|
||||
|
||||
// history dropdown
|
||||
historyMenu: "#history-menu",
|
||||
historyMenuItem: "#history-menu-item"
|
||||
};
|
||||
|
||||
function getConfig() {
|
||||
return config;
|
||||
}
|
||||
|
||||
function loadTemplate(name, selector) {
|
||||
try {
|
||||
templates[name] = _.template($(selector).html());
|
||||
} catch (err) {
|
||||
console.error("Failed to parse template " + name + " " + selector);
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
$.each(config, function(name, selector) {
|
||||
loadTemplate(name, selector);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTemplate(name, context) {
|
||||
context["moment"] = moment;
|
||||
context["linkify"] = window.linkify;
|
||||
context["renderTemplate"] = renderTemplate;
|
||||
context["sortMapByKey"] = alerts.sortMapByKey;
|
||||
context["getLabelAttrs"] = alerts.getLabelAttrs;
|
||||
var t = templates[name];
|
||||
if (t === undefined) {
|
||||
console.error("Unknown template " + name);
|
||||
return "<div class='jumbotron'><h1>Internal error: unknown template " + name + "</h1></div>";
|
||||
}
|
||||
try {
|
||||
return t(context);
|
||||
} catch (err) {
|
||||
return "<div class='jumbotron'>Failed to render template " + name + "<h1><p>" + err + "</p></h1></div>";
|
||||
}
|
||||
}
|
||||
|
||||
exports.init = init;
|
||||
exports.getConfig = getConfig;
|
||||
exports.loadTemplate = loadTemplate;
|
||||
exports.renderTemplate = renderTemplate;
|
||||
@@ -1,30 +0,0 @@
|
||||
const templates = require("./templates");
|
||||
const templatesMock = require("./__mocks__/templatesMock");
|
||||
require("javascript-linkify");
|
||||
|
||||
test("templates init()", () => {
|
||||
document.body.innerHTML = templatesMock.loadTemplates();
|
||||
templates.init();
|
||||
});
|
||||
|
||||
test("linkify simple link", () => {
|
||||
expect(window.linkify("http://localhost")).toMatchSnapshot();
|
||||
expect(window.linkify("http://localhost:8080/abc")).toMatchSnapshot();
|
||||
expect(window.linkify("http://localhost:8080/abc#foo")).toMatchSnapshot();
|
||||
expect(window.linkify("http://localhost:8080/abc?foo")).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("linkify kibana link", () => {
|
||||
let longLink =
|
||||
"https://kibana/app/kibana#/dashboard/dashboard_name?_g=" +
|
||||
"(time:(from:now-1h,mode:quick,to:now))&_a=(filters:!((query:" +
|
||||
"(match:(host:(query:hostname,type:phrase))),meta:" +
|
||||
"(alias:!n,disabled:!f,index:'logstash-*',key:host,negate:!f," +
|
||||
"value:hostname)),(meta:(alias:!n,disabled:!f,index:'logstash-*'" +
|
||||
",key:program,negate:!f,value:puppet-agent),query:(match:(program:" +
|
||||
"(query:puppet-agent,type:phrase)))),(meta:(alias:!n,disabled:" +
|
||||
"!f,index:'logstash-*',key:level,negate:!f,value:ERROR),query:" +
|
||||
"(match:(level:(query:ERROR,type:phrase))))))";
|
||||
expect(window.linkify(longLink)).toMatchSnapshot();
|
||||
expect(window.linkify("foo " + longLink + " bar")).toMatchSnapshot();
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
|
||||
const alerts = require("./alerts");
|
||||
const autocomplete = require("./autocomplete");
|
||||
const filters = require("./filters");
|
||||
const grid = require("./grid");
|
||||
const summary = require("./summary");
|
||||
const templates = require("./templates");
|
||||
const unsee = require("./unsee");
|
||||
|
||||
// when user click on any alert label modal popup with a list of possible
|
||||
// filter will show, this function is used to setup that modal
|
||||
function setupModal() {
|
||||
$("#labelModal").on("show.bs.modal", function(event) {
|
||||
unsee.pause();
|
||||
var modal = $(this);
|
||||
var label = $(event.relatedTarget);
|
||||
var labelKey = label.data("label-key");
|
||||
var labelVal = label.data("label-val");
|
||||
var attrs = alerts.getLabelAttrs(labelKey, labelVal);
|
||||
var counter = summary.getCount(labelKey, labelVal);
|
||||
modal.find(".modal-title").html(
|
||||
templates.renderTemplate("modalTitle", {
|
||||
attrs: attrs,
|
||||
counter: counter
|
||||
})
|
||||
);
|
||||
var hints = autocomplete.generateHints(labelKey, labelVal);
|
||||
modal.find(".modal-body").html(
|
||||
templates.renderTemplate("modalBody", {hints: hints})
|
||||
);
|
||||
$(".modal-table").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() {
|
||||
var modal = $(this);
|
||||
modal.find(".modal-title").children().remove();
|
||||
modal.find(".modal-body").children().remove();
|
||||
unsee.resume();
|
||||
});
|
||||
}
|
||||
|
||||
// each alert group have a link generated for it, but we hide it until
|
||||
// user hovers over that group so it doesn"t trash the UI
|
||||
function setupGroupLinkHover(elem) {
|
||||
$(elem).on("mouseenter", function() {
|
||||
$(this).find(".alert-group-link > a").finish().animate({
|
||||
opacity: 100
|
||||
}, 200);
|
||||
});
|
||||
$(elem).on("mouseleave", function() {
|
||||
$(this).find(".alert-group-link > a").finish().animate({
|
||||
opacity: 0
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
// find all elements inside alert group panel that will use tooltips
|
||||
// and setup those
|
||||
function setupGroupTooltips(groupElem) {
|
||||
$.each(groupElem.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"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupGroupAnnotationToggles(groupElem) {
|
||||
$(groupElem).on("click", "[data-toggle=toggle-hidden-annotation]", function() {
|
||||
var alert = $(this).parent();
|
||||
var icon = $(this).find("i.fa");
|
||||
var showingHidden = icon.hasClass("fa-search-minus");
|
||||
if (showingHidden) {
|
||||
// we're currently showing hidden annotations, so the action is to hide them
|
||||
icon.removeClass("fa-search-minus").addClass("fa-search-plus");
|
||||
$.each(alert.find(".hidden-annotation"), function(i, annotation){
|
||||
$(annotation).addClass("hidden");
|
||||
});
|
||||
} else {
|
||||
// we're currently hiding hidden annotations, so the action is to show them
|
||||
icon.removeClass("fa-search-plus").addClass("fa-search-minus");
|
||||
$.each(alert.find(".hidden-annotation"), function(i, annotation){
|
||||
$(annotation).removeClass("hidden");
|
||||
});
|
||||
}
|
||||
grid.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
exports.setupModal = setupModal;
|
||||
exports.setupGroupTooltips = setupGroupTooltips;
|
||||
exports.setupGroupLinkHover = setupGroupLinkHover;
|
||||
exports.setupGroupAnnotationToggles = setupGroupAnnotationToggles;
|
||||
@@ -1,101 +0,0 @@
|
||||
const $ = window.$ = window.jQuery = require("jquery");
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
test("ui setupModal()", () => {
|
||||
document.body.innerHTML =
|
||||
"<script type='application/json' id='modal-title'>title</script>" +
|
||||
"<script type='application/json' id='modal-body'><%- hints %></script>" +
|
||||
"<div class='modal' 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 id='label' type='button'" +
|
||||
" data-label-type='filter'" +
|
||||
" data-label-key='foo'" +
|
||||
" data-label-val='bar'" +
|
||||
" data-toggle='modal'" +
|
||||
" data-target='#labelModal'>" +
|
||||
"</div>";
|
||||
// mock modal templates
|
||||
const templates = require("./templates");
|
||||
templates.loadTemplate("modalTitle", "#modal-title");
|
||||
templates.loadTemplate("modalBody", "#modal-body");
|
||||
|
||||
require("bootstrap/js/modal.js");
|
||||
const ui = require("./ui");
|
||||
ui.setupModal();
|
||||
// modal shouldn't be visible (no in class)
|
||||
expect($("#labelModal").hasClass("in")).toBe(false);
|
||||
|
||||
// trigger modal show
|
||||
$("#label").click();
|
||||
jest.runAllTimers();
|
||||
// modal should be visible (with in class)
|
||||
expect($("#labelModal").hasClass("in")).toBe(true);
|
||||
// we should have hints in the body
|
||||
expect($(".modal-body").text()).toBe("foo=bar,foo!=bar");
|
||||
});
|
||||
|
||||
test("ui setupGroupLinkHover()", () => {
|
||||
document.body.innerHTML =
|
||||
"<div id='links'>" +
|
||||
" <span class='alert-group-link'>" +
|
||||
" <a href='#'>link</a>" +
|
||||
" </span>" +
|
||||
"</div>";
|
||||
const ui = require("./ui");
|
||||
ui.setupGroupLinkHover($("#links"));
|
||||
|
||||
// trigger hover, link should be visible
|
||||
$("#links").trigger("mouseenter");
|
||||
jest.runAllTimers();
|
||||
expect($("a").attr("style")).toBe("opacity: 100;");
|
||||
|
||||
// disable hover, , link should be invisible (fully transparent)
|
||||
$("#links").trigger("mouseleave");
|
||||
jest.runAllTimers();
|
||||
expect($("a").attr("style")).toBe("opacity: 0;");
|
||||
});
|
||||
|
||||
test("ui setupGroupTooltips()", () => {
|
||||
document.body.innerHTML =
|
||||
"<div id='groupTest'>" +
|
||||
" <div id='foo' title='foo' data-toggle='tooltip'>foo</div>" +
|
||||
" <div id='bar' data-ts-title='bar' data-toggle='tooltip'>bar</div>" +
|
||||
"</div>";
|
||||
require("bootstrap/js/tooltip.js");
|
||||
const ui = require("./ui");
|
||||
ui.setupGroupTooltips($("#groupTest"));
|
||||
|
||||
// check if bootstrap tooltip was applied, it will empty tooltip attr if set
|
||||
// and save it under data-original-title
|
||||
expect($("#foo").attr("title")).toBe("");
|
||||
expect($("#foo").data("original-title")).toBe("foo");
|
||||
expect($("#bar").attr("title")).toBe("");
|
||||
expect($("#bar").data("original-title")).toBe("");
|
||||
|
||||
// trigger hover events for foo
|
||||
$("#foo").trigger("mouseenter");
|
||||
// fast forward all timers since there's a delay for tooltip show
|
||||
jest.runAllTimers();
|
||||
// check if tooltip was added to the DOM with the right text
|
||||
expect($(".tooltip-inner").text()).toBe("foo");
|
||||
|
||||
// hide foo tooltip and check if it's gone
|
||||
$("#foo").trigger("mouseleave");
|
||||
jest.runAllTimers();
|
||||
expect($(".tooltip-inner").length).toBe(0);
|
||||
|
||||
// repeat for bar
|
||||
$("#bar").trigger("mouseenter");
|
||||
// fast forward all timers since there's a delay for tooltip show
|
||||
jest.runAllTimers();
|
||||
// check if tooltip was added to the DOM with the right text
|
||||
expect($(".tooltip-inner").text()).toBe("bar");
|
||||
});
|
||||
@@ -1,398 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = window.$ = window.jQuery = require("jquery");
|
||||
const Clipboard = require("clipboard");
|
||||
const moment = require("moment");
|
||||
const Raven = require("raven-js");
|
||||
|
||||
require("bootstrap-loader");
|
||||
require("font-awesome-webpack");
|
||||
|
||||
require("bootstrap-select");
|
||||
require("bootstrap-select/dist/css/bootstrap-select.css");
|
||||
|
||||
require("loaders.css/loaders.css");
|
||||
require("loaders.css/loaders.css.js");
|
||||
|
||||
require("eonasdan-bootstrap-datetimepicker/src/js/bootstrap-datetimepicker.js");
|
||||
require("eonasdan-bootstrap-datetimepicker/src/less/bootstrap-datetimepicker-build.less");
|
||||
|
||||
require("./favicon.ico");
|
||||
require("./base.css");
|
||||
|
||||
const alerts = require("./alerts");
|
||||
const autocomplete = require("./autocomplete");
|
||||
const colors = require("./colors");
|
||||
const config = require("./config");
|
||||
const counter = require("./counter");
|
||||
const grid = require("./grid");
|
||||
const filters = require("./filters");
|
||||
const progress = require("./progress");
|
||||
const silence = require("./silence");
|
||||
const summary = require("./summary");
|
||||
const templates = require("./templates");
|
||||
const ui = require("./ui");
|
||||
const unsilence = require("./unsilence");
|
||||
const watchdog = require("./watchdog");
|
||||
|
||||
var timer = false;
|
||||
var version = false;
|
||||
var refreshInterval = 15;
|
||||
var hiddenAt = false;
|
||||
|
||||
var selectors = {
|
||||
refreshButton: "#refresh",
|
||||
errors: "#errors",
|
||||
instanceErrors: "#instance-errors",
|
||||
clickToCopy: ".click-to-copy"
|
||||
};
|
||||
|
||||
function parseAJAXError(xhr, textStatus) {
|
||||
// default to textStatus, it's usually just "error" string
|
||||
var err = textStatus;
|
||||
if (xhr.readyState === 0) {
|
||||
// ajax() completed but request wasn't send
|
||||
err = "Connection to the remote endpoint failed";
|
||||
} else if (xhr.responseJSON && xhr.responseJSON.error) {
|
||||
// there's response JSON and an error key in it
|
||||
err = xhr.responseJSON.error;
|
||||
} else if (xhr.responseText) {
|
||||
// else check response as a string
|
||||
err = xhr.responseText;
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
function getRefreshRate() {
|
||||
return refreshInterval;
|
||||
}
|
||||
|
||||
function setRefreshRate(seconds) {
|
||||
var rate = parseInt(seconds);
|
||||
if (isNaN(rate) || rate === null) {
|
||||
// if passed rate is incorrect use select value
|
||||
rate = config.getOption("refresh").Get();
|
||||
if (isNaN(rate) || rate === null) {
|
||||
// if that's also borked use default 15
|
||||
rate = 15;
|
||||
}
|
||||
}
|
||||
// don't allow setting refresh rate lower than 1s
|
||||
if (rate < 1) {
|
||||
rate = 1;
|
||||
}
|
||||
refreshInterval = rate;
|
||||
progress.resetTimer();
|
||||
}
|
||||
|
||||
function needsUpgrade(responseVersion) {
|
||||
if (version === false) {
|
||||
version = responseVersion;
|
||||
return false;
|
||||
}
|
||||
return version != responseVersion;
|
||||
}
|
||||
|
||||
function updateIsReady() {
|
||||
progress.complete();
|
||||
$(selectors.refreshButton).prop("disabled", true);
|
||||
counter.hide();
|
||||
}
|
||||
|
||||
function updateCompleted() {
|
||||
counter.show();
|
||||
filters.updateDone();
|
||||
progress.complete();
|
||||
$(selectors.refreshButton).prop("disabled", false);
|
||||
// hack for fixing padding since input can grow and change height
|
||||
$("body").css("padding-top", $(".navbar").height());
|
||||
}
|
||||
|
||||
function renderError(template, context) {
|
||||
counter.markError();
|
||||
grid.clear();
|
||||
grid.hide();
|
||||
$(selectors.errors).html(templates.renderTemplate(template, context));
|
||||
$(selectors.errors).show();
|
||||
counter.markUnknown();
|
||||
summary.update({});
|
||||
document.title = "(◕ O ◕)";
|
||||
updateCompleted();
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (config.getOption("autorefresh").Get()) {
|
||||
filters.updateDone();
|
||||
} else {
|
||||
filters.setPause();
|
||||
return false;
|
||||
}
|
||||
progress.start();
|
||||
if (timer !== false) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
/* eslint-disable no-use-before-define */
|
||||
// FIXME circular dependency resume -> triggerReload -> resume -> ...
|
||||
timer = setTimeout(triggerReload, getRefreshRate() * 1000);
|
||||
}
|
||||
|
||||
function handleError(err) {
|
||||
Raven.captureException(err);
|
||||
if (window.console) {
|
||||
console.error(err.stack);
|
||||
}
|
||||
renderError("internalError", {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
raw: err
|
||||
});
|
||||
setTimeout(function() {
|
||||
resume();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function upgrade() {
|
||||
renderError("reloadNeeded", {});
|
||||
setTimeout(function() {
|
||||
location.reload();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function triggerReload() {
|
||||
updateIsReady();
|
||||
$.ajax({
|
||||
url: "alerts.json?q=" + filters.getFilters().join(","),
|
||||
success: function(resp) {
|
||||
counter.markSuccess();
|
||||
if (needsUpgrade(resp.version)) {
|
||||
upgrade();
|
||||
} else {
|
||||
if (resp.upstreams.counters.total === 0) {
|
||||
// no upstream to use fail hard
|
||||
counter.markUnknown();
|
||||
$(selectors.instanceErrors).html("");
|
||||
renderError("updateError", {
|
||||
error: "Fatal error",
|
||||
messages: [ "No working Alertmanager server found" ],
|
||||
lastTs: watchdog.getLastUpdate()
|
||||
});
|
||||
resume();
|
||||
} else if (resp.upstreams.counters.healthy > 0 ) {
|
||||
// we have some healthy upstreams, check for failed ones
|
||||
if (resp.upstreams.counters.failed > 0) {
|
||||
var instances = [];
|
||||
resp.upstreams.instances.sort(function(a, b){
|
||||
if(a.name < b.name) return -1;
|
||||
if(a.name > b.name) return 1;
|
||||
return 0;
|
||||
});
|
||||
$.each(resp.upstreams.instances, function(i, instance){
|
||||
if (instance.error !== "") {
|
||||
instances.push(instance);
|
||||
}
|
||||
});
|
||||
$(selectors.instanceErrors).html(
|
||||
templates.renderTemplate("instanceError", {
|
||||
instances: instances
|
||||
})
|
||||
);
|
||||
} else {
|
||||
$(selectors.instanceErrors).html("");
|
||||
}
|
||||
// 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.updateAlerts(resp);
|
||||
updateCompleted();
|
||||
watchdog.pong(moment(resp.timestamp));
|
||||
resume();
|
||||
if (!watchdog.isFatal()) {
|
||||
$(selectors.errors).html("");
|
||||
$(selectors.errors).hide("");
|
||||
}
|
||||
} catch (err) {
|
||||
counter.markUnknown();
|
||||
handleError(err);
|
||||
resume();
|
||||
}
|
||||
}, 50);
|
||||
} else {
|
||||
// we have upstreams but none is working, fail hard
|
||||
counter.markUnknown();
|
||||
$(selectors.instanceErrors).html("");
|
||||
var failedInstances = [];
|
||||
$.each(resp.upstreams.instances, function(i, instance) {
|
||||
if (instance.error !== "") {
|
||||
failedInstances.push(instance);
|
||||
}
|
||||
});
|
||||
renderError("configError", {
|
||||
instances: failedInstances
|
||||
});
|
||||
resume();
|
||||
}
|
||||
}
|
||||
},
|
||||
error: function(xhr, textStatus) {
|
||||
counter.markUnknown();
|
||||
$(selectors.instanceErrors).html("");
|
||||
// if fatal error was already triggered we have error message
|
||||
// so don't add new one
|
||||
if (!watchdog.isFatal()) {
|
||||
var err = parseAJAXError(xhr, textStatus);
|
||||
renderError("updateError", {
|
||||
error: "Backend error",
|
||||
messages: [ err ],
|
||||
lastTs: watchdog.getLastUpdate()
|
||||
});
|
||||
}
|
||||
resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function pause() {
|
||||
progress.pause();
|
||||
filters.setPause();
|
||||
if (timer !== false) {
|
||||
clearInterval(timer);
|
||||
timer = false;
|
||||
}
|
||||
}
|
||||
|
||||
function flash() {
|
||||
var bg = $("#flash").css("background-color");
|
||||
$("#flash").css("display", "block").animate({
|
||||
backgroundColor: "#fff"
|
||||
}, 300, function() {
|
||||
$(this).animate({
|
||||
backgroundColor: bg
|
||||
}, 100).css("display", "none");
|
||||
});
|
||||
}
|
||||
|
||||
// when user switches to a different tab but keeps unsee tab open in the background
|
||||
// some browsers (like Chrome) will try to apply some forms of throttling for the JS
|
||||
// code, to ensure that there are no visual artifacts (like state alerts not removed from the page)
|
||||
// redraw all alerts if we detect that the user switches from a different tab to unsee
|
||||
function setupPageVisibilityHandler() {
|
||||
// based on https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
|
||||
if (typeof document.hidden !== "undefined" && typeof document.addEventListener !== "undefined") {
|
||||
document.addEventListener("visibilitychange", function() {
|
||||
if (document.hidden) {
|
||||
// when tab is hidden set a timestamp of that event
|
||||
hiddenAt = moment().utc().unix();
|
||||
} else {
|
||||
// when user switches back check if we have a timestamp
|
||||
// and if autorefresh is enable
|
||||
if (hiddenAt && config.getOption("autorefresh").Get()) {
|
||||
// get the diff to see how long tab was hidden
|
||||
var diff = moment().utc().unix() - hiddenAt;
|
||||
if (diff > refreshInterval) {
|
||||
// if it was hidden for more than one refresh cycle
|
||||
// then manually refresh alerts to ensure everything
|
||||
// is up to date
|
||||
triggerReload();
|
||||
}
|
||||
}
|
||||
hiddenAt = false;
|
||||
}
|
||||
}, false);
|
||||
}
|
||||
}
|
||||
|
||||
function init(localStore) {
|
||||
progress.init();
|
||||
|
||||
config.init({
|
||||
CopySelector: "#copy-settings-with-filter",
|
||||
SaveSelector: "#save-default-filter",
|
||||
ResetSelector: "#reset-settings"
|
||||
});
|
||||
config.loadFromCookies();
|
||||
|
||||
counter.init();
|
||||
summary.init();
|
||||
grid.init();
|
||||
autocomplete.init();
|
||||
filters.init(localStore);
|
||||
watchdog.init(30, 60*15); // set watchdog to 15 minutes
|
||||
|
||||
$(selectors.refreshButton).click(function() {
|
||||
if (!$(selectors.refreshButton).prop("disabled")) {
|
||||
triggerReload();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
setupPageVisibilityHandler();
|
||||
}
|
||||
|
||||
function onReady(localStore) {
|
||||
// wrap all inits so we can handle errors
|
||||
try {
|
||||
// init all elements using bootstrapSwitch
|
||||
$(".toggle").bootstrapSwitch();
|
||||
|
||||
// enable tooltips, #settings is a dropdown so it already uses different data-toggle
|
||||
$("[data-toggle='tooltip'], #settings, #history").tooltip({
|
||||
trigger: "hover"
|
||||
});
|
||||
|
||||
var clipboard = new Clipboard(selectors.clickToCopy);
|
||||
clipboard.on("success", function(e) {
|
||||
// flash element after copy
|
||||
$(e.trigger).finish().fadeOut(100).fadeIn(300);
|
||||
// hide tooltip after flash
|
||||
$(e.trigger).tooltip("hide");
|
||||
// reset focus
|
||||
e.clearSelection();
|
||||
});
|
||||
|
||||
colors.init($("#alerts").data("static-color-labels").split(" "));
|
||||
templates.init();
|
||||
ui.setupModal();
|
||||
silence.setupSilenceForm();
|
||||
unsilence.init();
|
||||
init(localStore);
|
||||
|
||||
// delay initial alert load to allow browser finish rendering
|
||||
setTimeout(function() {
|
||||
filters.setFilters();
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
Raven.captureException(error);
|
||||
if (window.console) {
|
||||
console.error("Error: " + error.stack);
|
||||
}
|
||||
// templates might not be loaded yet, make some html manually
|
||||
$("#errors").html(
|
||||
"<div class='jumbotron'>" +
|
||||
"<h1 class='text-center'>" +
|
||||
"Internal error <i class='fa fa-exclamation-circle text-danger'/>" +
|
||||
"</h1>" +
|
||||
"<div class='text-center'><p>" +
|
||||
error.message +
|
||||
"</p></div></div>"
|
||||
).show();
|
||||
}
|
||||
}
|
||||
|
||||
exports.init = init;
|
||||
exports.pause = pause;
|
||||
exports.resume = resume;
|
||||
exports.triggerReload = triggerReload;
|
||||
exports.getRefreshRate = getRefreshRate;
|
||||
exports.setRefreshRate = setRefreshRate;
|
||||
exports.flash = flash;
|
||||
exports.parseAJAXError = parseAJAXError;
|
||||
exports.onReady = onReady;
|
||||
|
||||
$(document).ready(function() {
|
||||
onReady(window.localStorage);
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
const $ = require("jquery");
|
||||
const templatesMock = require("./__mocks__/templatesMock");
|
||||
const alertsMock = require("./__mocks__/alertsMock");
|
||||
const LocalStorageMock = require("./__mocks__/localStorageMock");
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
test("unsee getRefreshRate()", () => {
|
||||
window.jQuery = require("jquery");
|
||||
const unsee = require("./unsee");
|
||||
unsee.getRefreshRate();
|
||||
});
|
||||
|
||||
// this runs only if we have test alerts.json response which is generated
|
||||
// when running view_test.go
|
||||
const alertsServer = alertsMock.mockAlertsJSON();
|
||||
if (alertsServer) {
|
||||
test("unsee main entrypoint", () => {
|
||||
// load template files and setup required elements
|
||||
var body = templatesMock.loadTemplates();
|
||||
// counter in the navbar
|
||||
body.push(
|
||||
"<div class='navbar-header'>" +
|
||||
" <a class='navbar-brand text-center'>" +
|
||||
" <strong id='alert-count'>0</strong>" +
|
||||
" <div id='spinner'>" +
|
||||
" </div>" +
|
||||
" </a>" +
|
||||
"</div>"
|
||||
);
|
||||
// filter input
|
||||
body.push("<input id='filter' value='' data-default-used='' data-default-filter=''>");
|
||||
// alerts placeholder
|
||||
body.push(
|
||||
"<div id='alerts' data-static-color-labels=''>" +
|
||||
" <div class='grid-sizer'></div>" +
|
||||
"</div>"
|
||||
);
|
||||
// error placeholders
|
||||
body.push(
|
||||
"<div id='raven-error'></div>" +
|
||||
"<div id='instance-errors'></div>" +
|
||||
"<div id='errors'></div>"
|
||||
);
|
||||
document.body.innerHTML = body.join("\n");
|
||||
|
||||
const unsee = require("./unsee");
|
||||
const grid = require("./grid");
|
||||
require("bootstrap/js/tooltip.js");
|
||||
require("bootstrap/js/modal.js");
|
||||
require("bootstrap/js/popover.js");
|
||||
alertsServer.start();
|
||||
|
||||
unsee.onReady(LocalStorageMock);
|
||||
unsee.triggerReload();
|
||||
jest.runOnlyPendingTimers();
|
||||
// we should have 2 alerts
|
||||
expect(grid.items().length).toBe(2);
|
||||
expect($("#alert-count").text()).toBe("2");
|
||||
|
||||
unsee.triggerReload();
|
||||
jest.runOnlyPendingTimers();
|
||||
// we should still have 2 alerts
|
||||
expect(grid.items().length).toBe(2);
|
||||
expect($("#alert-count").text()).toBe("2");
|
||||
|
||||
// clear grid and update again
|
||||
grid.clear();
|
||||
unsee.triggerReload();
|
||||
jest.runOnlyPendingTimers();
|
||||
// we should still have 2 alerts
|
||||
expect(grid.items().length).toBe(2);
|
||||
expect($("#alert-count").text()).toBe("2");
|
||||
|
||||
alertsServer.stop();
|
||||
});
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
|
||||
const unsee = require("./unsee");
|
||||
|
||||
var selectors = {
|
||||
button: "button.silence-delete"
|
||||
};
|
||||
|
||||
function unsilenceButtonByID(alertmanagerURI, silenceID) {
|
||||
var amSelector = "[data-alertmanager-uri='" + alertmanagerURI + "']";
|
||||
var silenceSelector = "[data-silence-id='" + silenceID + "']";
|
||||
return $(selectors.button + amSelector + silenceSelector);
|
||||
}
|
||||
|
||||
function markInProgress(alertmanagerURI, silenceID) {
|
||||
var elem = unsilenceButtonByID(alertmanagerURI, silenceID);
|
||||
elem.attr("title", "Silence is being deleted from Alertmanager");
|
||||
elem.tooltip("fixTitle");
|
||||
elem.find(".fa").removeClass("fa-trash-o").addClass("fa-refresh fa-spin");
|
||||
}
|
||||
|
||||
function markFailed(alertmanagerURI, silenceID, xhr) {
|
||||
var err = unsee.parseAJAXError(xhr, "Failed to delete this silence from Alertmanager");
|
||||
var elem = unsilenceButtonByID(alertmanagerURI, silenceID);
|
||||
elem.attr("title", err);
|
||||
elem.tooltip("fixTitle");
|
||||
elem.find(".fa").removeClass("fa-trash-o fa-refresh fa-spin").addClass("fa-exclamation-circle text-danger");
|
||||
|
||||
// Disable button, wait 5s and reset button to the original state
|
||||
elem.data("disabled", "true");
|
||||
setTimeout(function() {
|
||||
elem.find(".fa").removeClass("fa-exclamation-circle text-danger").addClass("fa-trash-o");
|
||||
elem.removeData("disabled");
|
||||
elem.attr("title", "Delete this silence");
|
||||
elem.tooltip("fixTitle");
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function markSuccess(alertmanagerURI, silenceID) {
|
||||
var elem = unsilenceButtonByID(alertmanagerURI, silenceID);
|
||||
elem.attr("title", "Silence deleted from Alertmanager");
|
||||
elem.tooltip("fixTitle");
|
||||
elem.find(".fa").removeClass("fa-trash-o fa-refresh fa-spin").addClass("fa-check-circle text-success");
|
||||
// disable button so it's no longer clickable
|
||||
elem.data("disabled", "true");
|
||||
}
|
||||
|
||||
function deleteSilence(alertmanagerURI, silenceID) {
|
||||
$.ajax({
|
||||
type: "DELETE",
|
||||
url: alertmanagerURI + "/api/v1/silence/" + silenceID,
|
||||
error: function(xhr) {
|
||||
markFailed(alertmanagerURI, silenceID, xhr);
|
||||
},
|
||||
success: function() {
|
||||
markSuccess(alertmanagerURI, silenceID);
|
||||
},
|
||||
dataType: "json"
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
$("body").on("click", selectors.button, function(event) {
|
||||
var elem = $(event.currentTarget);
|
||||
|
||||
if (elem.data("disabled")) {
|
||||
// if we marked button as disabled then skip all actions
|
||||
// we use data attr to keep tooplips working on disabled buttons
|
||||
// setting attr(disabled) via jquery disables bootstrap tooltips
|
||||
return false;
|
||||
}
|
||||
|
||||
// hide tooltip for button that triggers this action
|
||||
elem.tooltip("hide");
|
||||
|
||||
var amURI = elem.data("alertmanager-uri");
|
||||
var silenceID = elem.data("silence-id");
|
||||
|
||||
// change icon to indicate progress
|
||||
markInProgress(amURI, silenceID);
|
||||
|
||||
// send DELETE request to Alertmanager
|
||||
deleteSilence(amURI, silenceID);
|
||||
});
|
||||
}
|
||||
|
||||
exports.init = init;
|
||||
@@ -1,66 +0,0 @@
|
||||
const $ = window.jQuery = require("jquery");
|
||||
const templatesMock = require("./__mocks__/templatesMock");
|
||||
const ajaxMock = require("./__mocks__/ajaxMock");
|
||||
|
||||
const unsilenceButtonHTML =
|
||||
"<button class='silence-delete'" +
|
||||
" data-alertmanager-uri='http://localhost'" +
|
||||
" data-silence-id='abcd'>" +
|
||||
" <span class='fa fa-trash-o'></span>" +
|
||||
"</button>";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
test("unsilence button icons after success", () => {
|
||||
var body = templatesMock.loadTemplates();
|
||||
body.push(unsilenceButtonHTML);
|
||||
document.body.innerHTML = body;
|
||||
|
||||
require("bootstrap/js/tooltip.js");
|
||||
const unsilence = require("./unsilence");
|
||||
|
||||
const ajaxServer = ajaxMock.createServer(200, {"status":"success"});
|
||||
ajaxServer.start();
|
||||
|
||||
unsilence.init();
|
||||
// icon should be trash-o before clicking
|
||||
expect($("button > span.fa").hasClass("fa-trash-o")).toBe(true);
|
||||
$("button.silence-delete").click();
|
||||
// and switch to green check mark in circle after
|
||||
expect($("button > span.fa").hasClass("fa-trash-o")).toBe(false);
|
||||
expect($("button > span.fa").hasClass("fa-check-circle")).toBe(true);
|
||||
expect($("button > span.fa").hasClass("text-success")).toBe(true);
|
||||
|
||||
ajaxServer.stop();
|
||||
});
|
||||
|
||||
test("unsilence button icons after failed delete", () => {
|
||||
var body = templatesMock.loadTemplates();
|
||||
body.push(unsilenceButtonHTML);
|
||||
document.body.innerHTML = body;
|
||||
|
||||
require("bootstrap/js/tooltip.js");
|
||||
const unsilence = require("./unsilence");
|
||||
|
||||
const ajaxServer = ajaxMock.createServer(500, {
|
||||
"status": "error",
|
||||
"errorType": "server_error",
|
||||
"error": "end time must not be modified for elapsed silence"
|
||||
});
|
||||
ajaxServer.start();
|
||||
|
||||
unsilence.init();
|
||||
// icon should be trash-o before clicking
|
||||
expect($("button > span.fa").hasClass("fa-trash-o")).toBe(true);
|
||||
$("button.silence-delete").click();
|
||||
// and switch to green check mark in circle after
|
||||
expect($("button > span.fa").hasClass("fa-trash-o")).toBe(false);
|
||||
expect($("button > span.fa").hasClass("fa-exclamation-circle")).toBe(true);
|
||||
expect($("button > span.fa").hasClass("text-danger")).toBe(true);
|
||||
|
||||
// run timers, it should reset the icon back to trash-o
|
||||
jest.runOnlyPendingTimers();
|
||||
expect($("button > span.fa").hasClass("fa-trash-o")).toBe(true);
|
||||
|
||||
ajaxServer.stop();
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const $ = require("jquery");
|
||||
const moment = require("moment");
|
||||
|
||||
const config = require("./config");
|
||||
const counter = require("./counter");
|
||||
const templates = require("./templates");
|
||||
|
||||
var selectors = {
|
||||
countdown: "#reload-counter"
|
||||
};
|
||||
|
||||
var lastTs = 0;
|
||||
var maxLag;
|
||||
|
||||
var inCountdown = false;
|
||||
var fatalCountdown = 60;
|
||||
var fatalReloadTimer = false;
|
||||
var fatalCounterTimer = false;
|
||||
|
||||
function timerTick() {
|
||||
if (lastTs === 0) return;
|
||||
|
||||
// don't raise an error if autorefresh is disabled
|
||||
if (!config.getOption("autorefresh").Get()) return;
|
||||
|
||||
var now = moment().utc().unix();
|
||||
if (now - lastTs > maxLag) {
|
||||
$("#errors").html(templates.renderTemplate("fatalError", {
|
||||
lastTs: lastTs,
|
||||
secondsLeft: fatalCountdown
|
||||
})).show();
|
||||
counter.markUnknown();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function init(interval, tolerance) {
|
||||
maxLag = tolerance;
|
||||
setInterval(timerTick, interval * 1000);
|
||||
}
|
||||
|
||||
function pong(ts) {
|
||||
lastTs = ts.utc().unix();
|
||||
}
|
||||
|
||||
function getLastUpdate() {
|
||||
return lastTs;
|
||||
}
|
||||
|
||||
function isFatal() {
|
||||
return inCountdown;
|
||||
}
|
||||
|
||||
exports.init = init;
|
||||
exports.pong = pong;
|
||||
exports.getLastUpdate = getLastUpdate;
|
||||
exports.isFatal = isFatal;
|
||||
@@ -1,63 +0,0 @@
|
||||
window.jQuery = require("jquery");
|
||||
const moment = require("moment");
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
test("watchdog init()", () => {
|
||||
const watchdog = require("./watchdog");
|
||||
watchdog.init(1, 1);
|
||||
});
|
||||
|
||||
test("watchdog getLastUpdate() without pong", () => {
|
||||
const watchdog = require("./watchdog");
|
||||
expect(watchdog.getLastUpdate()).toBe(0);
|
||||
});
|
||||
|
||||
test("watchdog getLastUpdate() with pong", () => {
|
||||
const watchdog = require("./watchdog");
|
||||
var ts = moment();
|
||||
watchdog.pong(ts);
|
||||
expect(watchdog.getLastUpdate()).toBe(ts.utc().unix());
|
||||
});
|
||||
|
||||
test("watchdog isFatal() should be false by default", () => {
|
||||
const watchdog = require("./watchdog");
|
||||
expect(watchdog.isFatal()).toBe(false);
|
||||
});
|
||||
|
||||
test("watchdog isFatal() should be true after deadline passes", () => {
|
||||
const counter = require("./counter");
|
||||
counter.init();
|
||||
|
||||
const config = require("./config");
|
||||
config.newOption({
|
||||
Cookie: "autoRefresh",
|
||||
QueryParam: "autorefresh",
|
||||
Selector: "#autorefresh",
|
||||
Getter: function() {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
const templatesMock = require("./__mocks__/templatesMock");
|
||||
document.body.innerHTML = templatesMock.loadTemplates();
|
||||
const templates = require("./templates");
|
||||
templates.init();
|
||||
|
||||
const watchdog = require("./watchdog");
|
||||
jest.clearAllTimers();
|
||||
watchdog.init(1, 60); // 1s interval, 60s tolerance
|
||||
// should be false before we pong() the first time
|
||||
expect(watchdog.isFatal()).toBe(false);
|
||||
|
||||
watchdog.pong(moment());
|
||||
// should be false after first pong since we're < maxLag
|
||||
expect(watchdog.isFatal()).toBe(false);
|
||||
|
||||
// last timestamp is too old, should be true
|
||||
var ts = moment().subtract(61, "seconds");
|
||||
watchdog.pong(ts);
|
||||
expect(watchdog.getLastUpdate()).toBe(ts.utc().unix());
|
||||
jest.runTimersToTime(2 * 1000);
|
||||
expect(watchdog.isFatal()).toBe(true);
|
||||
});
|
||||
@@ -1,332 +0,0 @@
|
||||
<script type="application/json" id="alert-group-title">
|
||||
<% if (Object.keys(group.labels).length > 0) { %>
|
||||
<% var filters = ['@receiver=' + group.receiver] %>
|
||||
<% _.each(group.labels, function(label_val, label_key) { filters.push(label_key + '=' + label_val) }) %>
|
||||
<% var groupLink = '?q=' + filters.join(',') %>
|
||||
<span class="pull-left alert-group-link">
|
||||
<a href="<%= groupLink %>" title="Link to this alert group", data-toggle="tooltip" data-placement="top">
|
||||
<i class="fa fa-share-square-o"/>
|
||||
</a>
|
||||
</span>
|
||||
<% if (group.alerts.length > 1) { %>
|
||||
<span class="badge pull-right">
|
||||
<%- group.alerts.length %>
|
||||
</span>
|
||||
<% } %>
|
||||
<div class="panel-title">
|
||||
<% if (Object.keys(group.labels).length > 0) { %>
|
||||
<% _.each(sortMapByKey(group.labels), function(label) { %>
|
||||
<% var attrs = getLabelAttrs(label.key, label.value) %>
|
||||
<%= renderTemplate('buttonLabel', {elem: 'div', elemClass: 'label label-list', attrs: attrs, label: label}) %>
|
||||
<% }) %>
|
||||
<% } else { %>
|
||||
<div class="label-list label"></div>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="alert-group-annotations">
|
||||
<% var hiddenCount = 0 %>
|
||||
<% _.each(alert.annotations, function(annotation) { %>
|
||||
<% if (annotation.isLink === false) { %>
|
||||
<% if (annotation.visible === false) { hiddenCount++ } %>
|
||||
<%= renderTemplate('alertAnnotation', {annotation: annotation}) %>
|
||||
<% } %>
|
||||
<% }) %>
|
||||
<% if (hiddenCount) { %>
|
||||
<span class="label label-default label-list cursor-pointer"
|
||||
data-toggle="toggle-hidden-annotation"
|
||||
type="button">
|
||||
<i class="fa fa-search-plus" title="Toggle hidden annotations" data-toggle="tooltip" data-placement="top" />
|
||||
</span>
|
||||
<% } %>
|
||||
<% _.each(alert.annotations, function(annotation) { %>
|
||||
<% if (annotation.isLink) { %>
|
||||
<a class="label label-list label-default"
|
||||
href="<%= annotation.value %>"
|
||||
target="_blank"
|
||||
title="<%= annotation.value %>"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top">
|
||||
<i class="fa fa-external-link"/>
|
||||
<%- annotation.name %>
|
||||
</a>
|
||||
<% } %>
|
||||
<% }) %>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="alert-group-labels">
|
||||
<% _.each(sortMapByKey(alert.labels), function(label) { %>
|
||||
<% if (group.labels[label.key] == undefined) { %>
|
||||
<% var attrs = getLabelAttrs(label.key, label.value) %>
|
||||
<%= renderTemplate('buttonLabel', {elem: 'span', attrs: attrs, label: label}) %>
|
||||
<% } %>
|
||||
<% }) %>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="alert-group-elements">
|
||||
<% var cls_indicator = 'incident-indicator-danger' %>
|
||||
<% if (alert.state === "suppressed") { cls_indicator = 'incident-indicator-success' } %>
|
||||
<div class="alert-static-elements">
|
||||
<% _.each(alert.alertmanager, function(am){ %>
|
||||
<% var labelCls = "label-primary" %>
|
||||
<% if (am.state === "suppressed") { %>
|
||||
<% labelCls = "label-success" %>
|
||||
<% } else if (am.state === "unprocessed") { %>
|
||||
<% labelCls = "label-default" %>
|
||||
<% } %>
|
||||
<a class="label label-list <%- labelCls %>"
|
||||
href="<%= am.source %>"
|
||||
target="_blank"
|
||||
title="Go to the alert source"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top">
|
||||
<%- am.name %>
|
||||
</a>
|
||||
<% }) %>
|
||||
<% var attrs = getLabelAttrs("@state", alert.state) %>
|
||||
<%= renderTemplate('buttonLabel', {elem: 'span', attrs: attrs, label: {key: '@state', value: alert.state, text: alert.state}}) %>
|
||||
<% if (alert.state != "suppressed") { %>
|
||||
<% var labels = [] %>
|
||||
<% var alertmanagers = [] %>
|
||||
<% _.each(sortMapByKey(alert.labels), function(label) { %>
|
||||
<% labels.push(label.key + '=' + label.value) %>
|
||||
<% }) %>
|
||||
<% _.each(alert.alertmanager, function(alertmanager){ %>
|
||||
<% alertmanagers.push(alertmanager.name) %>
|
||||
<% }) %>
|
||||
<% alertmanagers.sort() %>
|
||||
<span class="label label-list label-success cursor-pointer"
|
||||
type="button"
|
||||
data-labels="<%= labels.join(',') %>"
|
||||
data-alertmanagers="<%= alertmanagers.join(',') %>"
|
||||
data-alertname="<%= alert.labels.alertname %>"
|
||||
data-toggle="modal"
|
||||
data-target="#silenceModal">
|
||||
<i class="fa fa-bell-slash" title="Silence this alert" data-toggle="tooltip" data-placement="top" />
|
||||
</span>
|
||||
<% } %>
|
||||
<% var attrs = getLabelAttrs("@receiver", alert.receiver) %>
|
||||
<%= renderTemplate('buttonLabel', {elem: 'span', attrs: attrs, label: {key: '@receiver', value: alert.receiver, text: alert.receiver}}) %>
|
||||
<a class="label label-list label-default label-age label-ts"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
data-ts="<%= alert.startsAt %>">
|
||||
<span class="label-ts-span">
|
||||
<%- alert.startsAt %>
|
||||
</span>
|
||||
<div class="incident-indicator hidden <%= cls_indicator %>">
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="alert-group-silence">
|
||||
<% _.each(alert.alertmanager, function(am) { %>
|
||||
<% _.each(am.silences, function(silence) { %>
|
||||
<div class="silence-block well">
|
||||
<button type="button"
|
||||
class="close silence-delete"
|
||||
title="Delete this silence"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
data-alertmanager-uri="<%- am.uri %>"
|
||||
data-silence-id="<%- silence.id %>">
|
||||
<span class="fa fa-trash-o"></span>
|
||||
</button>
|
||||
<blockquote class="silence-comment">
|
||||
<% if (silence.jiraURL) { %>
|
||||
<a href="<%= silence.jiraURL %>" target="_blank">
|
||||
<i class="fa fa-external-link"/>
|
||||
<%- silence.comment %>
|
||||
</a>
|
||||
<% } else { %>
|
||||
<%- silence.comment %>
|
||||
<% } %>
|
||||
<br/>
|
||||
<% if (alert.alertmanager.length > 1) { %>
|
||||
<div class="label label-list label-primary">
|
||||
<%- am.name %>
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="label label-list label-default cursor-pointer click-to-copy"
|
||||
title="Click to copy this silence ID to clipboard"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
data-clipboard-text="<%- silence.id %>">
|
||||
<%- silence.id %>
|
||||
</div>
|
||||
<div class="label label-list label-default label-age label-ts cursor-help"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
data-ts="<%= silence.startsAt %>">
|
||||
Started
|
||||
<span class="label-ts-span">
|
||||
<%- silence.startsAt %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="label label-list label-default label-age label-ts cursor-help"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
data-ts="<%= silence.endsAt %>">
|
||||
Ends
|
||||
<span class="label-ts-span">
|
||||
<%- silence.endsAt %>
|
||||
</span>
|
||||
</div>
|
||||
<% _.each(silence.matchers, function(matcher) { %>
|
||||
<div class="label label-list label-success">
|
||||
<% var matcherOperator = "=" %>
|
||||
<% if (matcher.isRegex) { %>
|
||||
<% matcherOperator = "=~" %>
|
||||
<% } %>
|
||||
<%- matcher.name %><%- matcherOperator %><%- matcher.value %>
|
||||
</div>
|
||||
<% }) %>
|
||||
<footer>
|
||||
<cite>
|
||||
<abbr>
|
||||
<%- silence.createdBy %>
|
||||
</abbr>
|
||||
</cite>
|
||||
</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
<% }) %>
|
||||
<% }) %>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="alert-group-label-map">
|
||||
<% var labelArr = [] %>
|
||||
<% _.each(labelMap, function(label, text) { %>
|
||||
<% 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 class="panel-body incident-group-separator">
|
||||
<div class="incident-group">
|
||||
<% var skippedLabel = '+' + skipped %>
|
||||
<% if (skipped == 1) { %>
|
||||
<% skippedLabel += " alert" %>
|
||||
<% } else { %>
|
||||
<% skippedLabel += " alerts" %>
|
||||
<% } %>
|
||||
<div class="incident-group">
|
||||
<span class="badge">
|
||||
<%- skippedLabel %>
|
||||
</span>
|
||||
<% var rendered = 0 %>
|
||||
<!--
|
||||
can't use underscore each() here as it doesn't support breaking the loop
|
||||
with 'return false', use jquery method
|
||||
-->
|
||||
<% $.each(labelArr, function(i, label) { %>
|
||||
<% if (rendered > 8 && labelArr.length > 10) { %>
|
||||
<span class="label label-list label-default">
|
||||
<% var skippedCount = labelArr.length - rendered %>
|
||||
<% if (skippedCount == 1) { %>
|
||||
+<%- skippedCount %> label
|
||||
<% } else { %>
|
||||
+<%- skippedCount %> labels
|
||||
<% } %>
|
||||
</span>
|
||||
<% return false %>
|
||||
<% } else { %>
|
||||
<% rendered++ %>
|
||||
<% var attrs = getLabelAttrs(label.key, label.value) %>
|
||||
<% if (label.hits > 1) { %>
|
||||
<div class="label-trim-group">
|
||||
<%= renderTemplate('buttonLabel', {elem: 'span', elemClass: 'label label-trim-tag', attrs: attrs, label: label}) %>
|
||||
<span class="label label-default label-trim-count">
|
||||
<%- label.hits %>
|
||||
</span>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<%= renderTemplate('buttonLabel', {elem: 'span', attrs: attrs, label: label}) %>
|
||||
<% } %>
|
||||
<% } %>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="alert-group">
|
||||
<div class="incident" id="<%= group.id %>" data-hash="<%= group.hash %>">
|
||||
<% var cls_panel = 'panel-default' %>
|
||||
<% if (group.stateCount.active > 0) { %>
|
||||
<% cls_panel = 'panel-danger' %>
|
||||
<% } else if (group.stateCount.suppressed > 0) { %>
|
||||
<% cls_panel = 'panel-success' %>
|
||||
<% } %>
|
||||
<div class="panel <%= cls_panel %>">
|
||||
<div class="panel-heading text-center">
|
||||
<%= renderTemplate('alertGroupTitle', {group: group}) %>
|
||||
</div>
|
||||
<% var labelMap = {} %>
|
||||
<% var skipped = 0 %>
|
||||
<% _.each(group.alerts, function(alert, i) { %>
|
||||
<% if (i > alertLimit - 1) { %>
|
||||
<% skipped++ %>
|
||||
<% _.each(alert.labels, function(label_val, label_key) { %>
|
||||
<% 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 stateText = '@state: ' + alert.state %>
|
||||
<% if (labelMap[stateText] == undefined) { labelMap[stateText] = {key: '@state', value: alert.state, hits: 0} } %>
|
||||
<% labelMap[stateText].hits++ %>
|
||||
<% } else { %>
|
||||
<% var cls_body = '' %>
|
||||
<% if (i < group.alerts.length - 1) { cls_body = 'incident-group-separator' } %>
|
||||
<div class="panel-body <%= cls_body %>">
|
||||
<div class="incident-group">
|
||||
<%= renderTemplate('alertGroupAnnotations', {alert: alert}) %>
|
||||
<%= renderTemplate('alertGroupLabels', {alert: alert, group: group}) %>
|
||||
<%= renderTemplate('alertGroupElements', {alert: alert}) %>
|
||||
<%= renderTemplate('alertGroupSilence', {alert: alert}) %>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
<% }) %>
|
||||
<% if (!$.isEmptyObject(labelMap)) { %>
|
||||
<%= renderTemplate('alertGroupLabelMap', {labelMap: labelMap, skipped: skipped}) %>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="label-button-filter">
|
||||
<% if (elemClass == undefined) { var elemClass = '' } %>
|
||||
<% if (attrs == undefined) { var attrs = {class: '', style: ''} } %>
|
||||
<<%= elem %> class="<%= elemClass %> <%= attrs.class %>"
|
||||
style="<%= attrs.style %>"
|
||||
type="button"
|
||||
data-label-type="filter"
|
||||
data-label-key="<%= label.key %>"
|
||||
data-label-val="<%= label.value %>"
|
||||
data-toggle="modal"
|
||||
data-target="#labelModal">
|
||||
<%- label.text %>
|
||||
</<%= elem %>>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="alert-annotation">
|
||||
<% var cls = "" %>
|
||||
<% if (annotation.visible === false) { %>
|
||||
<% cls = "hidden hidden-annotation" %>
|
||||
<% } %>
|
||||
<div class="well well-sm annotation-well <%- cls %>">
|
||||
<i class="fa fa-info-circle text-muted" title="<%- annotation.name %>" data-toggle="tooltip" data-placement="top"/>
|
||||
<%= linkify(_.escape(annotation.value)) %>
|
||||
</div>
|
||||
</script>
|
||||
@@ -1,110 +0,0 @@
|
||||
<script type="application/json" id="reload-needed">
|
||||
<div class="jumbotron">
|
||||
<h1 class="text-center">
|
||||
New version detected, reloading ...
|
||||
<i class="fa fa-refresh fa-spin"/>
|
||||
</h1>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
|
||||
<script type="application/json" id="update-error">
|
||||
<div class="jumbotron">
|
||||
<h1 class="text-center">
|
||||
<%- error %>
|
||||
<% _.each(messages, function(message) { %>
|
||||
<div class="text-center">
|
||||
<p>
|
||||
<%- message %>
|
||||
</p>
|
||||
</div>
|
||||
<% }) %>
|
||||
<% if (lastTs > 0) { %>
|
||||
<div class="text-center">
|
||||
<p>
|
||||
Last successful update was <%= moment.unix(lastTs).fromNow() %>
|
||||
</p>
|
||||
</div>
|
||||
<% } %>
|
||||
</h1>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
|
||||
<script type="application/json" id="fatal-error">
|
||||
<div class="jumbotron">
|
||||
<h1 class="text-center">
|
||||
Critical error
|
||||
<i class="fa fa-exclamation-circle text-danger"/>
|
||||
</h1>
|
||||
<div class="text-center">
|
||||
<p>
|
||||
Auto refresh is enabled but last update was <%= moment.unix(lastTs).fromNow() %>
|
||||
</p>
|
||||
<p>
|
||||
This page will auto reload in
|
||||
<span id='reload-counter'>
|
||||
<%- secondsLeft %> seconds
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
|
||||
<script type="application/json" id="internal-error">
|
||||
<div class="jumbotron">
|
||||
<h1 class="text-center">
|
||||
Internal error
|
||||
<i class="fa fa-exclamation-circle text-danger"/>
|
||||
</h1>
|
||||
<div class="text-center">
|
||||
<p>
|
||||
<% if (name) { %>
|
||||
<%- name %>
|
||||
<% } %>
|
||||
<% if (message) { %>
|
||||
<%- message %>
|
||||
<% } %>
|
||||
<% if (!name && !message) { %>
|
||||
<% var msg = raw.split('(') %>
|
||||
<% if (msg.length > 0) { %>
|
||||
<%- msg[0] %>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
|
||||
<script type="application/json" id="instance-error">
|
||||
<% _.each(instances, function(instance) { %>
|
||||
<div class="alert alert-warning text-center" role="alert">
|
||||
<span class='label label-list label-primary'>
|
||||
<%- instance.name %>
|
||||
</span>
|
||||
<%- instance.error %>
|
||||
</div>
|
||||
<% }) %>
|
||||
</script>
|
||||
|
||||
|
||||
<script type="application/json" id="configuration-error">
|
||||
<div class="jumbotron">
|
||||
<h1 class="text-center">
|
||||
Configuration error
|
||||
<i class="fa fa-exclamation-circle text-danger"/>
|
||||
</h1>
|
||||
<div>
|
||||
<% _.each(instances, function(instance) { %>
|
||||
<p>
|
||||
<span class='label label-list label-primary'>
|
||||
<%- instance.name %>
|
||||
</span>
|
||||
<%- instance.error %>
|
||||
</p>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script type="application/json" id="history-menu">
|
||||
<% let filterRendered = false %>
|
||||
<% if (filters.length) { %>
|
||||
<% _.each(filters, function(filter) { %>
|
||||
<% if (filter !== activeFilter) { %>
|
||||
<%= renderTemplate("historyMenuItem", {filter: filter, icon: "fa fa-search"}) %>
|
||||
<% filterRendered = true %>
|
||||
<% } %>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
<% if (defaultFilter || savedFilter) { %>
|
||||
<% if (filterRendered) { %>
|
||||
<li role="separator" class="divider"></li>
|
||||
<% } %>
|
||||
<% if (defaultFilter) { %>
|
||||
<%= renderTemplate("historyMenuItem", {filter: defaultFilter, icon: "fa fa-home"}) %>
|
||||
<% } %>
|
||||
<% if (savedFilter) { %>
|
||||
<%= renderTemplate("historyMenuItem", {filter: savedFilter, icon: "fa fa-floppy-o"}) %>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="history-menu-item">
|
||||
<li class="history-menu">
|
||||
<a class="cursor-pointer history-menu-item">
|
||||
<span class="rawFilter hidden">
|
||||
<%- filter %>
|
||||
</span>
|
||||
<i class="<%- icon %>"></i>
|
||||
<% _.each(filter.split(","), function(filterItem) { %>
|
||||
<span class="label-list label label-info">
|
||||
<%- filterItem %>
|
||||
</span>
|
||||
<% }) %>
|
||||
</a>
|
||||
</li>
|
||||
</script>
|
||||
@@ -1,187 +0,0 @@
|
||||
<!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="{{ .WebPrefix }}static/dist/favicon.ico">
|
||||
<title>(◕︵◕)</title>
|
||||
|
||||
{{ template "static/dist/templates/loader_shared.html" }}
|
||||
{{ template "static/dist/templates/loader_unsee.html" }}
|
||||
</head>
|
||||
|
||||
<body class="dark" data-raven-dsn="{{ .SentryDSN }}" data-unsee-version="{{ .Version }}">
|
||||
|
||||
<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="{{ .DefaultFilter }}"
|
||||
autofocus>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
<li class="dropdown">
|
||||
<a href="#" id="history" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"
|
||||
title="Filter history" data-toggle="tooltip" data-placement="auto">
|
||||
<i id="historyList" class="fa fa-history"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu" id="historyMenu"></ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ .WebPrefix }}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" id="version">
|
||||
{{ .Version }}
|
||||
</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="raven-error" class="alert alert-warning text-center hidden" role="alert"></div>
|
||||
<div id="instance-errors"></div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="modal fade" id="silenceModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header text-center">
|
||||
<div class="modal-title">
|
||||
<button class="close" type="button" data-dismiss="modal">
|
||||
<i class="fa fa-close"></i>
|
||||
</button>
|
||||
<h4 class="modal-title">
|
||||
<i class="fa fa-bell-slash"></i>
|
||||
New silence
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{{ template "templates/alertgroup.html" }}
|
||||
{{ template "templates/summary.html" }}
|
||||
{{ template "templates/errors.html" }}
|
||||
{{ template "templates/modal.html" }}
|
||||
{{ template "templates/silence.html" }}
|
||||
{{ template "templates/history.html" }}
|
||||
@@ -1,33 +0,0 @@
|
||||
<script type="application/json" id="modal-title">
|
||||
<button class="close" type="button" data-dismiss="modal">
|
||||
<i class="fa fa-close"/>
|
||||
</button>
|
||||
<div class="label-list label <%= attrs.class %>" style="<%= attrs.style %>">
|
||||
<%- attrs.text %>
|
||||
</div>
|
||||
<span class="badge badge-primary">
|
||||
<%- counter %>
|
||||
</span>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="modal-body">
|
||||
<table class="table table-striped modal-table">
|
||||
<caption class="text-center">
|
||||
Quick filters
|
||||
</caption>
|
||||
<tbody>
|
||||
<% _.each(hints, function(hint) { %>
|
||||
<tr>
|
||||
<td class="modal-row-filters">
|
||||
<%- hint %>
|
||||
</td>
|
||||
<td class="modal-row-actions">
|
||||
<button class="btn btn-sm btn-primary pull-right modal-button-filter" type="button" data-filter-append-value="<%= hint %>">
|
||||
Apply
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</script>
|
||||
@@ -1,280 +0,0 @@
|
||||
<script type="application/json" id="silence-form">
|
||||
<div id="newSilenceAlert" class="alert alert-danger hidden" role="alert"></div>
|
||||
<form id="newSilenceForm">
|
||||
<div>
|
||||
<label class="control-label">Alertmanager</label>
|
||||
<select class="selectpicker silence-alertmanager-picker"
|
||||
data-style="silence-label-select"
|
||||
data-icon-base="fa"
|
||||
data-tick-icon="fa-check"
|
||||
data-width="fit"
|
||||
data-select-all-text="<i class='fa fa-check-square-o'></i>"
|
||||
data-deselect-all-text="<i class='fa fa-square-o'></i>"
|
||||
data-multiple-separator=" "
|
||||
<% if (Object.keys(alertmanagers).length > 1) { %>data-actions-box="true"<% } %>
|
||||
multiple
|
||||
required>
|
||||
<% _.each(alertmanagers, function(am) { %>
|
||||
<option <% if (_.contains(selectedAlertmanagers, am.name)) { %>selected="selected"<% } %>
|
||||
value="<%= am.uri %>"
|
||||
data-content="<span class='label label-list label-primary'><%- am.name %></span>">
|
||||
<%- am.name %>
|
||||
</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="control-label">Labels to match</label>
|
||||
<table class="table table-condensed silence-label-selects">
|
||||
<% if (Object.keys(labels).length > 0) { %>
|
||||
<% _.each(sortMapByKey(labels), function(label) { %>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<span class="badge select-label-badge" title="Click to select / deselect all values" data-toggle="tooltip">
|
||||
<%- Object.keys(label.value).length %>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<select class="selectpicker silence-label-picker"
|
||||
data-label-key="<%= label.key %>"
|
||||
data-style="silence-label-select"
|
||||
data-icon-base="fa"
|
||||
data-tick-icon="fa-check"
|
||||
data-width="fit"
|
||||
data-select-all-text="<i class='fa fa-check-square-o'></i>"
|
||||
data-deselect-all-text="<i class='fa fa-square-o'></i>"
|
||||
data-multiple-separator=" "
|
||||
data-selected-text-format="count > 1"
|
||||
<% if (Object.keys(label.value).length > 10) { %>data-live-search="true"<% } %>
|
||||
<% if (Object.keys(label.value).length > 1) { %>data-actions-box="true"<% } %>
|
||||
multiple>
|
||||
<% _.each(sortMapByKey(label.value), function(label_val) { %>
|
||||
<option <% if (label_val.value.selected) { %>selected="selected"<% } %>
|
||||
value="<%= label_val.key %>"
|
||||
data-content="<span class='<%= label_val.value.attrs.class %>' style='<%= label_val.value.attrs.style %>'><%- label_val.value.attrs.text %></span>">
|
||||
<%- label_val.value.attrs.text %>
|
||||
</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
<% } else { %>
|
||||
<tr>
|
||||
<td align="center" class="text-muted">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
No labels to match on, all alerts are already resolved.
|
||||
</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</table>
|
||||
|
||||
<div>
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li role="presentation">
|
||||
<a href="#startsAtTab" aria-controls="startsAtTab" role="tab" data-toggle="tab">
|
||||
Starts <span id="silence-start-description"></span>
|
||||
</a>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<a href="#endsAtTab" aria-controls="endsAtTab" role="tab" data-toggle="tab">
|
||||
Ends <span id="silence-end-description"></span>
|
||||
</a>
|
||||
</li>
|
||||
<li role="presentation" class="active">
|
||||
<a href="#duration" aria-controls="duration" role="tab" data-toggle="tab">
|
||||
Duration
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane fade" id="startsAtTab">
|
||||
<div id="startsAt" class="datetime-picker"></div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane fade" id="endsAtTab">
|
||||
<div id="endsAt" class="datetime-picker"></div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane active fade in" id="duration">
|
||||
<table class="table-condensed silence-duration-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="#" class="btn silence-duration-btn" data-duration-action="increment" data-duration-unit="days" data-duration-step="1">
|
||||
<i class="fa fa-chevron-up"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td></td>
|
||||
<td class="silence-separator"></td>
|
||||
<td>
|
||||
<a href="#" class="btn silence-duration-btn" data-duration-action="increment" data-duration-unit="hours" data-duration-step="1">
|
||||
<i class="fa fa-chevron-up"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td></td>
|
||||
<td class="silence-separator"></td>
|
||||
<td>
|
||||
<a href="#" class="btn silence-duration-btn" data-duration-action="increment" data-duration-unit="minutes" data-duration-step="5">
|
||||
<i class="fa fa-chevron-up"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div id="silence-duration-days" class="text-center silence-duration-text">0</div>
|
||||
</td>
|
||||
<td class="silence-duration-label">
|
||||
<span class="text-center text-muted">days</span>
|
||||
</td>
|
||||
<td class="silence-separator"></td>
|
||||
<td>
|
||||
<div id="silence-duration-hours" class="text-center silence-duration-text">0</div>
|
||||
</td>
|
||||
<td class="silence-duration-label">
|
||||
<span class="text-center text-muted">hours</span>
|
||||
</td>
|
||||
<td class="silence-separator"></td>
|
||||
<td>
|
||||
<div id="silence-duration-minutes" class="text-center silence-duration-text">0</div>
|
||||
</td>
|
||||
<td class="silence-duration-label">
|
||||
<span class="text-center text-muted">minutes</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="#" class="btn silence-duration-btn" data-duration-action="decrement" data-duration-unit="days" data-duration-step="1">
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td></td>
|
||||
<td class="silence-separator"></td>
|
||||
<td>
|
||||
<a href="#" class="btn silence-duration-btn" data-duration-action="decrement" data-duration-unit="hours" data-duration-step="1">
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td></td>
|
||||
<td class="silence-separator"></td>
|
||||
<td>
|
||||
<a href="#" class="btn silence-duration-btn" data-duration-action="decrement" data-duration-unit="minutes" data-duration-step="5">
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon">
|
||||
<i class="fa fa-envelope"></i>
|
||||
</span>
|
||||
<input type="email"
|
||||
class="form-control"
|
||||
id="createdBy"
|
||||
placeholder="Email"
|
||||
name="createdBy"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon">
|
||||
<i class="fa fa-comment"></i>
|
||||
</span>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="comment"
|
||||
placeholder="Comment"
|
||||
name="comment"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="separator">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<a class="text-muted icon-as-button" data-toggle="collapse" href="#silenceJSON" aria-expanded="false" aria-controls="silenceJSON">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6 text-right">
|
||||
<button id="silenceSubmit" type="submit" class="btn btn-success">
|
||||
<i class="fa fa-floppy-o"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="collapse" id="silenceJSON">
|
||||
<pre id="silenceJSONBlob"></pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="silence-form-results">
|
||||
<table class="table">
|
||||
<% _.each(alertmanagers, function(alertmanager) { %>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="label label-list label-primary">
|
||||
<%- alertmanager.name %>
|
||||
</span>
|
||||
</td>
|
||||
<td class="silence-post-result" data-uri="<%- alertmanager.uri %>">
|
||||
<div class="text-center text-muted">
|
||||
<i class="fa fa-refresh fa-spin"></i>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</table>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="silence-form-validation-error">
|
||||
<p class="text-center">
|
||||
<i class="fa fa-exclamation-circle"></i>
|
||||
<%- error %>
|
||||
</p>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="silence-form-success">
|
||||
<p class="text-success">
|
||||
<i class="fa fa-check-circle"></i>
|
||||
<%- silenceID %>
|
||||
</p>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="silence-form-error">
|
||||
<p class="text-danger">
|
||||
<i class="fa fa-exclamation-circle"></i>
|
||||
<%- error %>
|
||||
</p>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="silence-form-fatal">
|
||||
<div class="silence-result-icon text-center text-danger">
|
||||
<i class="fa fa-exclamation-circle"></i>
|
||||
</div>
|
||||
<p class="text-center">
|
||||
New silence form rendering failed.
|
||||
</p>
|
||||
<p class="text-center">
|
||||
<%- error %>
|
||||
</p>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="silence-form-loading">
|
||||
<div class="silence-result-icon text-center text-muted">
|
||||
<i class="fa fa-refresh fa-spin"></i>
|
||||
</div>
|
||||
</script>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script type="application/json" id="breakdown">
|
||||
<div class="popover">
|
||||
<div class="arrow"></div>
|
||||
<h1 class="popover-title text-center"></h1>
|
||||
<div class="popover-content" id="breakdown-content"></div>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="breakdown-content">
|
||||
<% if(tags.length > 0) { %>
|
||||
<table class="table">
|
||||
<% _.each(tags, function(tag) { %>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="label <%= tag.cls %>" style="<%= tag.style %>">
|
||||
<%- tag.name %>
|
||||
</span>
|
||||
</td>
|
||||
<td align="center">
|
||||
<span class="breakdown-badge">
|
||||
<%- tag.val %>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</table>
|
||||
<% } else { %>
|
||||
<p class="text-muted">Empty</p>
|
||||
<% } %>
|
||||
</script>
|
||||
19
main.go
19
main.go
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
@@ -12,7 +11,6 @@ import (
|
||||
"github.com/prymitive/unsee/internal/config"
|
||||
"github.com/prymitive/unsee/internal/models"
|
||||
"github.com/prymitive/unsee/internal/transform"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/DeanThompson/ginpprof"
|
||||
"github.com/gin-contrib/cors"
|
||||
@@ -21,6 +19,7 @@ import (
|
||||
"github.com/gin-gonic/contrib/sentry"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
raven "github.com/getsentry/raven-go"
|
||||
ginprometheus "github.com/mcuadros/go-gin-prometheus"
|
||||
@@ -38,11 +37,15 @@ var (
|
||||
// If there are requests with the same filter we should respond from cache
|
||||
// rather than do all the filtering every time
|
||||
apiCache *cache.Cache
|
||||
|
||||
// used by static file view handlers
|
||||
staticFileSystem = newBinaryFileSystem("ui/build")
|
||||
staticFileServer = http.FileServer(staticFileSystem)
|
||||
)
|
||||
|
||||
func getViewURL(sub string) string {
|
||||
u := path.Join(config.Config.Listen.Prefix, sub)
|
||||
if strings.HasSuffix(sub, "/") {
|
||||
if strings.HasSuffix(sub, "/") && !strings.HasSuffix(u, "/") {
|
||||
// if sub path had trailing slash then add it here, since path.Join will
|
||||
// skip it
|
||||
return u + "/"
|
||||
@@ -52,8 +55,7 @@ func getViewURL(sub string) string {
|
||||
|
||||
func setupRouter(router *gin.Engine) {
|
||||
router.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
router.Use(static.Serve(getViewURL("/static"), newBinaryFileSystem("static")))
|
||||
|
||||
router.Use(static.Serve(getViewURL("/"), staticFileSystem))
|
||||
router.Use(cors.New(cors.Config{
|
||||
AllowAllOrigins: true,
|
||||
AllowCredentials: true,
|
||||
@@ -62,11 +64,11 @@ func setupRouter(router *gin.Engine) {
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
}))
|
||||
|
||||
router.GET(getViewURL("/favicon.ico"), favicon)
|
||||
router.GET(getViewURL("/"), index)
|
||||
router.GET(getViewURL("/help"), help)
|
||||
router.GET(getViewURL("/alerts.json"), alerts)
|
||||
router.GET(getViewURL("/autocomplete.json"), autocomplete)
|
||||
router.NoRoute(notFound)
|
||||
}
|
||||
|
||||
func setupUpstreams() {
|
||||
@@ -171,11 +173,6 @@ func main() {
|
||||
|
||||
router := gin.New()
|
||||
|
||||
var t *template.Template
|
||||
t = loadTemplates(t, "templates")
|
||||
t = loadTemplates(t, "static/dist/templates")
|
||||
router.SetHTMLTemplate(t)
|
||||
|
||||
prom := ginprometheus.NewPrometheus("gin")
|
||||
prom.MetricsPath = getViewURL("/metrics")
|
||||
prom.Use(router)
|
||||
|
||||
82
package.json
82
package.json
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"name": "unsee",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/prymitive/unsee.git"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "jest"
|
||||
},
|
||||
"jest": {
|
||||
"collectCoverage": true,
|
||||
"coverageDirectory": ".coverage",
|
||||
"collectCoverageFrom": [
|
||||
"assets/static/*.js",
|
||||
"!assets/static/jquery.typing*.js",
|
||||
"!<rootDir>/node_modules/"
|
||||
],
|
||||
"verbose": true,
|
||||
"moduleNameMapper": {
|
||||
"\\.(css|less)$": "<rootDir>/assets/static/__mocks__/styleMock.js",
|
||||
"bootstrap\\.loader\\!\\.\\/no-op.js$": "<rootDir>/node_modules/bootstrap-loader/lib/bootstrap.loader.js",
|
||||
"\\!\\.\\/font-awesome\\.config\\.js$": "<rootDir>/assets/static/__mocks__/styleMock.js",
|
||||
"^favico\\.js$": "<rootDir>/assets/static/__mocks__/faviconMock.js",
|
||||
"^./favicon.ico$": "<rootDir>/assets/static/__mocks__/styleMock.js",
|
||||
"^is-in-viewport$": "<rootDir>/assets/static/__mocks__/isInViewportMock.js"
|
||||
},
|
||||
"testURL": "http://localhost"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^3.3.7",
|
||||
"bootstrap-select": "^1.12.4",
|
||||
"bootstrap-switch": "^3.3.4",
|
||||
"bootstrap-tagsinput": "git+https://github.com/bootstrap-tagsinput/bootstrap-tagsinput.git#41fe4aa2201c4af4cfc4293fa3fc8260ad60bf35",
|
||||
"bootswatch": "^3.3.7",
|
||||
"clipboard": "^1.7.1",
|
||||
"corejs-typeahead": "^1.2.1",
|
||||
"eonasdan-bootstrap-datetimepicker": "^4.17.47",
|
||||
"favico.js": "^0.3.10",
|
||||
"font-awesome": "^4.7.0",
|
||||
"is-in-viewport": "^3.0.3",
|
||||
"javascript-linkify": "^0.3.0",
|
||||
"jquery": "^3.3.1",
|
||||
"js-cookie": "^2.2.0",
|
||||
"js-sha1": "^0.6.0",
|
||||
"loaders.css": "^0.1.2",
|
||||
"lru": "^3.1.0",
|
||||
"masonry-layout": "^4.2.1",
|
||||
"moment": "^2.22.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"raven-js": "^3.25.2",
|
||||
"underscore": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-core": "^6.26.3",
|
||||
"babel-loader": "^7.1.4",
|
||||
"babel-preset-env": "^1.7.0",
|
||||
"bootstrap-loader": "^2.2.0",
|
||||
"bootstrap-sass": "^3.3.7",
|
||||
"clean-webpack-plugin": "^0.1.19",
|
||||
"css-loader": "^0.28.11",
|
||||
"eslint": "^5.0.0",
|
||||
"expose-loader": "^0.7.5",
|
||||
"extract-text-webpack-plugin": "^3.0.2",
|
||||
"file-loader": "^1.1.11",
|
||||
"font-awesome-webpack": "0.0.5-beta.2",
|
||||
"jest": "^21.2.1",
|
||||
"jquery-bridget": "^2.0.1",
|
||||
"less": "^3.0.4",
|
||||
"markdownlint-cli": "^0.11.0",
|
||||
"mock-xhr": "^0.1.0",
|
||||
"node-sass": "^4.9.0",
|
||||
"resolve-url-loader": "^2.3.0",
|
||||
"sass-loader": "^7.0.3",
|
||||
"style-loader": "^0.21.0",
|
||||
"uglifyjs-webpack-plugin": "^1.2.5",
|
||||
"url-loader": "^1.0.1",
|
||||
"webpack": "^4.12.0",
|
||||
"webpack-cli": "^3.0.3",
|
||||
"window": "^4.2.5"
|
||||
}
|
||||
}
|
||||
18279
package-lock.json → ui/package-lock.json
generated
18279
package-lock.json → ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
49
ui/package.json
Normal file
49
ui/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"version": "0.0.1",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.1.0",
|
||||
"@fortawesome/react-fontawesome": "0.1.0",
|
||||
"bootstrap": "^4.1.1",
|
||||
"bootswatch": "^4.1.1",
|
||||
"fast-deep-equal": "^2.0.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mobx": "^5.0.3",
|
||||
"mobx-react": "^5.2.3",
|
||||
"mobx-stored": "^1.0.2",
|
||||
"moment": "^2.22.2",
|
||||
"prop-types": "^15.6.2",
|
||||
"qs": "^6.5.2",
|
||||
"react": "^16.4.1",
|
||||
"react-autosuggest": "^9.3.4",
|
||||
"react-dom": "^16.4.1",
|
||||
"react-highlighter": "^0.4.2",
|
||||
"react-input-autosize": "^2.2.1",
|
||||
"react-linkify": "^0.2.2",
|
||||
"react-masonry-infinite": "^1.2.2",
|
||||
"react-moment": "^0.7.9",
|
||||
"react-resize-detector": "^3.0.1",
|
||||
"react-scripts": "1.1.4",
|
||||
"react-transition-group": "^2.4.0",
|
||||
"why-did-you-update": "^0.1.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test --env=jsdom",
|
||||
"eject": "react-scripts eject",
|
||||
"build-css": "node_modules/.bin/node-sass-chokidar src/ -o src/",
|
||||
"watch-css": "npm run build-css && node_modules/.bin/node-sass-chokidar src/ -o src/ --watch --recursive"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-plugin-react": "^7.10.0",
|
||||
"markdownlint-cli": "^0.10.0",
|
||||
"node-sass-chokidar": "^1.3.0",
|
||||
"react-test-renderer": "^16.4.1"
|
||||
}
|
||||
}
|
||||
BIN
ui/public/favicon.ico
Normal file
BIN
ui/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1,27 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="full" lang="en">
|
||||
|
||||
<head>
|
||||
<html 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="{{ .WebPrefix }}static/dist/favicon.ico">
|
||||
<title>(◕︵◕)</title>
|
||||
|
||||
{{ template "static/dist/templates/loader_shared.html" }}
|
||||
{{ template "static/dist/templates/loader_help.html" }}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is added to the
|
||||
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||
<title>unsee</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="page-header text-center">
|
||||
<h1>
|
||||
Documentation
|
||||
</h1>
|
||||
<h1>Documentation</h1>
|
||||
</div>
|
||||
|
||||
<table class="table help">
|
||||
@@ -296,7 +295,7 @@
|
||||
<nav>
|
||||
<ul class="pager">
|
||||
<li>
|
||||
<a href="{{ .WebPrefix }}">
|
||||
<a href="%PUBLIC_URL%/">
|
||||
<i class="fa fa-arrow-circle-left"></i> Back
|
||||
</a>
|
||||
</li>
|
||||
@@ -306,6 +305,5 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
31
ui/public/index.html
Normal file
31
ui/public/index.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is added to the
|
||||
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||
<title>unsee</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
15
ui/public/manifest.json
Normal file
15
ui/public/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"short_name": "unsee",
|
||||
"name": "Alert dashboard for Prometheus Alertmanager",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": "index.html",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
6404
ui/src/App.css
Normal file
6404
ui/src/App.css
Normal file
File diff suppressed because it is too large
Load Diff
34
ui/src/App.js
Normal file
34
ui/src/App.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { Provider } from "mobx-react";
|
||||
|
||||
import { AlertStore, DecodeLocationSearch } from "Stores/AlertStore";
|
||||
import { NavBar } from "Components/NavBar";
|
||||
import { Grid } from "Components/Grid";
|
||||
import { Fetcher } from "Components/Fetcher";
|
||||
|
||||
import "./App.css";
|
||||
|
||||
class App extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const params = DecodeLocationSearch();
|
||||
|
||||
this.alertStore = new AlertStore(params.q);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<NavBar alertStore={this.alertStore} />
|
||||
<Provider alertStore={this.alertStore}>
|
||||
<Grid alertStore={this.alertStore} />
|
||||
</Provider>
|
||||
<Fetcher alertStore={this.alertStore} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { App };
|
||||
43
ui/src/App.scss
Normal file
43
ui/src/App.scss
Normal file
@@ -0,0 +1,43 @@
|
||||
// custom "dark" color, little less dark than flatly
|
||||
$blue: #455a64;
|
||||
// body background color should be same as navbar, so it blends into one
|
||||
$body-bg: $blue;
|
||||
// default is ~0.97rem
|
||||
$font-size-base: 1rem;
|
||||
|
||||
// make links light gray by default instead of green
|
||||
$link-color: #7b8a8b; // $gray-700
|
||||
|
||||
// make dark darker, default it's $gray-700
|
||||
$dark: #3b4247;
|
||||
|
||||
@import "../node_modules/bootswatch/dist/flatly/variables";
|
||||
@import "../node_modules/bootstrap/scss/bootstrap";
|
||||
@import "../node_modules/bootswatch/dist/flatly/bootswatch";
|
||||
|
||||
// negative margin used for silence expiry badges with progress
|
||||
.nmb-05 {
|
||||
margin-bottom: -($spacer * 0.125) !important;
|
||||
}
|
||||
|
||||
// this is used for navbar, to make it transparent
|
||||
.bg-primary-transparent {
|
||||
background-color: rgba($primary, 0.95);
|
||||
}
|
||||
|
||||
// silence block gets a different color on hover
|
||||
.hover-bg-light:hover {
|
||||
background-color: $light;
|
||||
}
|
||||
.hover-bg-light:focus {
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// remove triangle from dropdowns
|
||||
.dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
27
ui/src/Common/Colors.js
Normal file
27
ui/src/Common/Colors.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { StaticLabels } from "./Query";
|
||||
|
||||
const StaticColorLabelClass = "info";
|
||||
const DefaultLabelClass = "warning";
|
||||
|
||||
// returns bootstrap class for coloring based on pased label name & value
|
||||
function GetLabelColorClass(name, value) {
|
||||
if (name === StaticLabels.AlertName) {
|
||||
// special case for alertname label, which is the name of an alert
|
||||
return "dark";
|
||||
}
|
||||
|
||||
if (name === StaticLabels.State) {
|
||||
switch (value) {
|
||||
case "active":
|
||||
return "danger";
|
||||
case "suppressed":
|
||||
return "success";
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
}
|
||||
|
||||
return DefaultLabelClass;
|
||||
}
|
||||
|
||||
export { GetLabelColorClass, StaticColorLabelClass, DefaultLabelClass };
|
||||
16
ui/src/Common/Query.js
Normal file
16
ui/src/Common/Query.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const QueryOperators = Object.freeze({
|
||||
Equal: "="
|
||||
});
|
||||
|
||||
const StaticLabels = Object.freeze({
|
||||
AlertName: "alertname",
|
||||
AlertManager: "@alertmanager",
|
||||
Receiver: "@receiver",
|
||||
State: "@state"
|
||||
});
|
||||
|
||||
function FormatQuery(name, operator, value) {
|
||||
return `${name}${operator}${value}`;
|
||||
}
|
||||
|
||||
export { QueryOperators, StaticLabels, FormatQuery };
|
||||
51
ui/src/Components/Fetcher/index.js
Normal file
51
ui/src/Components/Fetcher/index.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import React, { Component } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
|
||||
const Fetcher = observer(
|
||||
class Fetcher extends Component {
|
||||
static propTypes = {
|
||||
alertStore: PropTypes.instanceOf(AlertStore).isRequired
|
||||
};
|
||||
|
||||
timer = null;
|
||||
|
||||
// FIXME store last update timestamp and timer should inspect it (fire it
|
||||
// every 1s) rather than forcing fetch each time
|
||||
|
||||
componentDidUpdate() {
|
||||
const { alertStore } = this.props;
|
||||
|
||||
alertStore.fetch();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { alertStore } = this.props;
|
||||
|
||||
alertStore.fetch();
|
||||
|
||||
this.timer = setInterval(() => this.props.alertStore.fetch(), 15000);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { alertStore } = this.props;
|
||||
|
||||
return (
|
||||
// data-filters is there to register filters for observation in mobx
|
||||
<span
|
||||
data-filters={alertStore.filters.values.map(f => f.raw).join(" ")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { Fetcher };
|
||||
@@ -0,0 +1,4 @@
|
||||
.list-group-item.components-grid-alertgrid-alertgroup-alert {
|
||||
border-width: 3px;
|
||||
line-height: 1rem;
|
||||
}
|
||||
100
ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js
Normal file
100
ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import React, { Component } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
import Moment from "react-moment";
|
||||
|
||||
import { GetLabelColorClass } from "Common/Colors";
|
||||
import { StaticLabels } from "Common/Query";
|
||||
import { FilteringLabel } from "Components/Labels/FilteringLabel";
|
||||
import { RenderNonLinkAnnotation, RenderLinkAnnotation } from "../Annotation";
|
||||
import { Silence } from "../Silence";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
const Alert = observer(
|
||||
class Alert extends Component {
|
||||
static propTypes = {
|
||||
alert: PropTypes.object.isRequired,
|
||||
showAlertmanagers: PropTypes.bool.isRequired,
|
||||
showReceiver: PropTypes.bool.isRequired,
|
||||
afterUpdate: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
alert,
|
||||
showAlertmanagers,
|
||||
showReceiver,
|
||||
afterUpdate
|
||||
} = this.props;
|
||||
|
||||
let classNames = [
|
||||
"components-grid-alertgrid-alertgroup-alert",
|
||||
"list-group-item",
|
||||
"pl-1 pr-0 py-0",
|
||||
"my-1",
|
||||
"rounded-0",
|
||||
"border-left-1 border-right-0 border-top-0 border-bottom-0",
|
||||
`border-${GetLabelColorClass(StaticLabels.State, alert.state)}`
|
||||
];
|
||||
|
||||
return (
|
||||
<li className={classNames.join(" ")}>
|
||||
{alert.annotations
|
||||
.filter(a => a.isLink === false)
|
||||
.map(a => (
|
||||
<RenderNonLinkAnnotation
|
||||
key={a.name}
|
||||
name={a.name}
|
||||
value={a.value}
|
||||
/>
|
||||
))}
|
||||
<span className="text-nowrap text-truncate px-1 mr-1 badge badge-secondary">
|
||||
<Moment fromNow>{alert.startsAt}</Moment>
|
||||
</span>
|
||||
{Object.entries(alert.labels).map(([name, value]) => (
|
||||
<FilteringLabel key={name} name={name} value={value} />
|
||||
))}
|
||||
{showAlertmanagers
|
||||
? alert.alertmanager.map(am => (
|
||||
<FilteringLabel
|
||||
key={am.name}
|
||||
name={StaticLabels.AlertManager}
|
||||
value={am.name}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{showReceiver ? (
|
||||
<FilteringLabel
|
||||
name={StaticLabels.Receiver}
|
||||
value={alert.receiver}
|
||||
/>
|
||||
) : null}
|
||||
{alert.annotations
|
||||
.filter(a => a.isLink === true)
|
||||
.map(a => (
|
||||
<RenderLinkAnnotation
|
||||
key={a.name}
|
||||
name={a.name}
|
||||
value={a.value}
|
||||
/>
|
||||
))}
|
||||
{alert.alertmanager.map(am =>
|
||||
am.silencedBy.map(silenceID => (
|
||||
<Silence
|
||||
key={silenceID}
|
||||
alertmanager={am.name}
|
||||
silenceID={silenceID}
|
||||
afterUpdate={afterUpdate}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { Alert };
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import Linkify from "react-linkify";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons/faExternalLinkAlt";
|
||||
|
||||
const RenderNonLinkAnnotation = ({ name, value }) => {
|
||||
return (
|
||||
<div key={name} className="p-1 mr-1">
|
||||
<span className="text-muted">{name}: </span>
|
||||
<Linkify>{value}</Linkify>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
RenderNonLinkAnnotation.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
const RenderLinkAnnotation = ({ name, value }) => {
|
||||
return (
|
||||
<a
|
||||
key={name}
|
||||
href={value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-nowrap text-truncate badge badge-secondary mr-1"
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} /> {name}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
RenderLinkAnnotation.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export { RenderNonLinkAnnotation, RenderLinkAnnotation };
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { Component } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
import { StaticLabels } from "Common/Query";
|
||||
import { FilteringLabel } from "Components/Labels/FilteringLabel";
|
||||
import { RenderNonLinkAnnotation, RenderLinkAnnotation } from "../Annotation";
|
||||
|
||||
const GroupFooter = observer(
|
||||
class GroupFooter extends Component {
|
||||
static propTypes = {
|
||||
group: PropTypes.object.isRequired,
|
||||
alertmanagers: PropTypes.arrayOf(PropTypes.string).isRequired
|
||||
};
|
||||
|
||||
render() {
|
||||
const { group, alertmanagers } = this.props;
|
||||
|
||||
return (
|
||||
<div className="card-footer px-2 py-1">
|
||||
{group.shared.annotations
|
||||
.filter(a => a.isLink === false)
|
||||
.map(a => (
|
||||
<RenderNonLinkAnnotation
|
||||
key={a.name}
|
||||
name={a.name}
|
||||
value={a.value}
|
||||
/>
|
||||
))}
|
||||
{Object.entries(group.shared.labels).map(([name, value]) => (
|
||||
<FilteringLabel key={name} name={name} value={value} />
|
||||
))}
|
||||
{alertmanagers.map(am => (
|
||||
<FilteringLabel
|
||||
key={am}
|
||||
name={StaticLabels.AlertManager}
|
||||
value={am}
|
||||
/>
|
||||
))}
|
||||
<FilteringLabel name={StaticLabels.Receiver} value={group.receiver} />
|
||||
{group.shared.annotations
|
||||
.filter(a => a.isLink === true)
|
||||
.map(a => (
|
||||
<RenderLinkAnnotation
|
||||
key={a.name}
|
||||
name={a.name}
|
||||
value={a.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { GroupFooter };
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { Component } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faShareSquare } from "@fortawesome/free-solid-svg-icons/faShareSquare";
|
||||
import { faChevronUp } from "@fortawesome/free-solid-svg-icons/faChevronUp";
|
||||
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
|
||||
|
||||
import { FormatAPIFilterQuery } from "Stores/AlertStore";
|
||||
import { QueryOperators, StaticLabels, FormatQuery } from "Common/Query";
|
||||
import { FilteringLabel } from "Components/Labels/FilteringLabel";
|
||||
import { FilteringCounterBadge } from "Components/Labels/FilteringCounterBadge";
|
||||
|
||||
const GroupHeader = observer(
|
||||
class GroupHeader extends Component {
|
||||
static propTypes = {
|
||||
collapseStore: PropTypes.object.isRequired,
|
||||
labels: PropTypes.object.isRequired,
|
||||
receiver: PropTypes.string.isRequired,
|
||||
stateCount: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collapseStore, labels, receiver, stateCount } = this.props;
|
||||
|
||||
let groupFilters = Object.keys(labels).map(name =>
|
||||
FormatQuery(name, QueryOperators.Equal, labels[name])
|
||||
);
|
||||
groupFilters.push(
|
||||
FormatQuery(StaticLabels.Receiver, QueryOperators.Equal, receiver)
|
||||
);
|
||||
const groupLink = `?${FormatAPIFilterQuery(groupFilters)}`;
|
||||
|
||||
return (
|
||||
<h5 className="card-title text-center mb-0">
|
||||
<a
|
||||
href={groupLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="float-left badge badge-pill text-nowrap text-truncate mr-1 ml-0 pl-0 pr-1"
|
||||
>
|
||||
<FontAwesomeIcon icon={faShareSquare} />
|
||||
</a>
|
||||
<span className="float-right">
|
||||
<FilteringCounterBadge
|
||||
name="@state"
|
||||
value="unprocessed"
|
||||
counter={stateCount.unprocessed}
|
||||
/>
|
||||
<FilteringCounterBadge
|
||||
name="@state"
|
||||
value="suppressed"
|
||||
counter={stateCount.suppressed}
|
||||
/>
|
||||
<FilteringCounterBadge
|
||||
name="@state"
|
||||
value="active"
|
||||
counter={stateCount.active}
|
||||
/>
|
||||
<a
|
||||
className="text-muted cursor-pointer badge text-nowrap text-truncate pr-0"
|
||||
onClick={collapseStore.toggle}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={collapseStore.value ? faChevronUp : faChevronDown}
|
||||
/>
|
||||
</a>
|
||||
</span>
|
||||
<span>
|
||||
{Object.keys(labels).map(name => (
|
||||
<FilteringLabel key={name} name={name} value={labels[name]} />
|
||||
))}
|
||||
</span>
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { GroupHeader };
|
||||
@@ -0,0 +1,8 @@
|
||||
.progress.silence-progress {
|
||||
height: 2px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cite.components-grid-alertgroup-silences {
|
||||
font-size: 100%;
|
||||
}
|
||||
221
ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js
Normal file
221
ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js
Normal file
@@ -0,0 +1,221 @@
|
||||
import React, { Component } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import { observable, action } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
|
||||
import moment from "moment";
|
||||
import Moment from "react-moment";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons/faExternalLinkAlt";
|
||||
import { faChevronUp } from "@fortawesome/free-solid-svg-icons/faChevronUp";
|
||||
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
|
||||
|
||||
import { StaticLabels } from "Common/Query";
|
||||
import { FilteringLabel } from "Components/Labels/FilteringLabel";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
const SilenceComment = ({ silence }) => {
|
||||
if (silence.jiraURL) {
|
||||
return (
|
||||
<a href={silence.jiraURL} target="_blank" rel="noopener noreferrer">
|
||||
<FontAwesomeIcon className="mr-1" icon={faExternalLinkAlt} />
|
||||
{silence.comment}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return silence.comment;
|
||||
};
|
||||
SilenceComment.propTypes = {
|
||||
silence: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
const SilenceExpiryBadgeWithProgress = ({ silence }) => {
|
||||
// if silence is expired we can skip progress value calculation
|
||||
if (moment(silence.endsAt) < moment()) {
|
||||
return (
|
||||
<span className="badge badge-danger text-nowrap text-truncate mw-100 align-bottom">
|
||||
Expired <Moment fromNow>{silence.endsAt}</Moment>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const durationDone = moment().unix() - moment(silence.startsAt).unix();
|
||||
const durationTotal =
|
||||
moment(silence.endsAt).unix() - moment(silence.startsAt).unix();
|
||||
const durationPercent = (durationDone / durationTotal) * 100;
|
||||
|
||||
let progressClass;
|
||||
if (durationPercent > 90) {
|
||||
progressClass = "progress-bar bg-danger";
|
||||
} else if (durationPercent > 75) {
|
||||
progressClass = "progress-bar bg-warning";
|
||||
} else {
|
||||
progressClass = "progress-bar bg-success";
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="badge badge-light nmb-05 text-nowrap text-truncate mw-100 align-bottom">
|
||||
Expires <Moment fromNow>{silence.endsAt}</Moment>
|
||||
<div className="progress silence-progress bg-white">
|
||||
<div
|
||||
className={progressClass}
|
||||
role="progressbar"
|
||||
style={{ width: durationPercent + "%" }}
|
||||
aria-valuenow={durationPercent}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
SilenceExpiryBadgeWithProgress.propTypes = {
|
||||
silence: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
const SilenceDetails = ({ alertmanager, silence }) => {
|
||||
let expiresClass = "secondary";
|
||||
let expiresLabel = "Expires";
|
||||
if (moment(silence.endsAt) < moment()) {
|
||||
expiresClass = "danger";
|
||||
expiresLabel = "Expired";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<FilteringLabel name={StaticLabels.AlertManager} value={alertmanager} />
|
||||
<span className="badge badge-secondary text-nowrap text-truncate px-1 mr-1">
|
||||
{silence.id}
|
||||
</span>
|
||||
<span className="badge badge-secondary text-nowrap text-truncate px-1 mr-1">
|
||||
Silenced <Moment fromNow>{silence.startsAt}</Moment>
|
||||
</span>
|
||||
<span
|
||||
className={`badge badge-${expiresClass} text-nowrap text-truncate px-1 mr-1`}
|
||||
>
|
||||
{expiresLabel} <Moment fromNow>{silence.endsAt}</Moment>
|
||||
</span>
|
||||
{silence.matchers.map(matcher => (
|
||||
<span
|
||||
key={JSON.stringify(matcher)}
|
||||
className="badge badge-success text-nowrap text-truncate px-1 mr-1"
|
||||
>
|
||||
{matcher.name}
|
||||
{matcher.isRegex ? "=~" : "="}
|
||||
{matcher.value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
SilenceDetails.propTypes = {
|
||||
alertmanager: PropTypes.string.isRequired,
|
||||
silence: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
//
|
||||
const FallbackSilenceDesciption = ({ alertmanager, silenceID }) => {
|
||||
return (
|
||||
<div>
|
||||
<small className="text-muted">
|
||||
Silenced by {alertmanager}/{silenceID}
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
FallbackSilenceDesciption.propTypes = {
|
||||
alertmanager: PropTypes.string.isRequired,
|
||||
silenceID: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
const Silence = inject("alertStore")(
|
||||
observer(
|
||||
class Silence extends Component {
|
||||
static propTypes = {
|
||||
alertStore: PropTypes.object.isRequired,
|
||||
alertmanager: PropTypes.string.isRequired,
|
||||
silenceID: PropTypes.string.isRequired,
|
||||
afterUpdate: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
// store collapse state, by default only silence comment is visible
|
||||
// the rest of the silence is hidden until expanded by a click
|
||||
collapse = observable(
|
||||
{
|
||||
value: true,
|
||||
toggle() {
|
||||
this.value = !this.value;
|
||||
}
|
||||
},
|
||||
{ toggle: action.bound },
|
||||
{ name: "Silence collpase toggle" }
|
||||
);
|
||||
|
||||
componentDidUpdate() {
|
||||
const { afterUpdate } = this.props;
|
||||
afterUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { alertStore, alertmanager, silenceID } = this.props;
|
||||
|
||||
// We pass alertmanager name and silence ID to Silence component
|
||||
// and we need to lookup the actual silence data in the store.
|
||||
// Data might be missing from the store so first check if we have
|
||||
// anything for this alertmanager instance
|
||||
const amSilences = alertStore.data.silences[alertmanager];
|
||||
if (!amSilences)
|
||||
return (
|
||||
<FallbackSilenceDesciption
|
||||
alertmanager={alertmanager}
|
||||
silenceID={silenceID}
|
||||
/>
|
||||
);
|
||||
|
||||
// next check if alertmanager has our silence ID
|
||||
const silence = amSilences[silenceID];
|
||||
if (!silence)
|
||||
return (
|
||||
<FallbackSilenceDesciption
|
||||
alertmanager={alertmanager}
|
||||
silenceID={silenceID}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card mt-1 border-0 p-1">
|
||||
<div className="card-text mb-0">
|
||||
<span className="text-muted my-1">
|
||||
<SilenceComment silence={silence} />
|
||||
<span className="blockquote-footer pt-1">
|
||||
<a
|
||||
className="float-right cursor-pointer"
|
||||
onClick={this.collapse.toggle}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={this.collapse.value ? faChevronUp : faChevronDown}
|
||||
/>
|
||||
</a>
|
||||
<cite className="components-grid-alertgroup-silences mr-2">
|
||||
{silence.createdBy}
|
||||
</cite>
|
||||
{this.collapse.value ? (
|
||||
<SilenceExpiryBadgeWithProgress silence={silence} />
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{this.collapse.value ? null : (
|
||||
<SilenceDetails alertmanager={alertmanager} silence={silence} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export { Silence };
|
||||
26
ui/src/Components/Grid/AlertGrid/AlertGroup/index.css
Normal file
26
ui/src/Components/Grid/AlertGrid/AlertGroup/index.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.components-grid-alertgrid-alertgroup-fade-enter {
|
||||
opacity: 0.01;
|
||||
}
|
||||
|
||||
.components-grid-alertgrid-alertgroup-fade-enter-active {
|
||||
opacity: 1;
|
||||
transition: opacity 0.4s ease-in;
|
||||
}
|
||||
|
||||
.components-grid-alertgrid-alertgroup-fade-exit {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.components-grid-alertgrid-alertgroup-fade-exit-active {
|
||||
opacity: 0.01;
|
||||
transition: opacity 0.4s ease-in;
|
||||
}
|
||||
|
||||
.components-grid-alertgrid-alertgroup-fade-appear {
|
||||
opacity: 0.01;
|
||||
}
|
||||
|
||||
.components-grid-alertgrid-alertgroup-fade-appear-active {
|
||||
opacity: 1;
|
||||
transition: opacity 0.4s ease-in;
|
||||
}
|
||||
196
ui/src/Components/Grid/AlertGrid/AlertGroup/index.js
Normal file
196
ui/src/Components/Grid/AlertGrid/AlertGroup/index.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { Component } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { observable, action } from "mobx";
|
||||
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons/faPlus";
|
||||
import { faMinus } from "@fortawesome/free-solid-svg-icons/faMinus";
|
||||
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
import { Alert } from "./Alert";
|
||||
import { GroupFooter } from "./GroupFooter";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
const initialAlertsToRender = 5;
|
||||
|
||||
// Used to calculate step size when loading more alerts.
|
||||
// Step is calculated from the excesive alert count
|
||||
// (what's > initialAlertsToRender) by dividing it into 5 clicks.
|
||||
// Don't use step lower than 5, too much clicking if we have a group of 9:
|
||||
// * we'll show initially 5
|
||||
// * step would be 1
|
||||
// * 4 extra clicks to see the entire group
|
||||
// but ensure that step wouldn't push us above totalSize
|
||||
// With 9 alerts and rendering 5 initially we want to show extra 9 after one
|
||||
// click, and when user clicks showLess we want to go back to 5.
|
||||
function getStepSize(totalSize) {
|
||||
return Math.min(
|
||||
Math.max(Math.round((totalSize - initialAlertsToRender) / 5), 5),
|
||||
totalSize - initialAlertsToRender
|
||||
);
|
||||
}
|
||||
|
||||
const LoadButton = ({ icon, action }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm py-0 hover-bg-light"
|
||||
onClick={action}
|
||||
>
|
||||
<FontAwesomeIcon className="text-muted" icon={icon} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
LoadButton.propTypes = {
|
||||
icon: PropTypes.object.isRequired,
|
||||
action: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const AlertGroup = observer(
|
||||
class AlertGroup extends Component {
|
||||
static propTypes = {
|
||||
afterUpdate: PropTypes.func.isRequired,
|
||||
group: PropTypes.object.isRequired,
|
||||
showAlertmanagers: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
// store collapse state, alert groups can be collapsed to only show
|
||||
// the header, this is controlled by UI element on the header itself, so
|
||||
// this observable needs to be passed down to it
|
||||
collapse = observable(
|
||||
{
|
||||
value: false,
|
||||
toggle() {
|
||||
this.value = !this.value;
|
||||
}
|
||||
},
|
||||
{
|
||||
toggle: action.bound
|
||||
},
|
||||
{ name: "Collpase toggle" }
|
||||
);
|
||||
|
||||
renderConfig = observable({
|
||||
alertsToRender: initialAlertsToRender
|
||||
});
|
||||
|
||||
loadMore = action(() => {
|
||||
const { group } = this.props;
|
||||
|
||||
const step = getStepSize(group.alerts.length);
|
||||
|
||||
// show cur+step, but not more that total alert count
|
||||
this.renderConfig.alertsToRender = Math.min(
|
||||
this.renderConfig.alertsToRender + step,
|
||||
group.alerts.length
|
||||
);
|
||||
});
|
||||
|
||||
loadLess = action(() => {
|
||||
const { group } = this.props;
|
||||
|
||||
const step = getStepSize(group.alerts.length);
|
||||
|
||||
// show cur-step, but not less than 1
|
||||
this.renderConfig.alertsToRender = Math.max(
|
||||
this.renderConfig.alertsToRender - step,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
componentDidUpdate() {
|
||||
// whenever grid component re-renders we need to ensure that grid elements
|
||||
// are packed correctly
|
||||
this.props.afterUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { group, showAlertmanagers, afterUpdate } = this.props;
|
||||
|
||||
let footerAlertmanagers = [];
|
||||
let showAlertmanagersInFooter = false;
|
||||
|
||||
// There's no need to render @alertmanager labels if there's only 1
|
||||
// alertmanager upstream
|
||||
if (showAlertmanagers) {
|
||||
// Decide if we show @alertmanager label in footer or for every alert
|
||||
// we show it in the footer only if every alert has the same set of
|
||||
// alertmanagers (and there's > 1 alert to show, there's no footer for 1)
|
||||
showAlertmanagersInFooter =
|
||||
group.alerts.length > 1 &&
|
||||
Object.values(group.alertmanagerCount).every(
|
||||
elem => elem === Object.values(group.alertmanagerCount)[0]
|
||||
);
|
||||
if (showAlertmanagersInFooter) {
|
||||
footerAlertmanagers = group.alerts[0].alertmanager.map(am => am.name);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
key={group.id}
|
||||
in={true}
|
||||
classNames="components-grid-alertgrid-alertgroup-fade"
|
||||
timeout={400}
|
||||
appear={true}
|
||||
enter={true}
|
||||
exit={true}
|
||||
>
|
||||
<div className="components-grid-alertgrid-alertgroup p-1">
|
||||
<div className="card">
|
||||
<div className="card-body px-2 pt-2 pb-1">
|
||||
<GroupHeader
|
||||
collapseStore={this.collapse}
|
||||
labels={group.labels}
|
||||
receiver={group.receiver}
|
||||
stateCount={group.stateCount}
|
||||
/>
|
||||
{this.collapse.value ? null : (
|
||||
<ul className="list-group mt-1">
|
||||
{group.alerts
|
||||
.slice(0, this.renderConfig.alertsToRender)
|
||||
.map(alert => (
|
||||
<Alert
|
||||
key={JSON.stringify(alert.labels)}
|
||||
alert={alert}
|
||||
showAlertmanagers={
|
||||
showAlertmanagers && !showAlertmanagersInFooter
|
||||
}
|
||||
showReceiver={group.alerts.length === 1}
|
||||
afterUpdate={afterUpdate}
|
||||
/>
|
||||
))}
|
||||
{group.alerts.length > initialAlertsToRender ? (
|
||||
<li className="list-group-item border-0 p-0 text-center">
|
||||
<LoadButton icon={faMinus} action={this.loadLess} />
|
||||
<small className="text-muted mx-2">
|
||||
{this.renderConfig.alertsToRender}
|
||||
{" of "}
|
||||
{group.alerts.length}
|
||||
</small>
|
||||
<LoadButton icon={faPlus} action={this.loadMore} />
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{this.collapse.value === false && group.alerts.length > 1 ? (
|
||||
<GroupFooter
|
||||
group={group}
|
||||
alertmanagers={footerAlertmanagers}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { AlertGroup };
|
||||
15
ui/src/Components/Grid/AlertGrid/Constants.js
Normal file
15
ui/src/Components/Grid/AlertGrid/Constants.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// grid sizes, defines how many columns are used depending on the screen width
|
||||
// this is config as expected by https://github.com/callmecavs/bricks.js#sizes
|
||||
const GridSizesConfig = [
|
||||
{ columns: 1, gutter: 0 },
|
||||
{ mq: "800px", columns: 2, gutter: 0 },
|
||||
{ mq: "1400px", columns: 3, gutter: 0 },
|
||||
{ mq: "2100px", columns: 4, gutter: 0 },
|
||||
{ mq: "2800px", columns: 5, gutter: 0 },
|
||||
{ mq: "3500px", columns: 6, gutter: 0 },
|
||||
{ mq: "4200px", columns: 7, gutter: 0 },
|
||||
{ mq: "4900px", columns: 7, gutter: 0 },
|
||||
{ mq: "5600px", columns: 8, gutter: 0 }
|
||||
];
|
||||
|
||||
export { GridSizesConfig };
|
||||
51
ui/src/Components/Grid/AlertGrid/index.css
Normal file
51
ui/src/Components/Grid/AlertGrid/index.css
Normal file
@@ -0,0 +1,51 @@
|
||||
.components-grid-alertgrid-alertgroup {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) and (max-width: 1399px) {
|
||||
.components-grid-alertgrid-alertgroup {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1400px) and (max-width: 2099px) {
|
||||
.components-grid-alertgrid-alertgroup {
|
||||
width: 33.333%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2100px) and (max-width: 2799px) {
|
||||
.components-grid-alertgrid-alertgroup {
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2800px) and (max-width: 3499px) {
|
||||
.components-grid-alertgrid-alertgroup {
|
||||
width: 20%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 3500px) and (max-width: 4199px) {
|
||||
.components-grid-alertgrid-alertgroup {
|
||||
width: 16.666%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 4200px) and (max-width: 4899px) {
|
||||
.components-grid-alertgrid-alertgroup {
|
||||
width: 14.285%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 4900px) and (max-width: 5599px) {
|
||||
.components-grid-alertgrid-alertgroup {
|
||||
width: 14.285%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 5600px) {
|
||||
.components-grid-alertgrid-alertgroup {
|
||||
width: 12.5%;
|
||||
}
|
||||
}
|
||||
114
ui/src/Components/Grid/AlertGrid/index.js
Normal file
114
ui/src/Components/Grid/AlertGrid/index.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { Component } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import { observable, action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
import MasonryInfiniteScroller from "react-masonry-infinite";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch";
|
||||
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { AlertGroup } from "./AlertGroup";
|
||||
import { GridSizesConfig } from "./Constants";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
const AlertGrid = observer(
|
||||
class AlertGrid extends Component {
|
||||
static propTypes = {
|
||||
alertStore: PropTypes.instanceOf(AlertStore).isRequired
|
||||
};
|
||||
|
||||
// store reference to generated masonry component so we can call it
|
||||
// to repack the grid after any component was re-rendered, which could
|
||||
// alter its size breaking grid layout
|
||||
masonryComponentReference = observable(
|
||||
{ ref: false },
|
||||
{},
|
||||
{ name: "Masonry reference" }
|
||||
);
|
||||
// store it for later
|
||||
storeMasonryRef = action(ref => {
|
||||
this.masonryComponentReference.ref = ref;
|
||||
});
|
||||
// used to call forcePack() which will repack all grid elements
|
||||
// (alert groups), this needs to be called if any group size changes
|
||||
masonryRepack = action(() => {
|
||||
if (this.masonryComponentReference.ref !== false) {
|
||||
this.masonryComponentReference.ref.forcePack();
|
||||
}
|
||||
});
|
||||
|
||||
// how many alert groups to render
|
||||
// FIXME reset on filter change
|
||||
initial = 50;
|
||||
groupsToRender = observable(
|
||||
{
|
||||
value: this.initial
|
||||
},
|
||||
{},
|
||||
{ name: "Groups to render" }
|
||||
);
|
||||
// how many groups add to render count when user scrolls to the bottom
|
||||
loadMoreStep = 30;
|
||||
//
|
||||
loadMore = action(() => {
|
||||
const { alertStore } = this.props;
|
||||
|
||||
this.groupsToRender.value = Math.min(
|
||||
this.groupsToRender.value + this.loadMoreStep,
|
||||
Object.keys(alertStore.data.groups).length
|
||||
);
|
||||
});
|
||||
|
||||
componentDidUpdate() {
|
||||
// whenever grid component re-renders we need to ensure that grid elements
|
||||
// are packed correctly
|
||||
this.masonryRepack();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { alertStore } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<MasonryInfiniteScroller
|
||||
ref={this.storeMasonryRef}
|
||||
pack={true}
|
||||
sizes={GridSizesConfig}
|
||||
loadMore={() => {
|
||||
this.loadMore();
|
||||
}}
|
||||
hasMore={
|
||||
this.groupsToRender.value <
|
||||
Object.keys(alertStore.data.groups).length
|
||||
}
|
||||
threshold={50}
|
||||
loader={
|
||||
<div key="loader" className="text-center text-muted py-3">
|
||||
<FontAwesomeIcon icon={faCircleNotch} size="lg" spin />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{Object.keys(alertStore.data.groups)
|
||||
.slice(0, this.groupsToRender.value)
|
||||
.map(id => (
|
||||
<AlertGroup
|
||||
key={id}
|
||||
group={alertStore.data.groups[id]}
|
||||
showAlertmanagers={
|
||||
alertStore.data.upstreams.instances.length > 1
|
||||
}
|
||||
afterUpdate={this.masonryRepack}
|
||||
/>
|
||||
))}
|
||||
</MasonryInfiniteScroller>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { AlertGrid };
|
||||
@@ -0,0 +1,33 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<BlankPage /> matches snapshot 1`] = `
|
||||
<div
|
||||
className="jumbotron text-center bg-primary my-4"
|
||||
>
|
||||
<h1
|
||||
className="display-1 my-5"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-trophy fa-w-18 text-success"
|
||||
data-icon="trophy"
|
||||
data-prefix="fas"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 576 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M552 64H448V24c0-13.3-10.7-24-24-24H152c-13.3 0-24 10.7-24 24v40H24C10.7 64 0 74.7 0 88v56c0 35.7 22.5 72.4 61.9 100.7 31.5 22.7 69.8 37.1 110 41.7C203.3 338.5 240 360 240 360v72h-48c-35.3 0-64 20.7-64 56v12c0 6.6 5.4 12 12 12h296c6.6 0 12-5.4 12-12v-12c0-35.3-28.7-56-64-56h-48v-72s36.7-21.5 68.1-73.6c40.3-4.6 78.6-19 110-41.7 39.3-28.3 61.9-65 61.9-100.7V88c0-13.3-10.7-24-24-24zM99.3 192.8C74.9 175.2 64 155.6 64 144v-16h64.2c1 32.6 5.8 61.2 12.8 86.2-15.1-5.2-29.2-12.4-41.7-21.4zM512 144c0 16.1-17.7 36.1-35.3 48.8-12.5 9-26.7 16.2-41.8 21.4 7-25 11.8-53.6 12.8-86.2H512v16z"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
</h1>
|
||||
<p
|
||||
className="lead text-muted"
|
||||
>
|
||||
Nothing to show
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
19
ui/src/Components/Grid/BlankPage/index.js
Normal file
19
ui/src/Components/Grid/BlankPage/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrophy } from "@fortawesome/free-solid-svg-icons/faTrophy";
|
||||
|
||||
class BlankPage extends Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="jumbotron text-center bg-primary my-4">
|
||||
<h1 className="display-1 my-5">
|
||||
<FontAwesomeIcon className="text-success" icon={faTrophy} />
|
||||
</h1>
|
||||
<p className="lead text-muted">Nothing to show</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { BlankPage };
|
||||
11
ui/src/Components/Grid/BlankPage/index.test.js
Normal file
11
ui/src/Components/Grid/BlankPage/index.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import renderer from "react-test-renderer";
|
||||
|
||||
import { BlankPage } from ".";
|
||||
|
||||
describe("<BlankPage />", () => {
|
||||
it("matches snapshot", () => {
|
||||
const tree = renderer.create(<BlankPage />).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<FatalError /> matches snapshot 1`] = `
|
||||
<div
|
||||
className="jumbotron text-center bg-primary my-4"
|
||||
>
|
||||
<div
|
||||
className="container-fluid"
|
||||
>
|
||||
<h1
|
||||
className="display-1 my-5 text-danger"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-exclamation-circle fa-w-16 "
|
||||
data-icon="exclamation-circle"
|
||||
data-prefix="fas"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
</h1>
|
||||
<p
|
||||
className="lead text-muted"
|
||||
>
|
||||
foo bar
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
27
ui/src/Components/Grid/FatalError/index.js
Normal file
27
ui/src/Components/Grid/FatalError/index.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { Component } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
|
||||
|
||||
class FatalError extends Component {
|
||||
static propTypes = {
|
||||
message: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
render() {
|
||||
const { message } = this.props;
|
||||
return (
|
||||
<div className="jumbotron text-center bg-primary my-4">
|
||||
<div className="container-fluid">
|
||||
<h1 className="display-1 my-5 text-danger">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} />
|
||||
</h1>
|
||||
<p className="lead text-muted">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { FatalError };
|
||||
11
ui/src/Components/Grid/FatalError/index.test.js
Normal file
11
ui/src/Components/Grid/FatalError/index.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import renderer from "react-test-renderer";
|
||||
|
||||
import { FatalError } from ".";
|
||||
|
||||
describe("<FatalError />", () => {
|
||||
it("matches snapshot", () => {
|
||||
const tree = renderer.create(<FatalError message="foo bar" />).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user