;
74 | }
75 |
76 | class Calendar extends PureComponent {
77 | props: CalendarProps;
78 | state = {
79 | flipped: false,
80 | };
81 |
82 | constructor(props: CalendarProps) {
83 | super(props);
84 | this.props.fetchStats();
85 | }
86 |
87 | render() {
88 | const buildDomain = (data, slo) => {
89 | const values = data && data.length > 0 && data.map(x => x.percent) || [0, 1];
90 | const [min, max] = [Math.min(...values), Math.max(...values)];
91 | return data && data.length > 1 && min !== max
92 | ? min > slo * 0.9 ? [slo * 0.9, 1] : ['auto', 1]
93 | : [0, 1];
94 | };
95 |
96 | const {isOpen, selectedDay, onDayClick, onCloseClick, stats, slo} = this.props;
97 | const {flipped} = this.state;
98 |
99 | const data = stats && Object.keys(stats.daily)
100 | .map(key => ({date: Number(key), percent: stats.daily[Number(key)]}))
101 | .sort((a, b) => a.date - b.date);
102 |
103 | const wrapperClass = classNames({
104 | 'calendar-chart-wrapper': true,
105 | closed: !isOpen,
106 | flipped: flipped,
107 | });
108 |
109 | const flipperClass = classNames({
110 | flipper: true,
111 | flipped: flipped,
112 | });
113 |
114 | const flipViewButtonClass = classNames({
115 | 'flip-view-button': true,
116 | chart: !flipped,
117 | calendar: flipped,
118 | });
119 |
120 | return (
121 |
122 |
123 |
132 | { data && data.length > 0 &&
133 | (
134 |
135 |
136 |
138 | }/>
139 | '' }
140 | startIndex={Math.max(0, data.length - 31)}
141 | endIndex={Math.max(0, data.length - 1)}
142 | travellerWidth={10}/>
143 | }/>
144 |
145 |
146 |
147 |
148 |
149 | )
150 | }
151 |
152 |
153 |
154 | { data && data.length > 0 &&
155 | (
)
156 | }
157 |
158 | );
159 | }
160 |
161 | formatPercentage = (val: number): string => numeral(val).format(val < 1 ? '0.00%' : '0%');
162 |
163 | getDateInfo(stats, timestamp, slo): [string, string] {
164 | const percentage = stats && stats[timestamp];
165 | const percentageFormatted = !stats || isNaN(percentage)
166 | ? 'N/A'
167 | : this.formatPercentage(percentage);
168 | const dateClass = classNames({
169 | 'data-available': percentage && !isNaN(percentage),
170 | 'data-unavailable': !percentage || isNaN(percentage),
171 | good: percentage && percentage >= slo,
172 | bad: percentage && percentage < slo
173 | });
174 | return [percentageFormatted, dateClass];
175 | }
176 |
177 | renderDay = (stats: Stats, slo: number) => ( props: DatetimeProps, currentDate: moment) => {
178 | const timestamp = currentDate.valueOf();
179 | const [percentageFormatted, dateClass] = this.getDateInfo(stats, timestamp, slo);
180 |
181 | props.className += ` ${dateClass}`;
182 | return
183 | { numeral(currentDate.date()).format('00') }
184 | { percentageFormatted }
185 | | ;
186 | };
187 |
188 | renderMonth = (stats: Stats, slo: number) => ( props: DatetimeProps, month: number, year: number) => {
189 | const timestamp = moment().date(1).month(month).year(year).startOf('day').valueOf();
190 | const [percentageFormatted, dateClass] = this.getDateInfo(stats, timestamp, slo);
191 |
192 | const localMoment = moment();
193 | const shortMonthName = localMoment.localeData().monthsShort(localMoment.month(month)).substring(0, 3);
194 |
195 | props.className += ` ${dateClass}`;
196 | return
197 | { shortMonthName }
198 | { percentageFormatted }
199 | | ;
200 | };
201 |
202 | renderYear = (stats: Stats, slo: number) => ( props: DatetimeProps, year: number) => {
203 | const timestamp = moment().date(1).month(0).year(year).startOf('day').valueOf();
204 | const [percentageFormatted, dateClass] = this.getDateInfo(stats, timestamp, slo);
205 |
206 | props.className += ` ${dateClass}`;
207 | return
208 | { year }
209 | { percentageFormatted }
210 | | ;
211 | };
212 |
213 | validateDate = (currentDate: moment) => {
214 | const tomorrow = moment().startOf('day').add(1, 'day');
215 | return currentDate.isBefore(tomorrow);
216 | };
217 |
218 | onFlipView = () => this.setState({flipped: !this.state.flipped});
219 |
220 | xAxisTickFormatter = (val) => moment(val).format(fullDateFormat);
221 |
222 | yAxisTickFormatter = (val) => numeral(val).format('0%');
223 | }
224 |
225 | const select = (state: State) => ({
226 | stats: state.stats.data,
227 | isLoading: state.stats.isLoading,
228 | error: state.stats.error,
229 | boardName: state.currentBoard,
230 | });
231 |
232 | const actions = (dispatch) => ({
233 | fetchStats: function() {
234 | const props = this;
235 | return dispatch(fetchStats(props.boardName));
236 | }
237 | });
238 |
239 | export default connect(select, actions)(Calendar);
240 |
--------------------------------------------------------------------------------
/src/main/webapp/js/components/CheckList.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import marked from 'marked';
3 | import type { Check } from '../state';
4 |
5 | type Props = {
6 | checks: Array
7 | };
8 |
9 | const CheckList = ({ checks }: Props) => (
10 |
11 | {checks.map(({ title, status, message }: Check) => (
12 |
19 | ))}
20 |
21 | );
22 |
23 | export default CheckList;
24 |
--------------------------------------------------------------------------------
/src/main/webapp/js/components/ErrorPage.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | type Props = {
3 | message: string
4 | };
5 | const ErrorPage = ({ message }: Props) => (
6 |
7 | {byMessage(message)}
8 |
9 | );
10 |
11 | const byMessage = (message: string) => {
12 | if (!message)
13 | return Unknown error
;
14 | else if (message.includes('is not ready'))
15 | return (
16 |
17 |
{message}
18 |
check this page for explanations
19 |
20 | );
21 | else if (message.includes('does not exist'))
22 | return (
23 |
27 | );
28 | else
29 | return {message}
;
30 | };
31 |
32 | export default ErrorPage;
--------------------------------------------------------------------------------
/src/main/webapp/js/components/Graph.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import { Component } from 'react';
3 | import { findDOMNode } from 'react-dom';
4 | import Box from './Box';
5 | import Timeline from './Timeline';
6 |
7 | import type { BoardView } from '../state';
8 |
9 | type Props = {
10 | board: BoardView
11 | };
12 |
13 | type State = {
14 | hoveredBoxTitle: string
15 | };
16 |
17 | class Graph extends Component {
18 | props: Props;
19 | state: State;
20 | constructor(props: Props) {
21 | super(props);
22 | this.state = {
23 | hoveredBoxTitle: ''
24 | };
25 | }
26 |
27 | render() {
28 | const { board } = this.props;
29 | return (
30 |
31 |
32 | {board.title}
33 | {board.message}
34 |
35 |
36 |
37 | {
38 | board.columns.map((col, i) => (
39 |
40 | {
41 | col.rows.map(row => (
42 |
43 | {row.title}
44 | {
45 | row.boxes.map(box => (
46 | this.setState({ hoveredBoxTitle: box.title })}
51 | onMouseLeave={() => this.setState({ hoveredBoxTitle: '' })}
52 | />
53 | ))
54 | }
55 |
56 | ))
57 | }
58 |
59 | ))
60 | }
61 |
62 |
67 |
68 | );
69 | }
70 |
71 | componentDidMount() {
72 | this.drawLines();
73 | window.addEventListener('resize', this.drawLines);
74 | }
75 |
76 | componentDidUpdate() {
77 | this.drawLines();
78 | }
79 |
80 | componentWillUnmount() {
81 | window.removeEventListener('resize', this.drawLines);
82 | }
83 |
84 | drawLines = () => {
85 | let lines = findDOMNode(this.refs.lines);
86 | const { board } = this.props;
87 | let hoveredTitle = this.state.hoveredBoxTitle;
88 | if(lines && board) {
89 | document.title = board.title;
90 | lines.innerHTML = board.links
91 | .map(([from, to]) => {
92 | return [from, to, findDOMNode(this.refs[from]), findDOMNode(this.refs[to])];
93 | })
94 | .filter(([_from, _to, fromNode, toNode]) => toNode && fromNode)
95 | .reduce((html, [from, to, fromNode, toNode]) => {
96 | let extraClass = '';
97 | if (hoveredTitle === from || hoveredTitle === to) {
98 | extraClass = ' hoveredPath';
99 | }
100 | return html + `
101 |
102 |
103 |
104 |
105 | `;
106 | }, '');
107 | }
108 | }
109 | }
110 |
111 | export default Graph;
112 |
113 | const getBox = (el: HTMLElement): { x: number, y: number } => {
114 | let rect = el.getBoundingClientRect();
115 | return {
116 | x: rect.left + (rect.right - rect.left) / 2,
117 | y: rect.top + (rect.bottom - rect.top) / 2
118 | };
119 | };
120 |
121 | const spline = (from: HTMLElement, to: HTMLElement): string => {
122 | let fromBox = getBox(from), toBox = getBox(to);
123 | let a, b;
124 | // Exact comparison can mess up during a hover animation.
125 | if (Math.abs(fromBox.x - toBox.x) < 5) {
126 | // Here we prefer to draw the line bottom to top because it makes the arc
127 | // look closer to left -> right.
128 | a = fromBox.y > toBox.y ? fromBox : toBox, b = fromBox.y > toBox.y ? toBox : fromBox;
129 | } else {
130 | a = fromBox.x < toBox.x ? fromBox : toBox, b = fromBox.x < toBox.x ? toBox : fromBox;
131 | }
132 | let n = Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)) * .75;
133 | return `M${a.x},${a.y} C${a.x + n},${a.y} ${b.x - n},${b.y} ${b.x},${b.y}`;
134 | };
135 |
--------------------------------------------------------------------------------
/src/main/webapp/js/components/StatusFavicon.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import { PureComponent } from 'react';
3 | import type { Status } from '../state';
4 | import { statusToColor } from '../utils';
5 |
6 | type Props = {
7 | status: Status
8 | };
9 |
10 | const Radius = 32;
11 |
12 | class StatusFavicon extends PureComponent {
13 | props: Props;
14 |
15 | canvas = null;
16 |
17 | render() {
18 | return null;
19 | }
20 |
21 | componentDidUpdate() {
22 | this.update();
23 | }
24 |
25 | componentDidMount() {
26 | this.update();
27 | }
28 |
29 | componentWillUnmount() {
30 | const favicon = document.querySelector('#favicon');
31 | if (favicon instanceof HTMLLinkElement) {
32 | favicon.href = '';
33 | }
34 | }
35 |
36 | update() {
37 | let { canvas, props: { status } } = this;
38 | if (canvas === null) {
39 | this.canvas = canvas = document.createElement('canvas');
40 | canvas.width = 2 * Radius;
41 | canvas.height = 2 * Radius;
42 | }
43 | const ctx = canvas.getContext('2d');
44 | if (ctx) {
45 | ctx.beginPath();
46 | ctx.arc(Radius, Radius, Radius / 2, 0, 2 * Math.PI);
47 | ctx.fillStyle = statusToColor(status);
48 | ctx.fill();
49 | }
50 | const favicon = document.querySelector('#favicon');
51 | if (favicon instanceof HTMLLinkElement) {
52 | favicon.href = canvas.toDataURL('image/x-icon');
53 | }
54 | }
55 | }
56 |
57 | export default StatusFavicon;
--------------------------------------------------------------------------------
/src/main/webapp/js/components/Timeline.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import { Component } from 'react';
3 | import { findDOMNode, render } from 'react-dom';
4 | import { connect } from 'react-redux';
5 | import vis from 'vis';
6 | import moment from 'moment';
7 | import type { State } from '../state';
8 | import { navigateToSnapshot, navigateToLiveBoard } from '../actions';
9 | import Controller from './TimelineController';
10 |
11 | type Props = {
12 | history: any,
13 | error: ?string,
14 | isLoading: boolean,
15 | isLiveMode: boolean,
16 | navigateToSnapshot: (timestamp: number) => void,
17 | navigateToLiveBoard: () => void,
18 | selectedDate: ?string,
19 | selectedTimestamp: ?number
20 | };
21 |
22 | class Timeline extends Component {
23 | props: Props;
24 | state: {
25 | hasFocus: boolean
26 | };
27 |
28 | timeline: any;
29 | dataset: any;
30 | static DATE_FORMAT = 'YYYY-MM-DD HH:mm';
31 |
32 | constructor(props: Props) {
33 | super(props);
34 | this.timeline = null;
35 | this.dataset = null;
36 | this.state = {
37 | hasFocus: false
38 | };
39 | }
40 |
41 | render() {
42 | const { history } = this.props;
43 | return (
44 | this.setState({ hasFocus: true })}
47 | onMouseLeave={() => this.setState({ hasFocus: false })}
48 | >
49 |
50 | {
51 | history && (
52 |
55 | )
56 | }
57 |
58 | );
59 | }
60 |
61 | componentDidMount() {
62 | this.updateAndRender();
63 | }
64 |
65 | shouldComponentUpdate(nextProps, nextState) {
66 | // No update: go back to a past timepoint
67 | if (this.props.isLiveMode === true && nextProps.isLiveMode === false) {
68 | return false;
69 | }
70 | // No update: focus state changes
71 | if (this.state.hasFocus !== nextState.hasFocus)
72 | return false;
73 | return true;
74 | }
75 |
76 | componentDidUpdate(prevProps) {
77 | const { isLiveMode, isLoading } = this.props;
78 | const { hasFocus } = this.state;
79 | // Switch from past to live mode
80 | if (prevProps.isLiveMode === false && isLiveMode === true) {
81 | this.timeline && this.timeline.setSelection([]);
82 | return;
83 | }
84 | // Do nothing if the user is focusing on the timeline
85 | if (hasFocus && (prevProps.isLoading === isLoading))
86 | return;
87 |
88 | if (!isLoading) {
89 | this.updateAndRender();
90 | }
91 | }
92 |
93 | updateAndRender() {
94 | this.updateDataset();
95 | this.renderTimeline();
96 | }
97 |
98 | updateDataset() {
99 | this.dataset = Object.entries(this.props.history)
100 | .filter(
101 | ([_, status]: [string, any]) =>
102 | status === 'ERROR' || status === 'WARNING'
103 | )
104 | .map(([ts, status]: [string, any], i) => {
105 | const date = moment(parseInt(ts));
106 | return {
107 | date,
108 | id: i,
109 | content: '',
110 | start: date.format(Timeline.DATE_FORMAT),
111 | title: date.format(Timeline.DATE_FORMAT),
112 | className: `${status} background`
113 | };
114 | })
115 | .sort((a, b) => a.start.localeCompare(b.start));
116 | }
117 |
118 | renderTimeline() {
119 | const node = findDOMNode(this);
120 | const container = node.querySelector('#container');
121 | const { history, selectedTimestamp, selectedDate } = this.props;
122 | // if selectedDate is not defined, defaults to last 24 hours
123 | const start = selectedDate ? moment(selectedDate) : moment().subtract(24, 'hour');
124 | const end = selectedDate ? moment(selectedDate).add(24, 'hour') : moment();
125 | if (!this.timeline) {
126 | const timeline = new vis.Timeline(container, this.dataset, {
127 | height: 75,
128 | type: 'point',
129 | stack: false,
130 | zoomMin: 60 * 1000,
131 | start,
132 | end
133 | });
134 | timeline.on('select', ({ items }) => {
135 | if (items.length > 0) {
136 | const entry = this.dataset.find(_ => _.id === items[0]);
137 | const timestamp = entry && entry.date.valueOf();
138 | timestamp && this.props.navigateToSnapshot(timestamp);
139 | } else {
140 | this.props.navigateToLiveBoard();
141 | }
142 | });
143 | this.timeline = timeline;
144 | } else {
145 | this.timeline.setWindow(start.valueOf(), end.valueOf());
146 | this.timeline.setItems(this.dataset);
147 | }
148 |
149 | if (this.dataset && this.dataset.length == 0) {
150 | render(
151 |
152 | {
153 | Object.keys(history).length > 0 ?
154 |
No warnings or errors in this period
:
155 |
No data available
156 | }
157 |
,
158 | container.querySelector('#message')
159 | );
160 | } else {
161 | render(
162 | ,
163 | container.querySelector('#message')
164 | );
165 | }
166 | if (selectedTimestamp) {
167 | const id = this.dataset.find(_ => _.date.valueOf() === selectedTimestamp);
168 | id && this.timeline.setSelection([id.id]);
169 | }
170 | }
171 | }
172 |
173 | const select = (state: State) => ({
174 | history: state.history.data,
175 | selectedDate: state.history.date,
176 | error: state.history.error,
177 | isLoading: state.history.isLoading,
178 | isLiveMode: state.isLiveMode,
179 | selectedTimestamp: state.selectedTimestamp,
180 | board: state.currentBoard
181 | });
182 |
183 | const actions = (dispatch) => ({
184 | navigateToSnapshot: function(timestamp) {
185 | const props = this;
186 | dispatch(navigateToSnapshot(props.board, timestamp));
187 | },
188 | navigateToLiveBoard: function() {
189 | const props = this;
190 | dispatch(navigateToLiveBoard(props.board));
191 | }
192 | });
193 |
194 | export default connect(select, actions)(Timeline);
195 |
--------------------------------------------------------------------------------
/src/main/webapp/js/components/TimelineController.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import { PureComponent } from 'react';
3 | import { connect } from 'react-redux';
4 | import moment from 'moment';
5 |
6 | import type { State } from '../state';
7 |
8 | import { fetchHistory, navigateToLiveBoard } from '../actions';
9 | import Calendar from './Calendar';
10 | import { Button } from '../lib';
11 |
12 | type Props = {
13 | boardName: string,
14 | date: ?string,
15 | isLiveMode: boolean,
16 | isLoading: boolean,
17 | error: ?string,
18 | selectedTimestamp: ?number,
19 | navigateToLiveBoard: () => void,
20 | fetchHistory: (date: ?string) => void,
21 | isLoadingBoard: boolean,
22 | slo: number,
23 | };
24 |
25 | class TimelineController extends PureComponent {
26 | props: Props;
27 | state: {
28 | isCalendarOpen: boolean,
29 | selectedDay: Date
30 | };
31 |
32 | constructor(props: Props) {
33 | super(props);
34 | this.state = {
35 | isCalendarOpen: false,
36 | selectedDay: new Date()
37 | };
38 | }
39 |
40 | render() {
41 | const {
42 | date,
43 | isLiveMode,
44 | selectedTimestamp,
45 | isLoading,
46 | error,
47 | isLoadingBoard,
48 | slo
49 | } = this.props;
50 | const { isCalendarOpen, selectedDay } = this.state;
51 | return (
52 |
53 |
54 | {date ? date : 'Last 24 hours'}
55 |
61 | {date &&
62 | }
65 | {isLoading && }
66 | {error}
67 |
68 |
69 | {isLiveMode
70 | ? 'LIVE'
71 | : `SNAPSHOT ${moment(selectedTimestamp).format('YYYY-MM-DD HH:mm')}`}
72 | {!isLiveMode && }
73 |
74 | {
75 | isLoadingBoard &&
76 | }
77 |
84 |
85 | );
86 | }
87 |
88 | handleDayClick = selectedDay => {
89 | const { selectedDay: prevDay } = this.state;
90 | this.setState({ selectedDay, isCalendarOpen: false }, () => {
91 | if (prevDay - selectedDay !== 0)
92 | this.props.fetchHistory(moment(this.state.selectedDay).format('YYYY-MM-DD'));
93 | });
94 | };
95 |
96 | handleLast24HClick = () => {
97 | this.props.fetchHistory();
98 | this.setState({ selectedDay: new Date(), isCalendarOpen: false });
99 | };
100 |
101 | hanldeResetClick = () => {
102 | this.props.navigateToLiveBoard();
103 | };
104 |
105 | handleCloseClick = () => {
106 | this.setState({ isCalendarOpen: false });
107 | };
108 | }
109 |
110 | const select = (state: State) => ({
111 | date: state.history.date,
112 | isLiveMode: state.isLiveMode,
113 | selectedTimestamp: state.selectedTimestamp,
114 | isLoading: state.history.isLoading,
115 | error: state.history.error,
116 | boardName: state.currentBoard,
117 | isLoadingBoard: state.selectedBoardView.isLoading,
118 | slo: state.selectedBoardView.data && state.selectedBoardView.data.slo
119 | });
120 |
121 | const actions = dispatch => ({
122 | navigateToLiveBoard: function() {
123 | const props = this;
124 | return dispatch(navigateToLiveBoard(props.boardName));
125 | },
126 | fetchHistory: function(date) {
127 | const props = this;
128 | return dispatch(fetchHistory(props.boardName, date));
129 | }
130 | });
131 |
132 | export default connect(select, actions)(TimelineController);
133 |
--------------------------------------------------------------------------------
/src/main/webapp/js/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from 'react-dom';
3 | import configureStore from './store';
4 | import App from './components/App';
5 | import { Provider } from 'react-redux';
6 |
7 | const store = configureStore();
8 |
9 | const mount = Component => render(
10 |
11 |
12 | ,
13 | document.getElementById('app')
14 | );
15 |
16 | mount(App);
17 |
18 | if (module.hot) {
19 | module.hot.accept('./components/App',() => {
20 | mount(require('./components/App').default);
21 | return true;
22 | });
23 | }
--------------------------------------------------------------------------------
/src/main/webapp/js/lib/Button.js:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | export const Button = ({ children, onClick }: any) => (
4 |
7 | );
--------------------------------------------------------------------------------
/src/main/webapp/js/lib/index.js:
--------------------------------------------------------------------------------
1 | export * from './Button';
--------------------------------------------------------------------------------
/src/main/webapp/js/router.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import { createRouter } from 'redux-url';
3 | import { createBrowserHistory } from 'history';
4 |
5 | import type { Action } from './actions';
6 | const routes = {
7 | '/': 'GOTO_BOARDS',
8 | '/:board': ({ board }): Action => ({
9 | type: 'GOTO_LIVE_BOARD',
10 | board
11 | }),
12 | '/:board/snapshot/:timestamp': ({ board, timestamp }): Action => ({
13 | type: 'GOTO_SNAPSHOT',
14 | board,
15 | timestamp: parseInt(timestamp)
16 | }),
17 | '*': 'NOT_FOUND'
18 | };
19 |
20 | const router = createRouter(routes, createBrowserHistory());
21 |
22 | export default router;
--------------------------------------------------------------------------------
/src/main/webapp/js/sagas/index.js:
--------------------------------------------------------------------------------
1 | import { takeLatest, call, put, fork, select } from 'redux-saga/effects';
2 | import { delay } from 'redux-saga';
3 | import { navigate } from 'redux-url';
4 | import moment from 'moment';
5 | import * as api from '../api';
6 | import { combineViewAndLayout, aggregateStatsByDay, aggregateStatsByMonth, aggregateStatsByYear } from '../utils';
7 | import { setPollingInterval } from '../actions';
8 |
9 | // fetch the current view of the board
10 | export function* fetchBoard(action, transformer = combineViewAndLayout) {
11 | try {
12 | const boards = yield call(fetchBoards);
13 | const boardView = yield call(api.fetchBoard, action.board);
14 | const config = boards.find(_ => _.title === boardView.title);
15 | const { layout, links, slo } = config;
16 | yield put({ type: 'FETCH_BOARD_SUCCESS', payload: transformer(boardView, layout, links, slo) });
17 | } catch (error) {
18 | yield put({ type: 'FETCH_BOARD_FAILURE', payload: error });
19 | }
20 | }
21 |
22 | // from route change
23 | export function* watchLiveBoardChange() {
24 | yield takeLatest('GOTO_LIVE_BOARD', fetchBoard);
25 | }
26 |
27 | // fetch boards
28 | export function* fetchBoards() {
29 | try {
30 | const cached = yield select(state => state.boards);
31 | if (cached && cached.length > 0)
32 | return cached;
33 | const boards = yield call(api.fetchBoards);
34 | yield put({ type: 'FETCH_BOARDS_SUCCESS', payload: boards });
35 | return boards;
36 | } catch (error) {
37 | yield put({ type: 'FETCH_BOARDS_FAILURE', payload: error });
38 | }
39 | }
40 |
41 | export function* watchFetchBoards() {
42 | yield takeLatest('FETCH_BOARDS', fetchBoards);
43 | }
44 |
45 | // fetch history
46 | export function* fetchHistory(action) {
47 | try {
48 | const history = yield call(
49 | action.date ? api.fetchHistoryOfDay : api.fetchHistory,
50 | action.board,
51 | action.date
52 | );
53 | yield put({ type: 'FETCH_HISTORY_SUCCESS', payload: history });
54 | } catch (error) {
55 | yield put({ type: 'FETCH_HISTORY_FAILURE', payload: error });
56 | }
57 | }
58 |
59 | export function* watchFetchHistory() {
60 | yield takeLatest('FETCH_HISTORY', fetchHistory);
61 | }
62 |
63 | // fetch snapshot
64 | export function* fetchSnapshot(action, transformer = combineViewAndLayout) {
65 | try {
66 | if (action.isLiveMode)
67 | return;
68 | const boards = yield call(fetchBoards);
69 | const currentBoard = yield select(state => state.currentBoard);
70 | const snapshot = yield call(api.fetchSnapshot, currentBoard, action.timestamp);
71 | const config = boards.find(_ => _.title === snapshot.title);
72 | const { layout, links } = config;
73 | yield put({ type: 'FETCH_SNAPSHOT_SUCCESS', payload: transformer(snapshot, layout, links) });
74 | } catch (error) {
75 | yield put({ type: 'FETCH_SNAPSHOT_FAILURE', payload: error });
76 | }
77 | }
78 |
79 | // from route change
80 | export function* watchSnapshotChange() {
81 | yield takeLatest('GOTO_SNAPSHOT', handleSnapshotChange);
82 | }
83 |
84 | export function* handleSnapshotChange(action) {
85 | // if history is not available, fetch it
86 | const history = yield select(state => state.history.data);
87 | if (!history) {
88 | const datetime = moment(action.timestamp);
89 | const date = moment().isSame(datetime, 'day') ? null : datetime.format('YYYY-MM-DD');
90 | yield put({ type: 'FETCH_HISTORY', board: action.board, date });
91 | }
92 | yield call(fetchSnapshot, action);
93 | }
94 |
95 | // fetch stats
96 | export function* fetchStats(action) {
97 | try {
98 | const hourlyStats = yield call(api.fetchStats, action.board);
99 | const dailyStats = aggregateStatsByDay(hourlyStats);
100 | const monthlyStats = aggregateStatsByMonth(hourlyStats);
101 | const yearlyStats = aggregateStatsByYear(hourlyStats);
102 |
103 | const stats = { daily: dailyStats, monthly: monthlyStats, yearly: yearlyStats };
104 |
105 | yield put({ type: 'FETCH_STATS_SUCCESS', payload: stats });
106 | } catch (error) {
107 | yield put({ type: 'FETCH_HISTORY_FAILURE', payload: error });
108 | }
109 | }
110 |
111 | export function* watchFetchStats() {
112 | yield takeLatest('FETCH_STATS', handleFetchStats);
113 | }
114 |
115 | export function* handleFetchStats(action) {
116 | const stats = yield select(state => state.stats.data);
117 | if (!stats || !stats[action.startDate]) {
118 | yield call(fetchStats, action);
119 | }
120 | }
121 |
122 | // polling service
123 | export function* poll(init = false) {
124 | const { interval, isLiveMode, date } = yield select(state => ({
125 | interval: state.pollingIntervalSeconds,
126 | isLiveMode: state.isLiveMode,
127 | date: state.history.date
128 | }));
129 | if (interval > 0) {
130 | const route = yield select(state => state.route);
131 | if (route.path === 'BOARD' && route.board && isLiveMode) {
132 | if (!init) // do not call it in initialization
133 | yield fork(fetchBoard, { type: 'FETCH_BOARD', board: route.board });
134 | if (!date) // polling history only in last 24 hours mode
135 | yield fork(fetchHistory, { type: 'FETCH_HISTORY', board: route.board });
136 | }
137 | yield delay(interval * 1000);
138 | yield call(poll);
139 | }
140 | }
141 |
142 | export function* watchPollingIntervalChange() {
143 | yield takeLatest('SET_POLLING_INTERVAL', poll, true);
144 | }
145 |
146 | // root
147 | export default function* rootSaga() {
148 | // watchers
149 | yield fork(watchFetchBoards);
150 | yield fork(watchFetchHistory);
151 | yield fork(watchFetchStats);
152 | yield fork(watchSnapshotChange);
153 | yield fork(watchLiveBoardChange);
154 | yield fork(watchPollingIntervalChange);
155 |
156 | // initial setup
157 | yield put(navigate(location.pathname, true));
158 | yield put(setPollingInterval(60));
159 | }
160 |
--------------------------------------------------------------------------------
/src/main/webapp/js/state.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import type { Action } from './actions';
3 |
4 | export type Check = {
5 | title: string,
6 | status: Status,
7 | message: string,
8 | label: ?string
9 | };
10 |
11 | export type Box = {
12 | title: string,
13 | status: Status,
14 | message: string,
15 | description: ?string,
16 | labelLimit: number,
17 | checks: Array
18 | };
19 |
20 | export type Row = {
21 | title: string,
22 | percentage: number,
23 | boxes: Array
24 | };
25 |
26 | export type Column = {
27 | percentage: number,
28 | rows: Array
29 | };
30 |
31 | export type BoardView = {
32 | title: string,
33 | status: Status,
34 | message: string,
35 | columns: Array,
36 | links: Array,
37 | slo: number
38 | };
39 |
40 | export type Layout = {
41 | columns: Array
42 | };
43 |
44 | export type BoardConfig = {
45 | title: string,
46 | layout: Layout,
47 | links: Array
48 | };
49 |
50 | export type Stats = {
51 | [k: ?number]: number
52 | };
53 |
54 | export type StatsGroup = {
55 | daily: Stats,
56 | monthly: Stats,
57 | yearly: Stats
58 | };
59 |
60 | export type Route = {
61 | path: string,
62 | [k: ?string]: any
63 | };
64 |
65 | export type Link = [string, string];
66 |
67 | export type Status = 'Unknown' | 'Success' | 'Error' | 'Warning';
68 |
69 | export type State = {
70 | currentBoard: ?string,
71 | route: Route,
72 | boards: Array,
73 | isLiveMode: boolean, // in live mode, polling the server
74 | history: {
75 | isLoading: boolean,
76 | data: any,
77 | error: ?string,
78 | date: ?string // if date is not specified, history comes from last 24 hours
79 | },
80 | liveBoardView: { // board view to be updated in live mode
81 | isLoading: boolean,
82 | data: ?BoardView,
83 | error: ?string
84 | },
85 | selectedBoardView: { // board view to be displayed
86 | isLoading: boolean,
87 | data: ?BoardView,
88 | error: ?string
89 | },
90 | selectedTimestamp: ?number, // timestamp of the snapshot board to be displayed
91 | stats: {
92 | isLoading: boolean,
93 | data: ?Object,
94 | error: ?string
95 | },
96 | pollingIntervalSeconds: number
97 | };
98 |
99 | const initState: State = {
100 | route: {
101 | path: 'NOT_FOUND'
102 | },
103 | currentBoard: null,
104 | boards: [],
105 | isLiveMode: true,
106 | history: {
107 | isLoading: false,
108 | data: null,
109 | error: null,
110 | date: null
111 | },
112 | liveBoardView: {
113 | isLoading: false,
114 | data: null,
115 | error: null
116 | },
117 | selectedBoardView: {
118 | isLoading: false,
119 | data: null,
120 | error: null,
121 | },
122 | stats: {
123 | isLoading: false,
124 | data: null,
125 | error: null
126 | },
127 | selectedTimestamp: null,
128 | pollingIntervalSeconds: 0
129 | };
130 |
131 | export default function reducers(state: State = initState, action: Action): State {
132 | switch (action.type) {
133 | // Current board view
134 | case 'FETCH_BOARD': {
135 | const liveBoardView = {
136 | ...state.liveBoardView,
137 | isLoading: true
138 | };
139 | return {
140 | ...state,
141 | liveBoardView,
142 | selectedBoardView: state.isLiveMode ? liveBoardView : state.selectedBoardView
143 | };
144 | }
145 | case 'FETCH_BOARD_SUCCESS': {
146 | const liveBoardView = {
147 | ...state.liveBoardView,
148 | isLoading: false,
149 | data: action.payload,
150 | error: null
151 | };
152 | return {
153 | ...state,
154 | liveBoardView,
155 | selectedBoardView: state.isLiveMode ? liveBoardView : state.selectedBoardView
156 | };
157 | }
158 | case 'FETCH_BOARD_FAILURE': {
159 | const liveBoardView = {
160 | ...state.liveBoardView,
161 | isLoading: false,
162 | error: action.payload
163 | };
164 | return {
165 | ...state,
166 | liveBoardView,
167 | selectedBoardView: state.isLiveMode ? liveBoardView : state.selectedBoardView
168 | };
169 | }
170 | // Boards
171 | case 'FETCH_BOARDS_SUCCESS':
172 | return {
173 | ...state,
174 | boards: action.payload
175 | };
176 | case 'FETCH_BOARDS_FAILURE':
177 | return {
178 | ...state,
179 | error: action.payload
180 | };
181 | // History
182 | case 'FETCH_HISTORY':
183 | return {
184 | ...state,
185 | history: {
186 | ...state.history,
187 | isLoading: true,
188 | date: action.date
189 | }
190 | };
191 | case 'FETCH_HISTORY_SUCCESS':
192 | return {
193 | ...state,
194 | history: {
195 | ...state.history,
196 | isLoading: false,
197 | error: null,
198 | data: action.payload
199 | }
200 | };
201 | case 'FETCH_HISTORY_FAILURE':
202 | return {
203 | ...state,
204 | history: {
205 | ...state.history,
206 | isLoading: false,
207 | error: action.payload
208 | }
209 | };
210 | // Snapshot
211 | case 'FETCH_SNAPSHOT_SUCCESS': {
212 | if (state.isLiveMode)
213 | return state;
214 | else
215 | return {
216 | ...state,
217 | selectedBoardView: {
218 | isLoading: false,
219 | error: null,
220 | data: action.payload
221 | }
222 | };
223 | }
224 | case 'FETCH_SNAPSHOT_FAILURE': {
225 | if (state.isLiveMode)
226 | return state;
227 | return {
228 | ...state,
229 | selectedBoardView: {
230 | isLoading: false,
231 | error: action.payload,
232 | data: state.selectedBoardView.data
233 | }
234 | };
235 | }
236 | // Stats
237 | case 'FETCH_STATS':
238 | return {
239 | ...state,
240 | stats: {
241 | ...state.stats,
242 | isLoading: true
243 | }
244 | };
245 | case 'FETCH_STATS_SUCCESS':
246 | return {
247 | ...state,
248 | stats: {
249 | ...state.stats,
250 | isLoading: false,
251 | data: action.payload,
252 | error: null
253 | }
254 | };
255 | case 'FETCH_STATS_FAILURE':
256 | return {
257 | ...state,
258 | stats: {
259 | ...state.stats,
260 | isLoading: true,
261 | data: null,
262 | error: action.payload
263 | }
264 | };
265 | // Polling service
266 | case 'SET_POLLING_INTERVAL':
267 | return {
268 | ...state,
269 | pollingIntervalSeconds: action.interval
270 | };
271 | // Routes
272 | case 'GOTO_BOARDS':
273 | return {
274 | ...state,
275 | currentBoard: null,
276 | route: {
277 | path: 'BOARDS'
278 | }
279 | };
280 | case 'GOTO_LIVE_BOARD':
281 | return {
282 | ...state,
283 | isLiveMode: true,
284 | selectedBoardView: {
285 | ...state.selectedBoardView,
286 | isLoading: true
287 | },
288 | selectedTimestamp: null,
289 | currentBoard: action.board,
290 | route: {
291 | path: 'BOARD',
292 | board: action.board
293 | }
294 | };
295 | // Time travel
296 | case 'GOTO_SNAPSHOT':
297 | return {
298 | ...state,
299 | isLiveMode: false,
300 | selectedBoardView: {
301 | ...state.selectedBoardView,
302 | isLoading: true
303 | },
304 | selectedTimestamp: action.timestamp,
305 | currentBoard: action.board,
306 | route: {
307 | path: 'BOARD',
308 | board: action.board
309 | }
310 | };
311 | case 'NOT_FOUND':
312 | return {
313 | ...state,
314 | currentBoard: null,
315 | route: {
316 | path: 'NOT_FOUND'
317 | }
318 | };
319 | default:
320 | return state;
321 | }
322 | }
323 |
--------------------------------------------------------------------------------
/src/main/webapp/js/store.js:
--------------------------------------------------------------------------------
1 | import { createStore, compose, applyMiddleware } from 'redux';
2 | import createSagaMiddlware from 'redux-saga';
3 | import reducer from './state';
4 | import rootSaga from './sagas';
5 | import router from './router';
6 |
7 | export default function configureStore() {
8 | const sagaMiddleware = createSagaMiddlware();
9 | const store = createStore(
10 | reducer,
11 | compose(
12 | applyMiddleware(
13 | router,
14 | sagaMiddleware,
15 | ),
16 | window.devToolsExtension ? window.devToolsExtension() : _ => _
17 | )
18 | );
19 | sagaMiddleware.run(rootSaga);
20 |
21 | if (module.hot) {
22 | module.hot.accept(() => {
23 | store.replaceReducer(require('./state').default);
24 | return true;
25 | });
26 | }
27 |
28 | return store;
29 | }
--------------------------------------------------------------------------------
/src/main/webapp/js/utils/api.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | // transfrom data from APIs
3 | import _ from 'lodash';
4 | import moment from 'moment';
5 | import type { Stats, BoardView, Layout } from '../state';
6 |
7 | // combine board view and board layout
8 | export const combineViewAndLayout = (view: any, layout: Layout, links: Array = [], slo: number = 0.97): BoardView => {
9 | // map from box name to [columnIndex, rowIndex, boxIndex]
10 | const map = new Map();
11 | layout.columns.forEach((col, i) =>
12 | col.rows.forEach((row, j) =>
13 | row.boxes.forEach((box, k) => map.set(box.title, [i,j,k]))
14 | )
15 | );
16 | const result = JSON.parse(JSON.stringify(layout));
17 | result.columns.forEach(col =>
18 | col.rows.forEach(row =>
19 | row.boxes.forEach(box => {
20 | box.status = 'Unknown';
21 | box.message = 'Unknown';
22 | })
23 | )
24 | );
25 | view.boxes.map(box => {
26 | const [i, j, k] = map.get(box.title) || [];
27 | const _box = result.columns[i].rows[j].boxes[k];
28 | // mutate result
29 | result.columns[i].rows[j].boxes[k] = {
30 | ..._box,
31 | status: box.status,
32 | message: box.message,
33 | checks: box.checks
34 | };
35 | });
36 | return {
37 | ...result,
38 | title: view.title,
39 | message: view.message,
40 | status: view.status,
41 | links,
42 | slo,
43 | };
44 | };
45 |
46 | // aggregate hourly statistics from API by local day/month/year
47 | const aggregateStatsBy = (granularity: string, stats: Stats): Stats =>
48 | _(stats)
49 | .toPairs()
50 | .groupBy(pair => moment(parseInt(pair[0])).startOf(granularity).valueOf())
51 | .mapValues(percents => _.reduce(percents, (acc: number, [_, percent]) => (acc + parseFloat(percent)), 0) / percents.length)
52 | .value();
53 |
54 | export const aggregateStatsByDay = (stats: Stats): Stats => aggregateStatsBy('day', stats);
55 | export const aggregateStatsByMonth = (stats: Stats): Stats => aggregateStatsBy('month', stats);
56 | export const aggregateStatsByYear = (stats: Stats): Stats => aggregateStatsBy('year', stats);
57 |
--------------------------------------------------------------------------------
/src/main/webapp/js/utils/fetcher.js:
--------------------------------------------------------------------------------
1 | const resolve = res => {
2 | const contentType = res.headers.get('content-type') || '';
3 | if (contentType.includes('json'))
4 | return res.json();
5 | return res.text();
6 | };
7 |
8 | export const fetcher = (url, options) =>
9 | fetch(url, options)
10 | .then(
11 | res => Promise.all([resolve(res), Promise.resolve(res)]),
12 | () => Promise.reject(Response.error())
13 | )
14 | .then(([body, res]) => {
15 | const response = {
16 | body,
17 | _res: res,
18 | status: res.status
19 | };
20 | if (res.status < 400)
21 | return Promise.resolve(response);
22 | else
23 | return Promise.reject(response);
24 | });
--------------------------------------------------------------------------------
/src/main/webapp/js/utils/index.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import type { Status } from '../state';
3 |
4 | export { fetcher } from './fetcher';
5 | export * from './api';
6 |
7 | export const statusToColor = (status: Status): string => {
8 | switch(status) {
9 | case 'SUCCESS': return '#2ebd59';
10 | case 'WARNING': return '#fdb843';
11 | case 'ERROR': return '#e7624f';
12 | default: return '#e0e0e0';
13 | }
14 | };
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Lato-Black.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-Black.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Lato-BlackItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-BlackItalic.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Lato-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-Bold.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Lato-BoldItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-BoldItalic.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Lato-Hairline.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-Hairline.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Lato-HairlineItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-HairlineItalic.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Lato-Italic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-Italic.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Lato-Light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-Light.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Lato-LightItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-LightItalic.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Lato-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-Regular.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/MaterialIcons-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/MaterialIcons-Regular.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Montserrat-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Montserrat-Bold.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Montserrat-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Montserrat-Regular.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Raleway-Black.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-Black.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Raleway-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-Bold.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Raleway-ExtraBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-ExtraBold.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Raleway-ExtraLight.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-ExtraLight.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Raleway-Light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-Light.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Raleway-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-Medium.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Raleway-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-Regular.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Raleway-SemiBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-SemiBold.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/fonts/Raleway-Thin.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-Thin.ttf
--------------------------------------------------------------------------------
/src/main/webapp/public/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/images/favicon.png
--------------------------------------------------------------------------------
/src/main/webapp/public/images/logo.sketch:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/images/logo.sketch
--------------------------------------------------------------------------------
/src/main/webapp/public/index.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | <%= htmlWebpackPlugin.options.title %>
7 |
8 | <% for(var i = 0; i < htmlWebpackPlugin.files.css.length; ++i) { %>
9 |
10 | <% } %>
11 |
12 |
13 |
14 | <% for(var i = 0; i < htmlWebpackPlugin.files.js.length; ++i) { %>
15 |
16 | <% } %>
17 |
18 |
--------------------------------------------------------------------------------
/src/main/webapp/style/components/BoardList.styl:
--------------------------------------------------------------------------------
1 | .board-list {
2 | padding: 2em
3 | overflow: auto
4 | .link {
5 | text-decoration: none
6 | font-size: 3em
7 | color: white
8 | boxShadow: 0 0 24px rgba(0,0,0,0.5)
9 | text-align: center
10 | padding: .5em
11 | background: #2980b9
12 | margin-bottom: .5em
13 | display: block
14 | }
15 | }
--------------------------------------------------------------------------------
/src/main/webapp/style/components/Box.styl:
--------------------------------------------------------------------------------
1 | @import '../global'
2 |
3 | div.box {
4 | display: flex
5 | flex-direction: column
6 | justify-content: center
7 |
8 | width: 80%
9 | margin-top: auto
10 | margin-bottom: auto
11 | border-radius: 3px
12 | box-shadow: 0 0 24px rgba(0,0,0,.5)
13 | padding: 1vh 1.25vh
14 | box-sizing: border-box
15 | cursor: pointer
16 | transition: .25s
17 | overflow: hidden
18 |
19 | &:hover {
20 | transform: scale(1.1)
21 | box-shadow: 0 0 36px rgba(0,0,0,.75)
22 | }
23 |
24 | div.checks {
25 | display: flex
26 | flex-wrap: wrap
27 | > .check {
28 | border: 1px solid rgba(255,255,255,.5)
29 | margin: 3px 3px 0 0
30 | padding: .25em .5em
31 | border-radius: 2px
32 | color: white
33 | box-sizing: border-box
34 | height: 2vh
35 | font-size: 1.25vh
36 | overflow: hidden
37 | white-space: nowrap
38 | text-overflow: ellipsis
39 | display: flex
40 | align-items: center
41 | }
42 | }
43 |
44 | h3 {
45 | float: none
46 | margin-bottom: 2px
47 | font-size: 1.5vmin
48 | font-weight: 500
49 | color: $white
50 | }
51 |
52 | strong {
53 | float: left
54 | display: block
55 | font-size: 1.4vmin
56 | font-weight: 400
57 | color: $white
58 | }
59 |
60 | #more {
61 | text-align: center
62 | color: white
63 | font-weight: bolder
64 | font-size: 2vh
65 | line-height: .25em
66 | }
67 | }
--------------------------------------------------------------------------------
/src/main/webapp/style/components/BoxModal.styl:
--------------------------------------------------------------------------------
1 | .box-modal {
2 | $headerHeight = 40px
3 | display: flex
4 | flex-direction: column
5 | height: 100%
6 | header {
7 | height: $headerHeight
8 | background: #2C3E50
9 | font-size: 1.5em
10 | padding: .25em
11 | span {
12 | color: white
13 | line-height: $headerHeight
14 | padding-left: 1em
15 | }
16 | button {
17 | float: right
18 | background: transparent
19 | padding-right: .5em
20 | color: white
21 | border: 0
22 | outline: 0
23 | font-size: 20px
24 | line-height: $headerHeight
25 | &:hover {
26 | cursor: pointer
27 | color: #DFDFDF
28 | }
29 | }
30 | }
31 | main {
32 | display: flex
33 | flex: 1
34 | flex-direction: column
35 | padding: 1em 2em
36 | min-height: 0
37 | > h3 {
38 | font-weight: 500
39 | margin-bottom: .5em
40 | color: #2c3e50
41 | margin-top: .75em
42 | font-size: 18px
43 | > .fa {
44 | margin-right: .5em
45 | }
46 | }
47 | .message {
48 | color: #5C5C5C
49 | }
50 | .description {
51 | padding: 1em 1.5em
52 | background: #FBFBFB
53 | border: 1px solid #EAEAEA
54 | line-height: 1.2em
55 | a {
56 | text-decoration: none
57 | }
58 | }
59 | }
60 | .checks {
61 | flex: 1
62 | overflow: auto
63 | .check {
64 | display: flex
65 | .status {
66 | display: inline-block
67 | width: 3px
68 | }
69 | .content {
70 | padding: 1em 1.5em
71 | background: #FBFBFB
72 | flex: 1
73 | border-top: 1px solid #EAEAEA
74 | border-right: 1px solid #EAEAEA
75 | color: #5C5C5C
76 | > h4 {
77 | margin-bottom: .25em
78 | }
79 | }
80 | &:last-child {
81 | .content {
82 | border-bottom: 1px solid #EAEAEA
83 | }
84 | }
85 | }
86 | }
87 | }
88 |
89 | .ReactModal__Overlay {
90 | background: rgba(255,255,255,0)
91 | transition: background .5s
92 | }
93 |
94 | // Modal opened
95 | .ReactModal__Body--open {
96 | #app {
97 | transform: scale(.9)
98 | }
99 | }
100 |
101 | .ReactModal__Content--after-open {
102 | animation: scaleUp .5s cubic-bezier(0.165, 0.840, 0.440, 1.000) forwards
103 | }
104 |
105 | // Modal closed
106 | #app {
107 | transform: scale(1)
108 | transition: transform .5s
109 | }
110 |
111 | .ReactModal__Content--before-close {
112 | animation: scaleDown .5s cubic-bezier(0.165, 0.840, 0.440, 1.000) forwards
113 | }
114 |
115 | @keyframes scaleUp {
116 | 0% {
117 | transform:scale(.8) translateY(1000px);
118 | opacity:0;
119 | }
120 | 100% {
121 | transform:scale(1) translateY(0px);
122 | opacity:1;
123 | }
124 | }
125 |
126 | @keyframes scaleDown {
127 | 0% {
128 | transform:scale(1) translateY(0px);
129 | opacity:1;
130 | }
131 | 100% {
132 | transform:scale(.8) translateY(1000px);
133 | opacity:0;
134 | }
135 | }
--------------------------------------------------------------------------------
/src/main/webapp/style/components/Calendar.styl:
--------------------------------------------------------------------------------
1 | /* hide back of pane during swap */
2 | .front, .back {
3 | backface-visibility: hidden
4 | -webkit-backface-visibility: hidden
5 | position: absolute !important
6 | top: 0
7 | left: 0
8 | }
9 |
10 | /* front pane, placed above back */
11 | .front {
12 | z-index: 2
13 | /* for firefox 31 */
14 | transform: rotateY(0deg)
15 | }
16 |
17 | /* back, initially hidden pane */
18 | .back {
19 | transform: rotateY(180deg)
20 | }
21 |
22 | .calendar-chart-wrapper {
23 | position: absolute
24 | left: 10px
25 | bottom: 40px
26 | width: 500px
27 | height: 360px
28 | padding: 40px
29 | background: rgb(64, 69, 73)
30 | perspective: 1000px
31 | z-index: 100
32 | transition: bottom 0.5s
33 | overflow: hidden
34 |
35 | .flipper {
36 | width: 100%
37 | height: 100%
38 | transition: 0.5s
39 | transform-style: preserve-3d
40 |
41 | &.flipped {
42 | transform: rotateY(180deg)
43 | }
44 | }
45 |
46 | &.closed {
47 | bottom: -550px
48 | }
49 |
50 | .close-button {
51 | position: absolute
52 | right: 10px
53 | top: 10px
54 | background: transparent
55 | border: none
56 | outline: 0
57 | font-size: 20px
58 | color: #ffffff
59 |
60 | &:hover {
61 | cursor: pointer
62 | color: #DFDFDF
63 | }
64 | }
65 |
66 | .flip-view-button {
67 | position: absolute
68 | right: 10px
69 | bottom: 10px
70 | background transparent
71 | border: none
72 | outline: 0
73 | font-size: 18px
74 | color: #ffffff
75 |
76 | &.chart:before {
77 | font-family: 'FontAwesome'
78 | content: '\f080'
79 | }
80 |
81 | &.calendar:before {
82 | font-family: 'FontAwesome'
83 | content: '\f073'
84 | }
85 |
86 | &:hover {
87 | cursor: pointer
88 | color: #DFDFDF
89 | }
90 | }
91 |
92 | .rdt.rdtStatic {
93 | position: relative
94 | width: 100%
95 |
96 | .rdtPicker {
97 | background: rgb(64, 69, 73)
98 | border: none
99 | width: 100%
100 | padding: 0
101 | color: #ffffff
102 |
103 | th {
104 | width: 40px
105 | height: 40px
106 | border-bottom: 0
107 | text-align: center
108 | vertical-align: middle
109 | font-weight: 400
110 |
111 | &:hover {
112 | cursor: default
113 | }
114 |
115 | &.rdtSwitch, &.rdtPrev, &.rdtNext {
116 | &:hover {
117 | background: rgba(255, 255, 255, .3)
118 | cursor: pointer
119 | }
120 | }
121 |
122 | }
123 |
124 | td {
125 | position: relative
126 | text-align: center
127 | vertical-align: middle
128 | padding-bottom: 5px
129 |
130 | span {
131 | font-weight: 500
132 | pointer-events: none
133 | }
134 |
135 | .date-label {
136 | display: inline
137 | }
138 |
139 | .percent-label {
140 | display: none
141 | font-size: 90%
142 | }
143 |
144 | &:not(.rdtOld):not(.rdtNew):not(.rdtDisabled) {
145 | &.good:after, &.bad:after {
146 | position: relative
147 | left: 5px
148 | font-size: 12px
149 | font-family: 'FontAwesome'
150 | vertical-align: middle
151 | }
152 | &.good:after {
153 | color: $green
154 | content: '\f058'
155 | }
156 | &.bad:after {
157 | content: '\f057'
158 | color: $red
159 | }
160 |
161 | &:hover {
162 | &:after { display: none }
163 | .date-label { display: none }
164 | .percent-label { display: inline }
165 | }
166 | }
167 |
168 | &.rdtToday:before {
169 | border-bottom: 7px solid #ffffff
170 | }
171 |
172 | &.rdtDay {
173 | height: 40px
174 | width: 60px
175 | }
176 |
177 | &.rdtMonth,
178 | &.rdtYear {
179 | height: 93px
180 | width: 80px
181 | }
182 |
183 | &.rdtOld,
184 | &.rdtNew {
185 | color: #666666
186 |
187 | span {
188 | cursor: not-allowed
189 | }
190 | }
191 |
192 | &.rdtYear:hover,
193 | &.rdtMonth:hover,
194 | &.rdtDay:hover,
195 | &.rdtHour:hover,
196 | &.rdtMinute:hover,
197 | &.rdtSecond:hover,
198 | &.rdtTimeToggle:hover,
199 | &.rdtActive,
200 | &.rdtActive:hover {
201 | background: rgba(255, 255, 255, .3)
202 | }
203 |
204 | &.rdtDisabled:hover, &.rdtOld:hover, &.rdtNew:hover {
205 | background: rgba(255, 255, 255, 0)
206 | }
207 | }
208 | }
209 | }
210 | }
211 |
212 | .recharts-wrapper .recharts-surface {
213 |
214 | .recharts-reference-line {
215 | .recharts-reference-line-line {
216 | stroke: $red
217 | stroke-dasharray 5
218 | }
219 | .recharts-text.recharts-label {
220 | font-size: 12px
221 | fill: $red
222 | stroke: none
223 | }
224 | }
225 |
226 | .recharts-brush {
227 |
228 | rect {
229 | stroke: #cccccc
230 | fill: #2b3238
231 | }
232 |
233 | .recharts-brush-texts {
234 | font-size: 12px
235 | stroke: #ffffff
236 | }
237 |
238 | .recharts-brush-slide {
239 | fill: #ffffff
240 | }
241 |
242 | .recharts-brush-traveller rect {
243 | fill: #ccccc
244 | }
245 | }
246 |
247 | .recharts-bar .recharts-bar-rectangles .recharts-bar-rectangle .recharts-rectangle {
248 | &.good {
249 | stroke: none
250 | fill: $green
251 | }
252 |
253 | &.bad {
254 | stroke: none
255 | fill: $red
256 | }
257 |
258 | .rectangle-tooltip-cursor path {
259 | fill: #ffffff
260 | }
261 | }
262 |
263 | .recharts-cartesian-axis {
264 | font-size: 12px
265 |
266 | .recharts-label {
267 | fill: #ffffff
268 | stroke: #ffffff
269 | }
270 |
271 | .recharts-cartesian-axis-line {
272 | stroke: #ffffff
273 | }
274 |
275 | .recharts-cartesian-axis-tick {
276 |
277 | .recharts-cartesian-axis-tick-line {
278 | stroke: #ffffff
279 | }
280 | .recharts-text.recharts-cartesian-axis-tick-value {
281 | stroke: none
282 | fill: #ffffff
283 | }
284 | }
285 | }
286 | }
287 |
288 | .custom-chart-tooltip {
289 | background: rgba(0, 0, 0, .5)
290 | padding: 10px
291 | border-radius: 4px
292 |
293 | .label-value {
294 | font-size: 12px
295 |
296 | .label {
297 | font-weight: bold
298 | }
299 |
300 | .value {
301 |
302 | }
303 | }
304 | }
--------------------------------------------------------------------------------
/src/main/webapp/style/components/ErrorPage.styl:
--------------------------------------------------------------------------------
1 | .error-page {
2 | display: flex
3 | flex-direction: column
4 | justify-content: center
5 | align-items: center
6 | font-size: 3em
7 | color: white
8 | h1 {
9 | color: #ff9800
10 | }
11 | a {
12 | color: #2196F3
13 | text-decoration: none
14 | }
15 | }
--------------------------------------------------------------------------------
/src/main/webapp/style/components/Timeline.styl:
--------------------------------------------------------------------------------
1 | .timeline {
2 | > #controller {
3 | padding: .25em .5em
4 | position: relative
5 | color: rgba(255,255,255,.8)
6 | background: rgba(128,128,128,.25)
7 | display: flex
8 | font-size: 18px
9 | height: 20px
10 | > .DayPicker {
11 | color: black
12 | position: absolute
13 | bottom: 2em
14 | background: white
15 | }
16 | > .board {
17 | margin-left: auto
18 | }
19 | > span {
20 | display: flex
21 | align-items: baseline
22 | }
23 | }
24 | > .info {
25 | color: white
26 | padding: 1em
27 | text-align: center
28 | }
29 | > #container {
30 | height: 75px
31 | position: relative
32 | > #message {
33 | position: absolute
34 | z-index: 1
35 | width: 100%
36 | height: 100%
37 | > .no-data {
38 | color: white
39 | font-size: 2em
40 | background: rgba(64, 70, 74, 0.72)
41 | width: 100%
42 | height: 100%
43 | display: flex
44 | justify-content: center
45 | align-items: center
46 | }
47 | }
48 | }
49 | .vis-panel.vis-center {
50 | overflow: visible
51 | }
52 | .vis-item {
53 | border-color: transparent
54 | color: white
55 | margin-top: 10px
56 | &:hover {
57 | cursor: pointer
58 | }
59 | &.vis-dot {
60 | top: 0px !important
61 | left: 6px !important
62 | border-radius: 6px
63 | border-width: 6px
64 | transition: transform .5s
65 | &.vis-selected {
66 | transform: scale(1.5)
67 | }
68 | }
69 | &.vis-selected {
70 | background: #2196F3
71 | border-color: transparent
72 | &.vis-point {
73 | background: transparent
74 | }
75 | }
76 | }
77 | .vis-text {
78 | &.vis-minor, &.vis-major {
79 | color: #d8d8d8 !important
80 | }
81 | }
82 | }
--------------------------------------------------------------------------------
/src/main/webapp/style/fonts.styl:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Material Icons'
3 | font-style: normal
4 | font-weight: 400
5 | src: url('/public/fonts/MaterialIcons-Regular.ttf') format('truetype')
6 | }
7 |
8 | @font-face {
9 | font-family: 'Montserrat';
10 | font-style: normal
11 | font-weight: 400
12 | src: url('/public/fonts/Montserrat-Regular.ttf') format('truetype')
13 | }
14 |
15 | @font-face {
16 | font-family: 'Montserrat';
17 | font-style: normal
18 | font-weight: 600
19 | src: url('/public/fonts/Montserrat-Bold.ttf') format('truetype')
20 | }
21 |
22 | @font-face {
23 | font-family: 'Lato';
24 | font-style: normal
25 | font-weight: 200
26 | src: url('/public/fonts/Lato-Hairline.ttf') format('truetype')
27 | }
28 |
29 | @font-face {
30 | font-family: 'Lato';
31 | font-style: italic
32 | font-weight: 200
33 | src: url('/public/fonts/Lato-HairlineItalic.ttf') format('truetype')
34 | }
35 |
36 | @font-face {
37 | font-family: 'Lato';
38 | font-style: normal
39 | font-weight: 300
40 | src: url('/public/fonts/Lato-Light.ttf') format('truetype')
41 | }
42 |
43 | @font-face {
44 | font-family: 'Lato';
45 | font-style: italic
46 | font-weight: 300
47 | src: url('/public/fonts/Lato-LightItalic.ttf') format('truetype')
48 | }
49 |
50 | @font-face {
51 | font-family: 'Lato';
52 | font-style: normal
53 | font-weight: 400
54 | src: url('/public/fonts/Lato-Regular.ttf') format('truetype')
55 | }
56 |
57 | @font-face {
58 | font-family: 'Lato';
59 | font-style: italic
60 | font-weight: 400
61 | src: url('/public/fonts/Lato-Italic.ttf') format('truetype')
62 | }
63 |
64 | @font-face {
65 | font-family: 'Lato';
66 | font-style: normal
67 | font-weight: 500
68 | src: url('/public/fonts/Lato-Bold.ttf') format('truetype')
69 | }
70 |
71 | @font-face {
72 | font-family: 'Lato';
73 | font-style: italic
74 | font-weight: 500
75 | src: url('/public/fonts/Lato-BoldItalic.ttf') format('truetype')
76 | }
77 |
78 | @font-face {
79 | font-family: 'Lato';
80 | font-style: normal
81 | font-weight: 600
82 | src: url('/public/fonts/Lato-Black.ttf') format('truetype')
83 | }
84 |
85 | @font-face {
86 | font-family: 'Lato';
87 | font-style: italic
88 | font-weight: 600
89 | src: url('/public/fonts/Lato-BlackItalic.ttf') format('truetype')
90 | }
91 |
--------------------------------------------------------------------------------
/src/main/webapp/style/global.styl:
--------------------------------------------------------------------------------
1 | $green = #2ebd59
2 | $red = #e7624f
3 | $orange = #fdb843
4 | $black = #2b3238
5 | $white = #ffffff
6 | $gray = #e0e0e0
7 | $darkgray = #bababa
8 | $blue = #2196f3
9 | $lightOrange = hsl(39, 65%, 68%)
10 |
--------------------------------------------------------------------------------
/src/main/webapp/style/index.styl:
--------------------------------------------------------------------------------
1 | @import 'reset'
2 | @import 'fonts'
3 | @import 'utils'
4 | @import url('https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css')
5 | @import '~vis/dist/vis.css'
6 | @import '~react-datetime/css/react-datetime.css'
7 |
8 | @import 'lib/Button'
9 | @import 'components/Timeline'
10 | @import 'components/BoardList'
11 | @import 'components/BoxModal'
12 | @import 'components/Box'
13 | @import 'components/Calendar'
14 | @import 'components/ErrorPage'
15 | @import './global'
16 |
17 | html, body {
18 | height: 100%
19 | }
20 | body {
21 | padding: 0
22 | margin: 0
23 | font-family: 'Lato', 'Helvetica', 'Arial', 'Sans'
24 | font-size: 14px
25 | overflow: hidden
26 | background: #9C9C9C
27 | input, select, textarea {
28 | font-family: 'Lato', 'Helvetica', 'Arial', 'Sans'
29 | font-size: 14px
30 | }
31 |
32 | #app {
33 | height: 100%
34 | background: $black
35 | & > div {
36 | height: 100%
37 | }
38 | & .graph {
39 | height: 100%
40 | display: flex
41 | flex-direction: column
42 |
43 | &.lost-connection {
44 | filter: grayscale(100%)
45 | -webkit-filter: grayscale(100%)
46 | }
47 |
48 | header {
49 | height: 7.5vh
50 | z-index: 10
51 | h1 {
52 | flex: 1
53 | color: $white
54 | font-size: 4vh
55 | text-align: center
56 | font-weight: 400
57 | margin-top: 1vh
58 | }
59 |
60 | p {
61 | flex: 1
62 | color: #fff
63 | text-align: center
64 | font-weight: 400
65 | font-size: 2vh
66 | }
67 | }
68 |
69 | main {
70 | flex: 1
71 | display: flex
72 |
73 | svg {
74 | position: absolute
75 | left: 0
76 | top: 0
77 | width: 100%
78 | height: 100%
79 | pointer-events: none
80 |
81 | .fgLine {
82 | stroke-width: 3
83 | fill: none
84 | stroke: $gray
85 |
86 | &.hoveredPath {
87 | stroke: $lightOrange
88 | }
89 | }
90 |
91 | .bgLine {
92 | stroke-width: 6
93 | fill: none
94 | stroke: rgba(255,255,255,.15)
95 | }
96 | }
97 |
98 | section.column {
99 | display: flex
100 | flex-direction: column
101 | justify-content: center
102 | position: relative
103 | border-left: 1px dotted rgba(255,255,255,.35)
104 |
105 | &:first-of-type {
106 | border: none
107 | }
108 |
109 | section.row {
110 | display: flex
111 | flex-direction: column
112 | justify-content: center
113 | align-items: center
114 | position: relative
115 | padding-top: 1.5vh
116 | flex: 1
117 | border-top: 1px dotted rgba(255,255,255,.35)
118 |
119 | &:first-of-type {
120 | border: none
121 | }
122 |
123 | h2 {
124 | font-size: 1.5vh
125 | text-align: center
126 | font-weight: 300
127 | color: $gray
128 | position: absolute
129 | top: .5vh
130 | left: 0
131 | right: 0
132 | }
133 | }
134 | }
135 | }
136 | }
137 | }
138 | }
139 |
140 | .background {
141 | background: $darkgray
142 | &.SUCCESS {
143 | background: $green
144 | }
145 | &.ERROR {
146 | background: $red
147 | }
148 | &.WARNING {
149 | background: $orange
150 | }
151 | &.UNKNOWN {
152 | background: $darkgray
153 | }
154 | }
155 |
156 | .color {
157 | &.SUCCESS {
158 | color: $green
159 | }
160 | &.ERROR {
161 | color: $red
162 | }
163 | &.WARNING {
164 | color: $orange
165 | }
166 | &.UNKNOWN {
167 | color: $darkgray
168 | }
169 | }
170 |
171 | .hidden {
172 | visibility: hidden
173 | }
174 |
--------------------------------------------------------------------------------
/src/main/webapp/style/lib/Button.styl:
--------------------------------------------------------------------------------
1 | .button {
2 | background: transparent
3 | color: white
4 | border: 0
5 | margin: 0 .1em
6 | cursor: pointer
7 | font-size: 12px
8 | border-top: 2px solid transparent
9 | border-bottom: 2px solid transparent
10 | &:hover {
11 | color: '#A9A9A9'
12 | border-bottom: 2px solid #4CAF50
13 | background: rgba(255, 255, 255, .1)
14 | }
15 | &:active {
16 | color: white
17 | }
18 | &:focus {
19 | outline: 0
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/main/webapp/style/reset.styl:
--------------------------------------------------------------------------------
1 | /*
2 | html5doctor.com Reset Stylesheet
3 | v1.6.1
4 | Last Updated: 2010-09-17
5 | Author: Richard Clark - http://richclarkdesign.com
6 | Twitter: @rich_clark
7 | Stylus-ized by
8 | dale tan
9 | http://www.whatthedale.com
10 | @HellaTan
11 | */
12 |
13 | html, body, div, span, object, iframe,
14 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
15 | abbr, address, cite, code,
16 | del, dfn, em, img, ins, kbd, q, samp,
17 | small, strong, sub, sup, var,
18 | b, i,
19 | dl, dt, dd, ol, ul, li,
20 | fieldset, form, label, legend,
21 | table, caption, tbody, tfoot, thead, tr, th, td,
22 | article, aside, canvas, details, figcaption, figure,
23 | footer, header, hgroup, menu, nav, section, summary,
24 | time, mark, audio, video
25 | background transparent
26 | border 0
27 | font-size 100%
28 | margin 0
29 | outline 0
30 | padding 0
31 | vertical-align baseline
32 |
33 | body
34 | line-height:1;
35 |
36 | article, aside, details, figcaption, figure,
37 | footer, header, hgroup, menu, nav, section
38 | display block
39 |
40 | nav ul
41 | list-style none
42 |
43 | blockquote, q
44 | quotes none
45 |
46 | blockquote:before, blockquote:after,
47 | q:before, q:after
48 | content ''
49 | content none
50 |
51 | a
52 | background transparent
53 | font-size 100%
54 | margin 0
55 | padding 0
56 | vertical-align baseline
57 |
58 | /* change colours to suit your needs */
59 | ins
60 | background-color #ff9
61 | color #000
62 | text-decoration none
63 |
64 | /* change colours to suit your needs */
65 | mark
66 | background-color #ff9
67 | color #000
68 | font-style italic
69 | font-weight bold
70 |
71 | del
72 | text-decoration line-through
73 |
74 | abbr[title], dfn[title]
75 | border-bottom 1px dotted
76 | cursor help
77 |
78 | table
79 | border-collapse collapse
80 | border-spacing 0
81 |
82 | /* change border colour to suit your needs */
83 | hr
84 | border 0
85 | border-top 1px solid #ccc
86 | display block
87 | height 1px
88 | margin 1em 0
89 | padding 0
90 |
91 | input, select
92 | vertical-align middle
93 |
--------------------------------------------------------------------------------
/src/main/webapp/style/utils.styl:
--------------------------------------------------------------------------------
1 | material-icon(name)
2 | &::before {
3 | content: name
4 | -webkit-font-feature-settings: 'liga'
5 | font-family: 'Material Icons'
6 | font-size: 24px
7 | display: inline-block
8 | text-transform: none
9 | {block}
10 | }
11 |
12 | second-material-icon(name)
13 | &::after {
14 | content: name
15 | -webkit-font-feature-settings: 'liga'
16 | font-family: 'Material Icons'
17 | font-size: 24px
18 | display: inline-block
19 | text-transform: none
20 | {block}
21 | }
22 |
--------------------------------------------------------------------------------
/src/test/scala/com/criteo/slab/app/StateServiceSpec.scala:
--------------------------------------------------------------------------------
1 | package com.criteo.slab.app
2 |
3 | import com.criteo.slab.core.{BoardView, Status}
4 | import org.scalatest.{FlatSpec, Matchers}
5 |
6 | class StateServiceSpec extends FlatSpec with Matchers {
7 | "getStatsByHour" should "aggregate stats by hour" in {
8 | val res = StateService.getStatsByHour(
9 | Seq(
10 | 0L -> BoardView("board0", Status.Warning, "", Seq.empty),
11 | 100L -> BoardView("board1", Status.Success, "", Seq.empty),
12 | 60 * 60 * 1000L -> BoardView("board2", Status.Error, "", Seq.empty),
13 | 200L -> BoardView("board3", Status.Unknown, "", Seq.empty)
14 | )
15 | )
16 | res should contain theSameElementsAs Seq(
17 | 0 -> Stats(
18 | 1,
19 | 1,
20 | 0,
21 | 1,
22 | 3
23 | ),
24 | 3600000L -> Stats(
25 | 0,
26 | 0,
27 | 1,
28 | 0,
29 | 1
30 | )
31 | )
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/test/scala/com/criteo/slab/core/BoardSpec.scala:
--------------------------------------------------------------------------------
1 | package com.criteo.slab.core
2 |
3 | import org.scalatest.mockito.MockitoSugar
4 | import org.scalatest.{FlatSpec, Matchers}
5 | import org.mockito.Mockito._
6 | import shapeless.HNil
7 |
8 | class BoardSpec extends FlatSpec with Matchers with MockitoSugar {
9 | val box1 = mock[Box[_]]
10 | when(box1.title) thenReturn "box 1"
11 | val box2 = mock[Box[_]]
12 | when(box2.title) thenReturn "box 2"
13 |
14 | "constructor" should "require that boxes and layout are correctly defined" in {
15 | intercept[IllegalArgumentException] {
16 | Board(
17 | "a broken board",
18 | box1 :: HNil,
19 | (views, _) => views.values.head,
20 | Layout(
21 | Column(
22 | 100,
23 | Row("row", 10, box2 :: Nil)
24 | )
25 | )
26 | )
27 | }
28 | intercept[IllegalArgumentException] {
29 | Board(
30 | "a broken board",
31 | box1 :: box2 :: HNil,
32 | (views, _) => views.values.head,
33 | Layout(
34 | Column(
35 | 100,
36 | Row("row", 10, List.empty)
37 | )
38 | )
39 | )
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/test/scala/com/criteo/slab/core/ExecutorSpec.scala:
--------------------------------------------------------------------------------
1 | package com.criteo.slab.core
2 |
3 | import java.time.Instant
4 |
5 | import com.criteo.slab.helper.FutureTests
6 | import org.mockito.Mockito._
7 | import org.scalatest.{BeforeAndAfterEach, FlatSpec, Matchers}
8 |
9 | class ExecutorSpec extends FlatSpec with Matchers with FutureTests with BeforeAndAfterEach {
10 | override def beforeEach = {
11 | reset(spiedCheck1)
12 | reset(spiedCheck2)
13 | reset(spiedCheck3)
14 | }
15 |
16 | val executor = Executor(board)
17 | "apply" should "fetch current check values if no context is given" in {
18 | val boardView = BoardView(
19 | "board",
20 | Status.Unknown,
21 | "board message",
22 | List(
23 | BoxView(
24 | "box 1",
25 | Status.Success,
26 | "box 1 message",
27 | List(
28 | CheckView(
29 | "check 1",
30 | Status.Success,
31 | "check 1 message: new value"
32 | ),
33 | CheckView(
34 | "check 2",
35 | Status.Success,
36 | "check 2 message: new value"
37 | )
38 | )
39 | ),
40 | BoxView(
41 | "box 2",
42 | Status.Success,
43 | "box 2 message",
44 | List(
45 | CheckView(
46 | "check 3",
47 | Status.Success,
48 | "check 3 message: 3"
49 | )
50 | )
51 | )
52 | )
53 | )
54 | val f = executor.apply(None)
55 | whenReady(f) { res =>
56 | verify(spiedCheck1, times(1)).apply
57 | verify(spiedCheck2, times(1)).apply
58 | verify(spiedCheck3, times(1)).apply
59 | res shouldEqual boardView
60 | }
61 | }
62 |
63 | "apply" should "fetch past check values if a context is given" in {
64 | val boardView = BoardView(
65 | "board",
66 | Status.Unknown,
67 | "board message",
68 | List(
69 | BoxView(
70 | "box 1",
71 | Status.Success,
72 | "box 1 message",
73 | List(
74 | CheckView(
75 | "check 1",
76 | Status.Success,
77 | "check 1 message: 100"
78 | ),
79 | CheckView(
80 | "check 2",
81 | Status.Success,
82 | "check 2 message: 100"
83 | )
84 | )
85 | ),
86 | BoxView(
87 | "box 2",
88 | Status.Success,
89 | "box 2 message",
90 | List(
91 | CheckView(
92 | "check 3",
93 | Status.Success,
94 | "check 3 message: 100"
95 | )
96 | )
97 | )
98 | )
99 | )
100 | val f = executor.apply(Some(Context(Instant.now)))
101 | whenReady(f) { res =>
102 | verify(spiedCheck1, never).apply
103 | verify(spiedCheck2, never).apply
104 | verify(spiedCheck3, never).apply
105 | res shouldEqual boardView
106 | }
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/test/scala/com/criteo/slab/core/LayoutSpec.scala:
--------------------------------------------------------------------------------
1 | package com.criteo.slab.core
2 |
3 | import com.criteo.slab.utils.Jsonable._
4 | import org.scalatest.{FlatSpec, Matchers}
5 |
6 | class LayoutSpec extends FlatSpec with Matchers {
7 |
8 | "Layout" should "be serializable to JSON" in {
9 | val layout = Layout(
10 | Column(50, Row("A", 25, List(
11 | Box[String]("box1", check1 :: Nil, (vs, _) => vs.head._2.view)
12 | )))
13 | )
14 | layout.toJSON shouldEqual """{"columns":[{"percentage":50.0,"rows":[{"title":"A","percentage":25.0,"boxes":[{"title":"box1","labelLimit":64}]}]}]}"""
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/test/scala/com/criteo/slab/core/ReadableViewSpec.scala:
--------------------------------------------------------------------------------
1 | package com.criteo.slab.core
2 |
3 | import com.criteo.slab.utils.Jsonable._
4 | import org.scalatest.{FlatSpec, Matchers}
5 |
6 | class ReadableViewSpec extends FlatSpec with Matchers {
7 |
8 | "toJSON" should "work" in {
9 | val boardView: ReadableView = BoardView(
10 | "board1",
11 | Status.Success,
12 | "msg",
13 | List(
14 | BoxView("box1", Status.Success, "msg", List(
15 | CheckView("check1", Status.Warning, "msg"),
16 | CheckView("check2", Status.Error, "msg", Some("label"))
17 | ))
18 | )
19 | )
20 | boardView.toJSON shouldEqual
21 | """
22 | |{
23 | |"title":"board1",
24 | |"status":"SUCCESS",
25 | |"message":"msg",
26 | |"boxes":[{
27 | |"title":"box1",
28 | |"status":"SUCCESS",
29 | |"message":"msg",
30 | |"checks":[
31 | |{"title":"check1","status":"WARNING","message":"msg"},
32 | |{"title":"check2","status":"ERROR","message":"msg","label":"label"}
33 | |]}
34 | |]}""".stripMargin.replaceAll("\n", "")
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/test/scala/com/criteo/slab/core/package.scala:
--------------------------------------------------------------------------------
1 | package com.criteo.slab
2 |
3 | import java.time.Instant
4 |
5 | import com.criteo.slab.lib.Values.Slo
6 | import shapeless.HNil
7 |
8 | import scala.concurrent.Future
9 | import scala.util.Try
10 | import org.mockito.Mockito._
11 |
12 | package object core {
13 | val check1 = Check[String](
14 | "c.1",
15 | "check 1",
16 | () => Future.successful("new value"),
17 | (value, _) => View(Status.Success, s"check 1 message: $value")
18 | )
19 | val check2 = Check[String](
20 | "c.2",
21 | "check 2",
22 | () => Future.successful("new value"),
23 | (value, _) => View(Status.Success, s"check 2 message: $value")
24 | )
25 | val check3 = Check[Int](
26 | "c.3",
27 | "check 3",
28 | () => Future.successful(3),
29 | (value, _) => View(Status.Success, s"check 3 message: $value")
30 | )
31 | val spiedCheck1 = spy(check1)
32 | val spiedCheck2 = spy(check2)
33 | val spiedCheck3 = spy(check3)
34 |
35 | val box1 = Box[String](
36 | "box 1",
37 | List(spiedCheck1, spiedCheck2),
38 | (views, _) => views.get(spiedCheck2).map(_.view.copy(message = "box 1 message")).getOrElse(View(Status.Unknown, "unknown"))
39 | )
40 |
41 | val box2 = Box[Int](
42 | "box 2",
43 | List(spiedCheck3),
44 | (views, _) => views.values.head.view.copy(message = "box 2 message")
45 | )
46 |
47 | val board = Board(
48 | "board",
49 | box1 :: box2 :: HNil,
50 | (_, _) => View(Status.Unknown, "board message"),
51 | Layout(
52 | Column(
53 | 50,
54 | Row("r1", 100, List(box1))
55 | ),
56 | Column(
57 | 50,
58 | Row("r2", 100, List(box2))
59 | )
60 | )
61 | )
62 |
63 |
64 | implicit def codecInt = new Codec[Int, String] {
65 |
66 | override def encode(v: Int): String = v.toString
67 |
68 | override def decode(v: String): Try[Int] = Try(v.toInt)
69 | }
70 |
71 | implicit def codecSlo = new Codec[Slo, String] {
72 |
73 | override def encode(v: Slo): String = v.underlying.toString
74 |
75 | override def decode(v: String): Try[Slo] = Try(Slo(v.toDouble))
76 | }
77 |
78 | implicit def codecString = new Codec[String, String] {
79 | override def encode(v: String): String = v
80 |
81 | override def decode(v: String): Try[String] = Try(v)
82 | }
83 |
84 | implicit def store = new Store[String] {
85 | override def upload[T](id: String, context: Context, v: T)(implicit ev: Codec[T, String]): Future[Unit] = Future.successful(())
86 |
87 | override def fetch[T](id: String, context: Context)(implicit ev: Codec[T, String]): Future[Option[T]] = Future.successful(ev.decode("100").toOption)
88 |
89 | override def fetchHistory[T](id: String, from: Instant, until: Instant)(implicit ev: Codec[T, String]): Future[Seq[(Long, T)]] = Future.successful(List.empty)
90 |
91 | override def uploadSlo(id: String, context: Context, v: Slo)(implicit codec: Codec[Slo, String]): Future[Unit] = Future.successful(())
92 |
93 | def fetchSloHistory(id: String, from: Instant, until: Instant)(implicit codec: Codec[Slo, String]): Future[Seq[(Long, Slo)]] = Future.successful(List.empty)
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/test/scala/com/criteo/slab/helper/FutureTests.scala:
--------------------------------------------------------------------------------
1 | package com.criteo.slab.helper
2 |
3 | import org.scalatest.concurrent.{Futures, ScalaFutures}
4 |
5 | trait FutureTests extends Futures with ScalaFutures {
6 | implicit val ec = concurrent.ExecutionContext.Implicits.global
7 | }
8 |
9 | object FutureTests extends FutureTests
--------------------------------------------------------------------------------
/src/test/scala/com/criteo/slab/lib/GraphiteMetricSpec.scala:
--------------------------------------------------------------------------------
1 | package com.criteo.slab.lib
2 |
3 | import com.criteo.slab.lib.graphite.{DataPoint, GraphiteMetric}
4 | import com.criteo.slab.utils.Jsonable
5 | import org.json4s.DefaultFormats
6 | import org.scalatest.{FlatSpec, Matchers}
7 |
8 | import scala.util.Success
9 |
10 | class GraphiteMetricSpec extends FlatSpec with Matchers {
11 | "JSON serializer" should "be able to read json" in {
12 | val json = """[{"target":"metric.one", "datapoints":[[1.0, 2000], [null, 2060]]}]""".stripMargin.replace("\n", "")
13 | val formats = DefaultFormats ++ Jsonable[GraphiteMetric].serializers
14 | val r = Jsonable.parse[List[GraphiteMetric]](json, formats)
15 | r shouldEqual Success(List(GraphiteMetric("metric.one", List(
16 | DataPoint(Some(1.0), 2000),
17 | DataPoint(None, 2060)
18 | ))))
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/test/scala/com/criteo/slab/lib/GraphiteStoreSpec.scala:
--------------------------------------------------------------------------------
1 | package com.criteo.slab.lib
2 |
3 | import java.net._
4 | import java.time.Duration
5 | import java.util.concurrent._
6 |
7 | import com.criteo.slab.core.Context
8 | import com.criteo.slab.helper.FutureTests
9 | import com.criteo.slab.lib.Values.Latency
10 | import com.criteo.slab.lib.graphite.{DataPoint, GraphiteMetric, GraphiteStore}
11 | import org.scalatest.{FlatSpec, Matchers}
12 |
13 | import scala.io._
14 | import com.criteo.slab.lib.graphite.GraphiteCodecs._
15 |
16 | class GraphiteStoreSpec extends FlatSpec with Matchers with FutureTests {
17 | val port = 5000
18 | val server = new ServerSocket(port)
19 | val store = new GraphiteStore("localhost", port, "http://localhost", Duration.ofSeconds(60))
20 |
21 | val pool = Executors.newFixedThreadPool(1)
22 |
23 | "value store" should "be able to send metrics to Graphite server" in {
24 | val f = pool.submit(new Echo(server))
25 | store.upload("metrics", Context.now, Latency(200))
26 | f.get should startWith("metrics.latency 200")
27 | }
28 |
29 | "upload" should "returns the exception when failed" in {
30 | whenReady(
31 | store.upload("metrics", Context.now, Latency(200)).failed
32 | ) { res =>
33 | res shouldBe a[java.net.ConnectException]
34 | }
35 | }
36 |
37 | "transform metrics" should "turn Graphite metrics into pairs" in {
38 | val metrics = List(
39 | GraphiteMetric(
40 | "metric.one",
41 | List(
42 | DataPoint(None, 2000),
43 | DataPoint(Some(1), 2060)
44 | )
45 | ),
46 | GraphiteMetric(
47 | "metric.two",
48 | List(
49 | DataPoint(None, 2000),
50 | DataPoint(Some(2), 2060)
51 | )
52 | )
53 | )
54 | GraphiteStore.transformMetrics("metric", metrics) shouldEqual Map("one" -> 1.0, "two" -> 2.0)
55 | }
56 |
57 | "transform metrics" should "return empty if some metrics are missing" in {
58 | val metrics = List(
59 | GraphiteMetric(
60 | "metric.one",
61 | List(DataPoint(Some(1), 2000))
62 | ),
63 | GraphiteMetric(
64 | "metric.two",
65 | List(DataPoint(None, 2000))
66 | )
67 | )
68 | GraphiteStore.transformMetrics("metric", metrics) shouldEqual Map.empty
69 | }
70 |
71 | "group metrics" should "group metrics" in {
72 | val metrics = List(
73 | GraphiteMetric(
74 | "metric.one",
75 | List(DataPoint(Some(1), 1000), DataPoint(Some(2), 2000))
76 | ),
77 | GraphiteMetric(
78 | "metric.two",
79 | List(DataPoint(Some(3), 1000), DataPoint(Some(4), 2000))
80 | )
81 | )
82 | GraphiteStore.groupMetrics("metric", metrics) shouldEqual Map(
83 | 1000000 -> Map("one" -> 1, "two" -> 3),
84 | 2000000 -> Map("one" -> 2, "two" -> 4)
85 | )
86 | }
87 |
88 | class Echo(server: ServerSocket) extends Callable[String] {
89 | def call() = {
90 | val s = server.accept
91 | val lines = new BufferedSource(s.getInputStream).getLines
92 | val result = lines.mkString
93 | s.close
94 | server.close
95 | result
96 | }
97 | }
98 |
99 | }
--------------------------------------------------------------------------------
/src/test/scala/com/criteo/slab/lib/InMemoryStoreSpec.scala:
--------------------------------------------------------------------------------
1 | package com.criteo.slab.lib
2 |
3 | import java.time.Instant
4 | import java.time.temporal.ChronoUnit
5 |
6 | import org.scalatest.{FlatSpec, Matchers}
7 | import org.slf4j.LoggerFactory
8 |
9 | import scala.collection.concurrent.TrieMap
10 |
11 | class InMemoryStoreSpec extends FlatSpec with Matchers {
12 | val logger = LoggerFactory.getLogger(this.getClass)
13 | "Cleaner" should "remove expired entries" in {
14 | val cache = TrieMap.empty[(String, Long), Any]
15 | val cleaner = InMemoryStore.createCleaner(cache, 1, logger)
16 |
17 | cache += ("a", Instant.now.minus(2, ChronoUnit.DAYS).toEpochMilli) -> 1
18 | cache += ("b", Instant.now.minus(1, ChronoUnit.DAYS).toEpochMilli) -> 2
19 | cache += ("c", Instant.now.toEpochMilli) -> 3
20 | cleaner.run()
21 | cache.size shouldBe 1
22 | cache.head._1._1 shouldBe "c"
23 | }
24 |
25 | }
26 |
--------------------------------------------------------------------------------
/src/test/scala/com/criteo/slab/utils/packageSpec.scala:
--------------------------------------------------------------------------------
1 | package com.criteo.slab.utils
2 |
3 | import org.scalatest.{FlatSpec, Matchers}
4 |
5 | import scala.util.{Failure, Success}
6 |
7 | class packageSpec extends FlatSpec with Matchers {
8 |
9 | "collectTries" should "collect all successful values" in {
10 | collectTries(
11 | List(Success(1), Success(2))
12 | ) shouldEqual Success(List(1, 2))
13 | }
14 | "collectTries" should "stops at first failure" in {
15 | val e = new Exception("e")
16 | collectTries(
17 | List(Success(1), Failure(e))
18 | ) shouldEqual Failure(e)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/test/webapp/sagas.spec.js:
--------------------------------------------------------------------------------
1 | import { call, put } from 'redux-saga/effects';
2 | import * as api from 'src/api';
3 | import { fetchBoard, fetchBoards } from 'src/sagas';
4 | describe('saga spec', () => {
5 | describe('fetchBoard', () => {
6 | it('calls API', () => {
7 | const iter = fetchBoard({ board: 'boardname' }, view => view);
8 | expect(iter.next().value).to.deep.equal(
9 | call(fetchBoards)
10 | );
11 | expect(iter.next([{ layout: {}, links: [] }]).value).to.deep.equal(
12 | call(api.fetchBoard, 'boardname'),
13 | );
14 | const payload = [1,2,3];
15 | const result = iter.next(payload).value;
16 | expect(result).to.deep.equal(put({
17 | type: 'FETCH_BOARD_SUCCESS',
18 | payload
19 | }));
20 | });
21 |
22 | it('put failure action if any exception is raised', () => {
23 | const iter = fetchBoard({ board: 'boardname' });
24 | iter.next();
25 | const error = new Error('uh');
26 | const next = iter.throw(error);
27 | expect(next.value).to.deep.equal(put({ type: 'FETCH_BOARD_FAILURE', payload: error }));
28 | });
29 | });
30 | });
--------------------------------------------------------------------------------
/src/test/webapp/utils/api.spec.js:
--------------------------------------------------------------------------------
1 | import { combineViewAndLayout, aggregateStatsByDay, aggregateStatsByMonth, aggregateStatsByYear } from 'src/utils';
2 | import moment from 'moment';
3 |
4 | describe('api utils specs', () => {
5 | describe('combineLayoutAndView', () => {
6 | const view = {
7 | title: 'Board1',
8 | status: 'SUCCESS',
9 | message: 'msg',
10 | boxes: [{
11 | title: 'Box1',
12 | status: 'SUCCESS',
13 | message: 'msg1',
14 | checks: [{
15 | title: 'Check1',
16 | status: 'SUCCESS',
17 | message: 'msg11',
18 | label: 'l11'
19 | }]
20 | }, {
21 | title: 'Box2',
22 | status: 'WARNING',
23 | message: 'msg2',
24 | checks: [{
25 | title: 'Check2',
26 | status: 'WARNING',
27 | message: 'msg22',
28 | label: 'l22'
29 | }]
30 | }]
31 | };
32 |
33 | const links = [
34 | ['Box1', 'Box2']
35 | ];
36 |
37 | const layout = {
38 | columns: [
39 | {
40 | percentage: 50,
41 | rows: [{
42 | title: 'Zone 1',
43 | percentage: 100,
44 | boxes: [{
45 | title: 'Box1',
46 | description: 'desc1',
47 | labelLimit: 2
48 | }]
49 | }]
50 | },
51 | {
52 | percentage: 50,
53 | rows: [{
54 | title: 'Zone 2',
55 | percentage: 100,
56 | boxes: [{
57 | title: 'Box2',
58 | description: 'desc2',
59 | labelLimit: 0
60 | }]
61 | }]
62 | }
63 | ]
64 | };
65 |
66 | it('combines board layout and view data, returns an object representing the view', () => {
67 | const result = combineViewAndLayout(view, layout, links);
68 | expect(result).to.deep.equal({
69 | title: 'Board1',
70 | status: 'SUCCESS',
71 | message: 'msg',
72 | columns: [
73 | {
74 | percentage: 50,
75 | rows: [{
76 | title: 'Zone 1',
77 | percentage: 100,
78 | boxes: [{
79 | title: 'Box1',
80 | description: 'desc1',
81 | status: 'SUCCESS',
82 | message: 'msg1',
83 | labelLimit: 2,
84 | checks: [{
85 | title: 'Check1',
86 | status: 'SUCCESS',
87 | message: 'msg11',
88 | label: 'l11'
89 | }]
90 | }]
91 | }]
92 | },
93 | {
94 | percentage: 50,
95 | rows: [{
96 | title: 'Zone 2',
97 | percentage: 100,
98 | boxes: [{
99 | title: 'Box2',
100 | status: 'WARNING',
101 | description: 'desc2',
102 | message: 'msg2',
103 | labelLimit: 0,
104 | checks: [{
105 | title: 'Check2',
106 | status: 'WARNING',
107 | message: 'msg22',
108 | label: 'l22'
109 | }]
110 | }]
111 | }]
112 | }
113 | ],
114 | links,
115 | slo: 0.97
116 | });
117 | });
118 | });
119 |
120 | describe('aggregateStatsByDay', () => {
121 | it('takes hourly stats and aggregate them into daily buckets', () => {
122 | const stats = {
123 | [moment('2000-01-01 00:00').valueOf()]: 0.5,
124 | [moment('2000-01-01 23:00').valueOf()]: 0.9,
125 | [moment('2000-01-02 00:00').valueOf()]: 1,
126 | [moment('2000-01-02 23:59').valueOf()]: 1,
127 | };
128 | const res = aggregateStatsByDay(stats);
129 | expect(res).to.deep.equal(
130 | {
131 | [moment('2000-01-01 00:00').valueOf()]: 0.7,
132 | [moment('2000-01-02 00:00').valueOf()]: 1,
133 | }
134 | );
135 | });
136 | });
137 |
138 | describe('aggregateStatsByMonth', () => {
139 | it('takes hourly stats and aggregate them into monthly buckets', () => {
140 | const stats = {
141 | [moment('2000-01-01 00:00').valueOf()]: 0.5,
142 | [moment('2000-01-01 23:00').valueOf()]: 0.9,
143 | [moment('2000-01-02 00:00').valueOf()]: 1,
144 | [moment('2000-01-02 23:59').valueOf()]: 1,
145 | [moment('2000-02-01 12:00').valueOf()]: 0.3,
146 | [moment('2000-02-02 13:00').valueOf()]: 0.3,
147 | };
148 | const res = aggregateStatsByMonth(stats);
149 | expect(res).to.deep.equal(
150 | {
151 | [moment('2000-01-01 00:00').valueOf()]: 0.85,
152 | [moment('2000-02-01 00:00').valueOf()]: 0.3,
153 | }
154 | );
155 | });
156 | });
157 |
158 | describe('aggregateStatsByYear', () => {
159 | it('takes hourly stats and aggregate them into yearly buckets', () => {
160 | const stats = {
161 | [moment('2000-01-01 00:00').valueOf()]: 0.5,
162 | [moment('2000-01-01 23:00').valueOf()]: 0.9,
163 | [moment('2000-01-02 00:00').valueOf()]: 1,
164 | [moment('2000-01-02 23:59').valueOf()]: 1,
165 | [moment('2000-02-01 12:00').valueOf()]: 0.3,
166 | [moment('2000-03-02 13:00').valueOf()]: 0.33,
167 | [moment('2001-01-01 00:00').valueOf()]: 0.1,
168 | [moment('2001-03-01 23:00').valueOf()]: 0.89,
169 | [moment('2001-03-12 00:00').valueOf()]: 0.14,
170 | [moment('2001-04-01 23:59').valueOf()]: 1,
171 | [moment('2001-05-01 12:00').valueOf()]: 0.4,
172 | [moment('2002-01-31 13:00').valueOf()]: 0.72,
173 | [moment('2002-04-04 00:00').valueOf()]: 0.32,
174 | };
175 | const res = aggregateStatsByYear(stats);
176 | expect(res).to.deep.equal(
177 | {
178 | [moment('2000-01-01 00:00').valueOf()]: 0.6716666666666665,
179 | [moment('2001-01-01 00:00').valueOf()]: 0.506,
180 | [moment('2002-01-01 00:00').valueOf()]: 0.52,
181 | }
182 | );
183 | });
184 | });
185 | });
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require('path');
2 | var yargs = require('yargs').argv;
3 | var webpack = require('webpack');
4 | var HtmlWebpackPlugin = require('html-webpack-plugin');
5 | var CopyWebpackPlugin = require('copy-webpack-plugin');
6 |
7 | yargs.env = yargs.env || {};
8 | var baseDir = path.resolve(__dirname, 'src/main/webapp');
9 | var outPath = path.resolve(__dirname, yargs.env.out || 'dist');
10 | var isProdMode = yargs.p || false;
11 | var config = {
12 | entry: [
13 | 'whatwg-fetch',
14 | 'babel-polyfill',
15 | path.resolve(baseDir, 'js/index.js'),
16 | path.resolve(baseDir, 'style/index.styl')
17 | ],
18 | output: {
19 | path: outPath,
20 | filename: 'index.js',
21 | publicPath: '/'
22 | },
23 | module: {
24 | loaders: [
25 | {
26 | test: /\.jsx?$/,
27 | loader: 'babel-loader',
28 | query: {
29 | cacheDirectory: true,
30 | presets: ['react', 'es2015'],
31 | plugins: [
32 | 'transform-class-properties',
33 | 'transform-object-rest-spread',
34 | 'transform-exponentiation-operator'
35 | ]
36 | },
37 | include: [path.join(__dirname, 'src')]
38 | },
39 | {
40 | test: /\.styl/,
41 | loaders: ['style-loader', 'css-loader', 'stylus-loader']
42 | },
43 | {
44 | test: /\.png$/,
45 | loaders: ['file-loader']
46 | }
47 | ]
48 | },
49 | resolve: {
50 | alias: {
51 | 'src': path.resolve(__dirname, 'src/main/webapp/js') // for testing
52 | }
53 | },
54 | plugins: [
55 | new HtmlWebpackPlugin({
56 | filename: 'index.html',
57 | template: path.resolve(baseDir, 'public/index.ejs'),
58 | inject: false,
59 | title: 'SLAB'
60 | }),
61 | new CopyWebpackPlugin(
62 | [
63 | {
64 | from: path.resolve(baseDir, 'public'),
65 | to: 'public'
66 | }
67 | ],
68 | {
69 | ignore: [
70 | '*.ejs'
71 | ]
72 | }
73 | ),
74 | new webpack.ProvidePlugin({
75 | 'React': 'react'
76 | }),
77 | new webpack.NamedModulesPlugin()
78 | ],
79 | devServer: {
80 | contentBase: outPath,
81 | compress: true,
82 | port: 9001,
83 | historyApiFallback: true,
84 | proxy: {
85 | '/api': 'http://localhost:' + (yargs.env.serverPort || 8080)
86 | },
87 | hot: !isProdMode
88 | },
89 | devtool: isProdMode ? 'source-map' : 'eval-source-map'
90 | };
91 |
92 | if (!isProdMode) {
93 | config.plugins.push(new webpack.NamedModulesPlugin());
94 | config.plugins.push(new webpack.HotModuleReplacementPlugin());
95 | }
96 |
97 | module.exports = config;
--------------------------------------------------------------------------------