feat(ui): new UI written in React

This commit is contained in:
Łukasz Mierzwa
2018-06-14 18:35:54 +02:00
parent c0fdb71276
commit a4a20ea3ef
125 changed files with 18015 additions and 15423 deletions

View File

@@ -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

View File

@@ -1 +0,0 @@
.gitignore

11
.dockerignore Normal file
View 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
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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())
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,6 +0,0 @@
class MockFavicon {
badge() {
}
}
module.exports = MockFavicon;

View File

@@ -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)
})

View File

@@ -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();

View File

@@ -1 +0,0 @@
module.exports = {};

View File

@@ -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;

View File

@@ -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

View File

@@ -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>"`;

View File

@@ -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;

View File

@@ -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"
});
});

View File

@@ -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;

View File

@@ -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" ])
);
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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); "
);
});

View File

@@ -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;

View File

@@ -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"
});
});

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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" ]
);
});

View File

@@ -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;

View File

@@ -1,5 +0,0 @@
const grid = require("./grid");
test("grid hide()", () => {
grid.hide();
});

View File

@@ -1,7 +0,0 @@
"use strict";
require("bootstrap-loader");
require("font-awesome-webpack");
require("./favicon.ico");
require("./base.css");

View File

@@ -1,4 +0,0 @@
test("help imports", () => {
window.jQuery = require("jquery");
require("./help");
});

View File

@@ -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);

View File

@@ -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;

View File

@@ -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")
});

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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"});
*/
});

View File

@@ -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");
}
}

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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("");
});

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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");
});

View File

@@ -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);
});

View File

@@ -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();
});
}

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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);
});

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" }}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View File

@@ -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)

View File

@@ -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"
}
}

2
ui/.env Normal file
View File

@@ -0,0 +1,2 @@
NODE_PATH=src
PUBLIC_URL=.

File diff suppressed because it is too large Load Diff

49
ui/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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
View 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
View 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

File diff suppressed because it is too large Load Diff

34
ui/src/App.js Normal file
View 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
View 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
View 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
View 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 };

View 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 };

View File

@@ -0,0 +1,4 @@
.list-group-item.components-grid-alertgrid-alertgroup-alert {
border-width: 3px;
line-height: 1rem;
}

View 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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -0,0 +1,8 @@
.progress.silence-progress {
height: 2px;
margin-top: 2px;
}
.cite.components-grid-alertgroup-silences {
font-size: 100%;
}

View 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 };

View 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;
}

View 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 };

View 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 };

View 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%;
}
}

View 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 };

View File

@@ -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>
`;

View 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 };

View 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();
});
});

View File

@@ -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>
`;

View 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 };

View 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