Commit be4104b3 authored by Max Reeves's avatar Max Reeves
Browse files

merge

parents 78705518 72d3da92
......@@ -4,4 +4,5 @@
/frontend/node_modules
/dashboards
/dashboards-data
/frontend/dist
\ No newline at end of file
/frontend/dist
/config
FROM golang:alpine
WORKDIR /go/src/meerkat
COPY . .
RUN go build
FROM node:lts
WORKDIR /root
COPY --from=0 /go/src/meerkat .
WORKDIR frontend
RUN npm install
RUN npm run prod
FROM alpine:latest
WORKDIR /meerkat
COPY --from=0 /go/src/meerkat .
COPY --from=1 /root/frontend frontend
VOLUME /meerkat/config
VOLUME /meerkat/dashboards
VOLUME /meerkat/dashboards-data
CMD ["/meerkat/meerkat", "-config", "/meerkat/config/meerkat.toml"]
HTTPAddr = "localhost:8585"
IcingaURL = "https://icinga.example.com:5665"
IcingaUsername = "api-user"
IcingaPassword = "api-password"
IcingaInsecureTLS = false
......@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"strings"
......@@ -17,14 +18,17 @@ import (
//Dashboard contains all information to render a dashboard
type Dashboard struct {
Title string `json:"title"`
Background string `json:"background"`
Tags []string `json:"tags"`
Checks []Check `json:"checks"`
Title string `json:"title"`
Slug string `json:"slug"`
Background string `json:"background"`
Tags []string `json:"tags"`
Elements []Element `json:"elements"`
}
//Check contains any service/host information needed
type Check struct {
//Element contains any service/host information needed
//This is an incomplete representation of the Element
//options arn't included
type Element struct {
Type string `json:"type"`
Title string `json:"title"`
Rect Rect `json:"rect"`
......@@ -58,6 +62,10 @@ func arrayContains(array []string, value string) bool {
return false
}
func slugFromFileName(fileName string) string {
return strings.TrimSuffix(fileName, filepath.Ext(fileName))
}
func handleListDashboards(w http.ResponseWriter, r *http.Request) {
files, err := ioutil.ReadDir("dashboards")
if err != nil {
......@@ -85,10 +93,10 @@ func handleListDashboards(w http.ResponseWriter, r *http.Request) {
continue
}
tags := dashboard.Tags
dashboard.Slug = slugFromFileName(f.Name())
if tagParam != "" {
if arrayContains(tags, tagParam) {
if arrayContains(dashboard.Tags, tagParam) {
dashboards = append(dashboards, dashboard)
}
} else {
......
version: '3.3'
services:
meerkat:
image: hub.sol1.net/meerkat:latest
container_name: meerkat
volumes:
- ./config:/meerkat/config
- ./dashboards:/meerkat/dashboards
- ./dashboards-data:/meerkat/dashboards-data
ports:
- 8585:8585
import { h, Fragment } from 'preact';
import { route } from 'preact-router';
import { useEffect, useReducer } from 'preact/hooks';
import { useEffect, useReducer, useState } from 'preact/hooks';
import * as meerkat from './meerkat'
import { routeParam, removeParam } from './util';
import { routeParam, removeParam, TagEditor } from './util';
import { CheckCard, CheckCardOptions } from './elements/card';
import { CheckSVG, CheckSVGOptions } from './elements/svg';
import { CheckSVG, CheckSVGOptions, CheckSVGDefaults } from './elements/svg';
import { CheckImage, CheckImageOptions } from './elements/image';
import { StaticText, StaticTextOptions } from './statics/text';
import { StaticSVG, StaticSVGOptions } from './statics/svg';
import { CheckLine, CheckLineOptions, CheckLineDefaults } from './elements/line';
import { StaticText, StaticTextOptions, StaticTextDefaults } from './statics/text';
import { StaticSVG, StaticSVGOptions, StaticSVGDefaults } from './statics/svg';
import { StaticImage, StaticImageOptions } from './statics/image';
//Manage dashboard state
......@@ -64,6 +65,8 @@ const dashboardReducer = (state, action) => {
//Edit page
export function Editor({slug, selectedElementId}) {
const [dashboard, dashboardDispatch] = useReducer(dashboardReducer, null);
const [savingDashboard, setSavingDashboard] = useState(false);
const [highlightedElementId, setHighlightedElementId] = useState(null);
useEffect(() => {
meerkat.getDashboard(slug).then(async d => {
......@@ -89,6 +92,7 @@ export function Editor({slug, selectedElementId}) {
}
const saveDashboard = async e => {
setSavingDashboard(true);
console.log(dashboard);
try {
const data = await meerkat.saveDashboard(slug, dashboard);
......@@ -99,30 +103,33 @@ export function Editor({slug, selectedElementId}) {
console.log('error saving dashboard:');
console.log(e);
}
setSavingDashboard(false);
}
return <Fragment>
<DashboardView dashboard={dashboard} dashboardDispatch={dashboardDispatch}
selectedElementId={selectedElementId ? Number(selectedElementId) : null} />
selectedElementId={selectedElementId ? Number(selectedElementId) : null}
highlightedElementId={highlightedElementId} />
<div class="editor">
<div class="options">
<h3>{dashboard.title}</h3>
<SidePanelSettings dashboard={dashboard} dashboardDispatch={dashboardDispatch} />
<hr />
<SidePanelElements dashboard={dashboard} dashboardDispatch={dashboardDispatch} />
<SidePanelElements dashboard={dashboard} dashboardDispatch={dashboardDispatch}
setHighlightedElementId={setHighlightedElementId} />
<ElementSettings selectedElement={selectedElement} updateElement={updateElement}/>
<ElementSettings selectedElement={selectedElement} updateElement={updateElement} />
</div>
</div>
<div class="side-bar-footer lefty-righty">
<button class="hollow" onClick={e => route('/')}>Home</button>
<button onClick={saveDashboard}>Save Dashboard</button>
<button onClick={saveDashboard} class={ savingDashboard ? 'loading' : ''}>Save Dashboard</button>
</div>
</Fragment>
}
function TransformableElement({rect, updateRect, children, glow}) {
function TransformableElement({rect, updateRect, rotation, updateRotation, children, glow, highlight}) {
//Handle dragging elements
const handleMove = downEvent => {
const mousemove = moveEvent => {
......@@ -175,9 +182,9 @@ function TransformableElement({rect, updateRect, children, glow}) {
let maxHeight = dashboardNode.clientHeight - elementNode.offsetTop;
//limit minimun resize
width = width < 100 ? 100 : width;
width = width < 40 ? 40 : width;
width = width < maxWidth ? width : maxWidth;
height = height < 50 ? 50 : height;
height = height < 40 ? 40 : height;
height = height < maxHeight ? height : maxHeight;
//convert dimensions to relative (px -> percentage based)
......@@ -199,20 +206,51 @@ function TransformableElement({rect, updateRect, children, glow}) {
window.addEventListener('mousemove', mousemove);
}
const handleRotate = downEvent => {
downEvent.stopPropagation();
const mousemove = moveEvent => {
//Go up an element due to rotate dot
const elementRect = downEvent.target.parentElement.getBoundingClientRect();
let centerX = elementRect.left + ((elementRect.right - elementRect.left) / 2.0);
let centerY = elementRect.top + ((elementRect.bottom - elementRect.top) / 2.0);
const mouseX = moveEvent.clientX;
const mouseY = moveEvent.clientY;
const radians = Math.atan2(mouseY - centerY, mouseX - centerX);
updateRotation(radians);
}
//Remove listeners on mouse button up
const mouseup = () => {
window.removeEventListener('mousemove', mousemove);
window.removeEventListener('mouseup', mouseup);
}
//Add movement and mouseup events
window.addEventListener('mouseup', mouseup);
window.addEventListener('mousemove', mousemove);
}
const left = `${rect.x}%`;
const top = `${rect.y}%`;
const width = `${rect.w}%`;
const height = `${rect.h}%`;
return <div class={`check ${glow ? 'glow' : ''}`}
style={{left: left, top: top, width: width, height: height}}
const _rotation = rotation ? `rotate(${rotation}rad)` : `rotate(0rad)`;
return <div class={`check ${glow || highlight ? 'glow' : ''}`}
style={{left: left, top: top, width: width, height: height, transform: _rotation}}
onMouseDown={handleMove}>
{children}
<div class="resize" onMouseDown={handleResize}></div>
<div class="rotate" onMouseDown={handleRotate}></div>
</div>
}
function DashboardElements({dashboardDispatch, selectedElementId, elements}) {
function DashboardElements({dashboardDispatch, selectedElementId, elements, highlightedElementId}) {
return elements.map((element, index) => {
const updateRect = rect => {
dashboardDispatch({
......@@ -225,29 +263,42 @@ function DashboardElements({dashboardDispatch, selectedElementId, elements}) {
});
}
const updateRotation = radian => {
dashboardDispatch({
type: 'updateElement',
elementIndex: index,
element: {
...element,
rotation: radian
}
});
}
let ele = null;
if(element.type === 'check-card') { ele = <CheckCard options={element.options} /> }
if(element.type === 'check-svg') { ele = <CheckSVG options={element.options}/> }
if(element.type === 'check-image') { ele = <CheckImage options={element.options}/> }
if(element.type === 'check-line') { ele = <CheckLine options={element.options} /> }
if(element.type === 'static-text') { ele = <StaticText options={element.options}/> }
if(element.type === 'static-svg') { ele = <StaticSVG options={element.options}/> }
if(element.type === 'static-image') { ele = <StaticImage options={element.options}/> }
return <TransformableElement rect={element.rect} updateRect={updateRect}
glow={selectedElementId === index}>
glow={selectedElementId === index} highlight={highlightedElementId === index}
updateRotation={updateRotation} rotation={element.rotation}>
{ele}
</TransformableElement>
});
}
//The actual dashboard being rendered
function DashboardView({dashboard, dashboardDispatch, selectedElementId}) {
function DashboardView({dashboard, dashboardDispatch, selectedElementId, highlightedElementId}) {
const backgroundImage = dashboard.background ? `url(${dashboard.background})` : 'none';
return <div class="dashboard-wrap">
<div class="dashboard" style={{backgroundImage: backgroundImage}}>
<DashboardElements elements={dashboard.elements} selectedElementId={selectedElementId}
dashboardDispatch={dashboardDispatch} />
dashboardDispatch={dashboardDispatch} highlightedElementId={highlightedElementId}/>
</div>
</div>
}
......@@ -272,26 +323,42 @@ function SidePanelSettings({dashboardDispatch, dashboard}) {
const updateTags = tags => {
dashboardDispatch({
type: 'setTags',
tags: tags.split(',').map(v => v.trim())
tags: tags.map(v => v.toLowerCase().trim())
});
}
const clearBackground = e => {
e.preventDefault();
dashboardDispatch({
type: 'setBackground',
background: null
});
}
const imgControls = src => {
if(src) {
return <Fragment>
<a onClick={clearBackground}>clear</a>&nbsp;
<a target="_blank" href={src}>view</a>
</Fragment>
}
return null;
}
return <Fragment>
<label for="title">Title</label>
<input type="text" id="title" placeholder="Network Overview" value={dashboard.title}
onInput={e => dashboardDispatch({type: 'setTitle', title: e.currentTarget.value})} />
<label for="tags">Tags</label>
<input id="tags" type="text" placeholder="Cool Tags" value={dashboard.tags.join(',')}
onInput={e => updateTags(e.currentTarget.value)} />
<TagEditor tags={dashboard.tags} updateTags={tags => updateTags(tags)} />
<label for="background-image">Background Image</label>
<label for="background-image">Background Image {imgControls(dashboard.background)}</label>
<input id="background-image" type="file" placeholder="Upload a background image"
accept="image/*" onChange={handleBackgroundImg}/>
</Fragment>
}
function SidePanelElements({dashboard, dashboardDispatch}) {
function SidePanelElements({dashboard, dashboardDispatch, setHighlightedElementId}) {
const addElement = e => {
const newId = dashboard.elements.length;
dashboardDispatch({type: 'addElement'});
......@@ -350,7 +417,7 @@ function SidePanelElements({dashboard, dashboardDispatch}) {
</Fragment>
}
export function ElementSettings({selectedElement, updateElement, updateTags}) {
export function ElementSettings({selectedElement, updateElement}) {
if(selectedElement === null) {
return null;
}
......@@ -360,10 +427,29 @@ export function ElementSettings({selectedElement, updateElement, updateTags}) {
updateElement({...selectedElement, options: newOptions})
}
//sets good default values for each visial type when they're selected
const updateType = e => {
const newType = e.currentTarget.value
let defaults = {};
switch(newType) {
case 'check-svg': defaults = CheckSVGDefaults; break;
case 'check-line': defaults = CheckLineDefaults; break;
case 'static-text': defaults = StaticTextDefaults; break;
case 'static-svg': defaults = StaticSVGDefaults; break;
}
updateElement({
...selectedElement,
type: newType,
options: Object.assign(selectedElement.options, defaults)
});
}
let ElementOptions = null;
if(selectedElement.type === 'check-card') { ElementOptions = <CheckCardOptions updateOptions={updateElementOptions} options={selectedElement.options} /> }
if(selectedElement.type === 'check-svg') { ElementOptions = <CheckSVGOptions updateOptions={updateElementOptions} options={selectedElement.options}/> }
if(selectedElement.type === 'check-image') { ElementOptions = <CheckImageOptions updateOptions={updateElementOptions} options={selectedElement.options}/> }
if(selectedElement.type === 'check-line') { ElementOptions = <CheckLineOptions updateOptions={updateElementOptions} options={selectedElement.options}/> }
if(selectedElement.type === 'static-text') { ElementOptions = <StaticTextOptions updateOptions={updateElementOptions} options={selectedElement.options} /> }
if(selectedElement.type === 'static-svg') { ElementOptions = <StaticSVGOptions updateOptions={updateElementOptions} options={selectedElement.options}/> }
if(selectedElement.type === 'static-image') { ElementOptions = <StaticImageOptions updateOptions={updateElementOptions} options={selectedElement.options}/> }
......@@ -382,11 +468,11 @@ export function ElementSettings({selectedElement, updateElement, updateTags}) {
onInput={e => updateElement({...selectedElement, title: e.currentTarget.value})} />
<label>Visual Type</label>
<select name="item-type" value={selectedElement.type}
onInput={e => updateElement({...selectedElement, type: e.currentTarget.value})}>
<select name="item-type" value={selectedElement.type} onInput={updateType}>
<option value="check-card">Icinga Card</option>
<option value="check-svg">Icinga SVG</option>
<option value="check-image">Icinga Image</option>
<option value="check-line">Icinga Line</option>
<option value="static-text">Static Text</option>
<option value="static-svg">Static SVG</option>
<option value="static-image">Static Image</option>
......
......@@ -12,24 +12,41 @@ export function CheckImageOptions({options, updateOptions}) {
updateOptions(opts);
}
const clearField = (e, field) => {
e.preventDefault();
let opts = {};
opts[field] = null;
updateOptions(opts);
}
const imgControls = (field) => {
if(options[field]) {
return <Fragment>
<a onClick={e => clearField(e, field)}>clear</a>&nbsp;
<a target="_blank" href={options[field]}>view</a>
</Fragment>
}
return null;
}
return <Fragment>
<label>Icinga Host or Service</label>
<IcingaCheckList checkId={options.checkId}
updateCheckId={checkId => updateOptions({checkId: checkId})} />
<label for="ok-image">OK State Image</label>
<label for="ok-image">OK State Image {imgControls('okImage')}</label>
<input id="ok-image" name="ok-image" type="file"
accept="image/*" onInput={e => handleImageUpload('okImage', e.target.files)}/>
<label for="warning-image">Warning State Image</label>
<label for="warning-image">Warning State Image {imgControls('warningImage')}</label>
<input id="warning-image" name="warning-image" type="file"
accept="image/*" onInput={e => handleImageUpload('warningImage', e.target.files)}/>
<label for="unknown-image">Unknown State Image</label>
<label for="unknown-image">Unknown State Image {imgControls('unknownImage')}</label>
<input id="unknown-image" name="unknown-image" type="file"
accept="image/*" onInput={e => handleImageUpload('unknownImage', e.target.files)}/>
<label for="critical-image">Critical State Image</label>
<label for="critical-image">Critical State Image {imgControls('criticalImage')}</label>
<input id="critical-image" name="critical-image" type="file"
accept="image/*" onInput={e => handleImageUpload('criticalImage', e.target.files)}/>
</Fragment>
......
import { h, Fragment, options } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import * as meerkat from '../meerkat';
import { icingaResultCodeToCheckState, icingaCheckTypeFromId, IcingaCheckList } from '../util'
export function CheckLineOptions({options, updateOptions}) {
return <div class="card-options">
<label>Icinga Host or Service</label>
<IcingaCheckList checkId={options.checkId}
updateCheckId={checkId => updateOptions({checkId: checkId})} />
<label for="stroke-width">Stroke width</label>
<input id="stroke-width" name="stroke-width" type="number" min="0" step="any" value={options.strokeWidth}
onInput={e => updateOptions({strokeWidth: Number(e.currentTarget.value)})}/>
<label>Render Arrows</label>
<div class="left spacer">
<input id="left-arrow" type="checkbox" checked={options.leftArrow}
onClick={e => updateOptions({leftArrow: e.currentTarget.checked})}/>
<label for="left-arrow" class="no-margin" style="font-weight: normal">Left</label>
</div>
<div class="left spacer">
<input id="right-arrow" type="checkbox" checked={options.rightArrow}
onClick={e => updateOptions({rightArrow: e.currentTarget.checked})}/>
<label for="right-arrow" class="no-margin" style="font-weight: normal">Right</label>
</div>
</div>
}
//The rendered view (in the actual dashboard) of the Check SVG
export function CheckLine({options}) {
const svgRef = useRef({clientWidth: 100, clientHeight: 40});
const [checkState, setCheckState] = useState(null);
//Handle state update
const updateState = async () => {
const checkType = icingaCheckTypeFromId(options.checkId);
const res = await meerkat.getIcingaCheckState(options.checkId, checkType);
const state = icingaResultCodeToCheckState(checkType, res.state);
setCheckState(state);
}
//Setup check refresher
useEffect(() => {
if(options.checkId !== null) {
updateState();
const intervalID = window.setInterval(updateState, 30*1000)
return () => window.clearInterval(intervalID);
}
}, [options.checkId]);
//SVG stroke color and icons to the correct version based
//on the current check state
let strokeColor = '';
if(checkState === 'ok' || checkState === 'up') {
strokeColor = `var(--color-icinga-green)`
}
if(checkState === 'warning') {
strokeColor = `var(--color-icinga-yellow)`
}
if(checkState === 'unknown') {
strokeColor = `var(--color-icinga-purple)`
}
if(checkState === 'critical' || checkState === 'down') {
strokeColor = `var(--color-icinga-red)`
}
return <div class="check-content svg" ref={svgRef}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox={`0 0 ${svgRef.current.clientWidth} ${svgRef.current.clientHeight}`} fill="none"
stroke={strokeColor} stroke-width={options.strokeWidth} stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1={svgRef.current.clientHeight / 2} x2={svgRef.current.clientWidth - 5} y2={svgRef.current.clientHeight / 2}></line>
{ options.leftArrow ? <polyline points={`30 5 5 ${svgRef.current.clientHeight / 2} 30 ${svgRef.current.clientHeight - 5}`}></polyline> : null }
{ options.rightArrow ? <polyline points={`${svgRef.current.clientWidth - 30} 5 ${svgRef.current.clientWidth - 5} ${svgRef.current.clientHeight / 2} ${svgRef.current.clientWidth - 30} ${svgRef.current.clientHeight - 5}`}></polyline> : null }
</svg>
</div>
}
export const CheckLineDefaults = {
strokeWidth: 4,
leftArrow: false,
rightArrow: true
}
\ No newline at end of file
......@@ -9,6 +9,13 @@ import { svgList } from '../svg-list'
export function CheckSVGOptions({options, updateOptions}) {
const svgOptions = svgList.map(svgName => <option value={svgName}>{svgName}</option>)
const clearField = (e, field) => {
e.preventDefault();
let opts = {};
opts[field] = null;
updateOptions(opts);
}
return <div class="card-options">
<label>Icinga Host or Service</label>
<IcingaCheckList checkId={options.checkId}
......@@ -19,7 +26,7 @@ export function CheckSVGOptions({options, updateOptions}) {
onInput={e => updateOptions({okSvg: e.currentTarget.value})}>
{svgOptions}
</select>
<label for="ok-stroke-color">OK Stroke color</label>
<label for="ok-stroke-color">OK Stroke color <a onClick={e => clearField(e, 'okStrokeColor')}>clear</a></label>
<div class="left spacer">
<input type="color" name="ok-stroke-color" id="ok-stroke-color" value={options.okStrokeColor}
onInput={e => updateOptions({okStrokeColor: e.currentTarget.value})}/>
......@@ -32,7 +39,7 @@ export function CheckSVGOptions({options, updateOptions}) {
onInput={e => updateOptions({warningSvg: e.currentTarget.value})}>
{svgOptions}
</select>
<label for="warning-stroke-color">Warning Stroke color</label>
<label for="warning-stroke-color">Warning Stroke color <a onClick={e => clearField(e, 'warningStrokeColor')}>clear</a></label>
<div class="left spacer">
<input type="color" name="warning-stroke-color" id="warning-stroke-color" value={options.warningStrokeColor}
onInput={e => updateOptions({warningStrokeColor: e.currentTarget.value})}/>
......@@ -45,7 +52,7 @@ export function CheckSVGOptions({options, updateOptions}) {
onInput={e => updateOptions({unknownSvg: e.currentTarget.value})}>
{svgOptions}
</select>
<label for="unknown-stroke-color">Unknown Stroke color</label>
<label for="unknown-stroke-color">Unknown Stroke color <a onClick={e => clearField(e, 'unknownStrokeColor')}>clear</a></label>
<div class="left spacer">
<input type="color" name="unknown-stroke-color" id="unknown-stroke-color" value={options.unknownStrokeColor}
onInput={e => updateOptions({unknownStrokeColor: e.currentTarget.value})}/>
......@@ -58,7 +65,7 @@ export function CheckSVGOptions({options, updateOptions}) {
onInput={e => updateOptions({criticalSvg: e.currentTarget.value})}>
{svgOptions}
</select>
<label for="critical-stroke-color">Critical Stroke color</label>
<label for="critical-stroke-color">Critical Stroke color <a onClick={e => clearField(e, 'criticalStrokeColor')}>clear</a></label>
<div class="left spacer">
<input type="color" name="critical-stroke-color" id="critical-stroke-color" value={options.criticalStrokeColor}
onInput={e => updateOptions({criticalStrokeColor: e.currentTarget.value})}/>
......@@ -114,4 +121,15 @@ export function CheckSVG({options}) {
<use xlinkHref={`/res/svgs/feather-sprite.svg#${svgName}`}/>
</svg>
</div>
}
export const CheckSVGDefaults = {
okSvg: 'check-circle',
okStrokeColor: '#44bb77',