diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..b89acaa0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# http://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 80 + +[*.{css, scss, js, html}] +indent_size = 2 +max_line_length = 140 + +[*.md] +trim_trailing_whitespace = false + +[*.{yml, yaml}] +indent_size = 2 + +[*.json] +indent_size = 2 diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..4e7a01d6 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +!.*.js +**/*.min.js +node_modules/** +rt_dashboard/templates/rt_dashboard/dashboard.js diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..45c4f45f --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,24 @@ +module.exports = { + extends: [ + 'standard', + ], + parser: 'babel-eslint', + parserOptions: { + sourceType: 'module', + }, + + env: { + browser: true, + es2020: true, + }, + + rules: { + 'comma-dangle': ['error', { + arrays: 'always-multiline', + objects: 'always-multiline', + imports: 'always-multiline', + exports: 'always-multiline', + functions: 'always-multiline', + }], + }, +} diff --git a/.gitignore b/.gitignore index e90d5e00..b1a4a77b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ dump.rdb /.direnv ._* .pytest_cache +**/static +**/node_modules diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..66df3b7a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +12.16.1 diff --git a/.travis.yml b/.travis.yml index 130fef63..55550214 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,9 +6,13 @@ python: - "3.6" - "3.7" - "3.8" +node_js: "12" install: - pip install -r requirements.txt - pip install -e . + - nvm install $(cat .nvmrc) && nvm use + - yarn script: - pytest - flake8 . + - yarn lint diff --git a/README.md b/README.md deleted file mode 100644 index 959d630c..00000000 --- a/README.md +++ /dev/null @@ -1,10 +0,0 @@ -rt-dashboard -============ - -[![Build Status](https://travis-ci.com/djangsters/rt-dashboard.svg?branch=master)](https://travis-ci.com/djangsters/rt-dashboard) -[![PyPI version](https://badge.fury.io/py/rt-dashboard.svg)](https://badge.fury.io/py/rt-dashboard) -![PyPI - Python Version](https://img.shields.io/pypi/pyversions/rt-dashboard) - -`rt-dashboard` allows you to monitor your [redis-tasks]( -https://github.com/djangsters/redis-tasks) queues, jobs, and workers - in realtime as well as inspect the historical tasks timeline. diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 00000000..92f5f6bd --- /dev/null +++ b/babel.config.js @@ -0,0 +1,29 @@ +module.exports = { + env: { + production: { + presets: [ + [ + '@babel/preset-env', + { + useBuiltIns: 'entry', + }, + ], + ], + }, + test: { + presets: [ + [ + '@babel/preset-env', + { + targets: { + node: 'current', + }, + }, + ], + ], + }, + }, + plugins: [ + '@babel/plugin-proposal-nullish-coalescing-operator', + ], +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..453077e9 --- /dev/null +++ b/package.json @@ -0,0 +1,82 @@ +{ + "name": "rt-dashboard", + "version": "1.0.0", + "description": "Redis Tasks Dashboard simple interface to manage and observe tasks", + "main": "webpack.config.js", + "directories": { + "test": "tests" + }, + "scripts": { + "test": "jest", + "build": "webpack", + "start": "webpack-dev-server --watch", + "lint": "eslint ." + }, + "repository": { + "type": "git", + "url": "git+https://github.com/djangsters/rt-dashboard.git" + }, + "keywords": [ + "rt", + "dashboard" + ], + "author": "Djangsters", + "license": "ISC", + "bugs": { + "url": "https://github.com/djangsters/rt-dashboard/issues" + }, + "homepage": "https://github.com/djangsters/rt-dashboard#readme", + "dependencies": { + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3", + "@fortawesome/fontawesome-free": "^5.13.0", + "babel-eslint": "^10.1.0", + "bootstrap": "^4.4.1", + "command-line-args": "^5.1.1", + "css-loader": "^3.4.2", + "d3-axis": "^1.0.12", + "d3-scale": "^3.2.1", + "d3-selection": "^1.4.1", + "d3-time": "^1.1.0", + "d3-time-format": "^2.2.3", + "d3-transition": "^1.3.2", + "date-fns": "^2.14.0", + "file-loader": "^6.0.0", + "html-loader": "^1.0.0", + "jquery": "^3.4.1", + "popper.js": "^1.16.1" + }, + "devDependencies": { + "@babel/core": "^7.9.0", + "@babel/preset-env": "^7.9.0", + "@babel/preset-react": "^7.9.1", + "babel-jest": "^26.0.1", + "babel-loader": "^8.1.0", + "eslint": "^6.8.0", + "eslint-config-standard": "^14.1.1", + "eslint-plugin-import": "^2.20.1", + "eslint-plugin-node": "^11.0.0", + "eslint-plugin-promise": "^4.2.1", + "eslint-plugin-standard": "^4.0.1", + "jest": "^26.0.1", + "mini-css-extract-plugin": "^0.9.0", + "node-sass": "^4.13.1", + "postcss-loader": "^3.0.0", + "sass-loader": "^8.0.2", + "style-loader": "^1.1.3", + "webpack": "^4.42.0", + "webpack-cli": "^3.3.11", + "webpack-dev-server": "^3.10.3" + }, + "sideEffects": [ + "**/*.css", + "**/*.scss", + "./rt_dashboard/javascript/src/app.js", + "./rt_dashboard/javascript/src/components/*/index.js" + ], + "jest": { + "verbose": true, + "transform": { + "^.+\\.js$": "babel-jest" + } + } +} diff --git a/rt_dashboard/contrib/django/__init__.py b/rt_dashboard/contrib/django/__init__.py index f698d17d..e896dfcc 100644 --- a/rt_dashboard/contrib/django/__init__.py +++ b/rt_dashboard/contrib/django/__init__.py @@ -25,7 +25,7 @@ def view(request): else: return render(request, 'rt_dashboard_admin.html', { 'title': 'RT Dashboard', - 'iframe_src': base_path + '/inner/', + 'iframe_src': base_path + '/inner/app', }) return view diff --git a/rt_dashboard/contrib/django/templates/rt_dashboard_admin.html b/rt_dashboard/contrib/django/templates/rt_dashboard_admin.html index 92678fd3..c6455bde 100644 --- a/rt_dashboard/contrib/django/templates/rt_dashboard_admin.html +++ b/rt_dashboard/contrib/django/templates/rt_dashboard_admin.html @@ -3,7 +3,7 @@ {% block content %} + +
diff --git a/rt_dashboard/javascript/src/components/history/HistoryChart.js b/rt_dashboard/javascript/src/components/history/HistoryChart.js new file mode 100644 index 00000000..ad879399 --- /dev/null +++ b/rt_dashboard/javascript/src/components/history/HistoryChart.js @@ -0,0 +1,105 @@ +import gantt from './gantt' +import { timeDay, timeHour } from 'd3-time' +import { loadTemplate } from '../../utils/dom' +import templateHtml from './HistoryChart.html' + +export default class HistoryChart extends HTMLElement { + constructor () { + super() + + this.attachShadow({ mode: 'open' }) + + loadTemplate(this.shadowRoot, templateHtml) + + this._root = this.shadowRoot.getElementById('chart-container') + } + + zoomIn () { + this._gantt.zoomInOut('in') + } + + zoomOut () { + this._gantt.zoomInOut('out') + } + + panLeft () { + this._gantt.panView('left', 0.30) + } + + panRight () { + this._gantt.panView('right', 0.30) + } + + setHistoryData ({ rows }) { + const eventTypes = {} + const eventList = rows + .map((item) => { + const key = `${item.group}-${item.subgroup}` + if (!(key in eventTypes)) { + eventTypes[key] = true + } + switch (item.type) { + case 'range': + return { + startDate: +item.start, + endDate: +item.end, + taskName: key, + toolTipHTML: item.title, + } + case 'point': + return { + startDate: +item.start, + endDate: (+item.start) + 1, + taskName: key, + toolTipHTML: item.title, + } + default: + throw new Error(`Unsupported item type ${item.type}`) + } + }) + + const eventStyleClassList = [ + 'blue-bar', + 'purple-bar', + 'red-bar', + 'green-bar', + 'orange-bar', + ] + + const ganttConfig = { + root: this._root, + eventSettings: { + eventList, + eventTypes: Object.keys(eventTypes), + eventStyleClassList, + enableToolTips: true, + }, + sizing: { + location: 'GanttChart', + margin: { + top: 60, + right: 40, + bottom: 20, + left: 120, + }, + height: '400', + width: window.innerWidth, + }, + timeDomainSettings: { + zoomLevels: ['5:sec', '15:sec', '1:min', '5:min', '15:min', '1:hr', '3:hr', '6:hr', '1:day'], + timeDomainStart: timeDay.offset(new Date(), -3), + timeDomainEnd: timeHour.offset(new Date(), +3), + startingTimeFormat: '%H:%M', + startingTimeDomainString: '1day', + startingZoomLevel: 8, + timeDomainMode: 'fixed', + }, + } + + if (this._gantt) { + this._gantt.redraw() + } else { + this._gantt = gantt(ganttConfig) + } + } +} diff --git a/rt_dashboard/javascript/src/components/history/HistoryPage.html b/rt_dashboard/javascript/src/components/history/HistoryPage.html new file mode 100644 index 00000000..48426d38 --- /dev/null +++ b/rt_dashboard/javascript/src/components/history/HistoryPage.html @@ -0,0 +1,18 @@ + + + + + diff --git a/rt_dashboard/javascript/src/components/history/HistoryPage.js b/rt_dashboard/javascript/src/components/history/HistoryPage.js new file mode 100644 index 00000000..1bb645c7 --- /dev/null +++ b/rt_dashboard/javascript/src/components/history/HistoryPage.js @@ -0,0 +1,43 @@ +import templateHtml from './HistoryPage.html' +import styles from '../../../styles/main.scss' +import { loadTemplate, whenUpgraded } from '../../utils/dom' +import { getHistory } from '../../api' + +export default class HistoryPage extends HTMLElement { + constructor () { + super() + + this.attachShadow({ mode: 'open' }) + + loadTemplate(this.shadowRoot, templateHtml, styles) + + this._chart = this.shadowRoot.getElementById('chart') + + this._controller = new AbortController() + + this.shadowRoot.getElementById('zoom-in').addEventListener('click', () => this._chart.zoomIn()) + this.shadowRoot.getElementById('zoom-out').addEventListener('click', () => this._chart.zoomOut()) + this.shadowRoot.getElementById('pan-left').addEventListener('click', () => this._chart.panLeft()) + this.shadowRoot.getElementById('pan-right').addEventListener('click', () => this._chart.panRight()) + } + + async fetchData () { + if (process.env.NODE_ENV === 'production') { + return getHistory({ signal: this._controller.signal }) + } + return import('./history').then((data) => data.default || data) + } + + async connectedCallback () { + const data = await this.fetchData() + if (data) { + await whenUpgraded(this._chart) + this._chart.setHistoryData(data) + } + } + + disconnectedCallback () { + // cancel the pending request if any + this._controller.abort() + } +} diff --git a/rt_dashboard/javascript/src/components/history/gantt.js b/rt_dashboard/javascript/src/components/history/gantt.js new file mode 100644 index 00000000..8c46f75a --- /dev/null +++ b/rt_dashboard/javascript/src/components/history/gantt.js @@ -0,0 +1,406 @@ +import { timeHour, timeSecond } from 'd3-time' +import { scaleBand, scaleTime } from 'd3-scale' +import { axisBottom, axisLeft } from 'd3-axis' +import { timeFormat } from 'd3-time-format' +import { event, select } from 'd3-selection' +import 'd3-transition' + +/** + * + * @param config + * @returns {any} + */ +export default function ganttOuter (config) { + var eventList = [] + + var eventTypes + var eventStyleClasses = ['event'] + var eventStyleCount + + var height + var width + var margin + + var minDate = new Date() + var maxDate = new Date() + + var currentViewBeginTime// = d3.time.day.offset(getEndDate(), -1); + var currentViewEndTime//= getEndDate(); + gantt.getCurrentViewCenterTime = function () { + var centerTime = timeSecond.offset(currentViewBeginTime, (((currentViewEndTime - currentViewBeginTime) / 2) / 1000)) + return (centerTime) + } + + var zoomLevels + var currentZoomLevel + + var tickFormat + + if (config != null) { + eventList = config.eventSettings.eventList + eventTypes = config.eventSettings.eventTypes + eventStyleClasses = config.eventSettings.eventStyleClassList + eventStyleCount = eventStyleClasses.length + + setMinMaxDate(eventList) + + margin = config.sizing.margin + height = config.sizing.height - margin.top - margin.bottom - 5 + width = config.sizing.width - margin.right - margin.left - 5 + if (config.eventSettings.eventTypes.length > 9) { + height = config.eventSettings.eventTypes.length * 40 + } + + currentViewBeginTime = timeHour.offset(minDate, -1) + currentViewEndTime = timeHour.offset(maxDate, +1) + + currentZoomLevel = config.timeDomainSettings.startingZoomLevel + + zoomLevels = config.timeDomainSettings.zoomLevels + + tickFormat = config.timeDomainSettings.startingTimeFormat + + var x = scaleTime().domain([currentViewBeginTime, currentViewEndTime]).range([0, width]).clamp(true) + + var y = scaleBand().domain(eventTypes).rangeRound([0, height - margin.top - margin.bottom], 0.1) + + var xAxis = axisBottom(x).tickFormat(timeFormat(tickFormat)) // .tickSubdivide(true) + .tickSize(8).tickPadding(8) + + var yAxis = axisLeft(y).tickSize(0) + } + + //= ========================================================================================================================= + // Other + // -------------------------------------------------------------------------------------------------------------------------- + var keyFunction = function (d) { + return d.startDate + d.taskName + d.endDate + } + + var rectTransform = function (d) { + return 'translate(' + x(d.startDate) + ',' + y(d.taskName) + ')' + } + + function setMinMaxDate (eventList) { + eventList.sort(function (a, b) { + return a.endDate - b.endDate + }) + maxDate = eventList[eventList.length - 1].endDate + eventList.sort(function (a, b) { + return a.startDate - b.startDate + }) + minDate = eventList[0].startDate + } + + var tooltipdiv = select('body').append('div') + .attr('class', 'tooltip') + .style('opacity', 0) + + // var drawControls = function () { + // select('#' + config.sizing.location).append('div') + // .attr('id', config.sizing.location + '-Controls') + // .attr('style', 'margin-left:' + width / 2 + 'px; width:400px;') + // select('#' + config.sizing.location + '-Controls') + // // .append("button") + // .append('i') + // .attr('class', 'fa fa-arrow-left fa-3x nav') + // .attr('style', 'margin-left:20px; border: 2px solid; border-radius: 10px; padding:3px 5px 3px 5px') + // .attr('onclick', config.sizing.location + ".panView('left',.30)") + // select('#' + config.sizing.location + '-Controls') + // .append('i') + // .attr('class', 'fa fa-arrow-right fa-3x') + // .attr('style', 'margin-left:20px; border: 2px solid; border-radius: 10px; padding:3px 5px 3px 5px') + // .attr('onclick', config.sizing.location + ".panView('right',.30)") + // select('#' + config.sizing.location + '-Controls') + // .append('i') + // .attr('class', 'fa fa-search-plus fa-3x') + // .attr('style', 'margin-left:20px; border: 2px solid; border-radius: 10px; padding:3px 5px 3px 5px') + // .attr('onclick', config.sizing.location + ".zoomInOut('in')") + // select('#' + config.sizing.location + '-Controls') + // .append('i') + // .attr('class', 'fa fa-search-minus fa-3x') + // .attr('style', 'margin-left:20px; border: 2px solid; border-radius: 10px; padding:3px 5px 3px 5px') + // .attr('onclick', config.sizing.location + ".zoomInOut('out')") + // } + + //= ========================================================================================================================= + // Define sizing and axis + // -------------------------------------------------------------------------------------------------------------------------- + + var initAxis = function () { + x = scaleTime().domain([currentViewBeginTime, currentViewEndTime]).range([0, width]).clamp(true) + y = scaleBand().domain(eventTypes).rangeRound([0, (height) - margin.top - margin.bottom], 0.2) + xAxis = axisBottom(x).tickFormat(timeFormat(tickFormat)) // .tickSubdivide(true) + .tickSize(8).tickPadding(8) + + yAxis = axisLeft(y).tickSize(0) + } + + //= ========================================================================================================================= + // Sizing + // -------------------------------------------------------------------------------------------------------------------------- + + gantt.width = function (value) { + if (!arguments.length) { return width } + width = +value + return gantt + } + + gantt.height = function (value) { + if (!arguments.length) { return height } + height = +value + return gantt + } + + gantt.margin = function (value) { + if (!arguments.length) { return margin } + margin = value + return gantt + } + + // -------------------------------------------------------------------------------------------------------------------------- + + /** + * @param {string} + * vale The value can be "fit" - the domain fits the data or + * "fixed" - fixed domain. + */ + + gantt.taskTypes = function (value) { + if (!arguments.length) { return eventTypes } + eventTypes = value + return gantt + } + + gantt.eventStyles = function (value) { + if (!arguments.length) { return eventStyleClasses } + eventStyleClasses = value + return gantt + } + + gantt.tickFormat = function (value) { + if (!arguments.length) { return tickFormat } + tickFormat = value + return gantt + } + + // function getEndDate () { + // var lastEndDate = Date.now() + // if (eventList.length > 0) { + // lastEndDate = eventList[eventList.length - 1].endDate + // } + // return lastEndDate + // } + + //= ========================================================================================================================= + // Draw / ReDraw Code + // -------------------------------------------------------------------------------------------------------------------------- + + function gantt (eventList) { + initAxis() + + gantt._root = select(config.root) + gantt._svgRoot = gantt._root + .append('svg') + .attr('class', 'chart') + .attr('id', config.sizing.location + '-ChartId') + .attr('width', width + margin.left + margin.right) + .attr('height', height + margin.top + margin.bottom) + + gantt._svg = gantt._svgRoot.append('g') + .attr('class', 'gantt-chart') + .attr('width', width + margin.left + margin.right) + .attr('height', height + margin.top + margin.bottom) + .attr('transform', 'translate(' + margin.left + ', ' + margin.top + ')') + + gantt._svg.selectAll('.chart') + .data(eventList, keyFunction).enter() + .append('rect') + .attr('rx', 5) + .attr('ry', 5) + .attr('data-status', function (d) { + return d.status + }) + .attr('class', function (d) { + var styleId = d.status % eventStyleCount + if (eventStyleClasses[styleId] == null) { + return 'bar' + } + return eventStyleClasses[styleId] + }) + .attr('y', 0) + .attr('transform', rectTransform) + .attr('height', function (d) { + return y.bandwidth() + }) + .attr('width', function (d) { + return (x(d.endDate) - x(d.startDate)) + }) + .on('mouseover', function (d) { + if (config.eventSettings.enableToolTips && d.toolTipHTML) { + tooltipdiv.transition() + .duration(200) + .style('opacity', 0.9) + tooltipdiv.html(d.toolTipHTML) + .style('left', (event.pageX) + 'px') + .style('top', (event.pageY - 28) + 'px') + } + }) + .on('mouseout', function (d) { + tooltipdiv.transition() + .duration(500) + .style('opacity', 0) + }) + .on('click', function (d) { + + }) + + gantt._x = gantt._svg.append('g') + .attr('class', 'x axis') + .attr('transform', 'translate(0, ' + (height - margin.top - margin.bottom) + ')') + + gantt._x + .transition() + .call(xAxis) + + gantt._y = gantt._svg.append('g') + .attr('class', 'y axis') + + gantt._y + .transition() + .call(yAxis) + + return gantt + } + + gantt.redraw = function (eventList) { + initAxis() + + var rect = this._svg.selectAll('rect').data(eventList, keyFunction) + + rect.enter() + .insert('rect', ':first-child') + .attr('rx', 5) + .attr('ry', 5) + .attr('class', function (d) { + if (eventStyleClasses[d.status % eventStyleCount] == null) { + return 'bar' + } + return eventStyleClasses[d.status % eventStyleCount] + }) + .transition() + .attr('y', 0) + .attr('transform', rectTransform) + .attr('height', function (d) { + return y.bandwidth() + }) + .attr('width', function (d) { + return (x(d.endDate) - x(d.startDate)) + }) + + rect.transition() + .attr('transform', rectTransform) + .attr('height', function (d) { + return y.bandwidth() + }) + .attr('width', function (d) { + return (x(d.endDate) - x(d.startDate)) + }) + + rect.exit().remove() + + this._x.transition().call(xAxis) + this._y.transition().call(yAxis) + + return gantt + } + + //= ================================================================================================================ + // Time Domain Code + // ------------------------------------------------------------------------------------------------------------------ + gantt.zoomInOut = function (inOrOut) { + if (inOrOut === 'in') { + if (currentZoomLevel === 0) { + alert("can't zoom in anymore") + } else { + currentZoomLevel-- + } + } else if (inOrOut === 'out') { + if (currentZoomLevel === (zoomLevels.length - 1)) { + alert("Can't zoom out anymore") + } else { + currentZoomLevel++ + } + } else { + alert("Error: Not sure if you're trying to zoom in or out. Check zoom function call") + } + this.setCustomZoom(currentZoomLevel) + } + + gantt.setCustomZoom = function (zoomLevel) { + currentZoomLevel = zoomLevel + var currentCenterTime = this.getCurrentViewCenterTime() + currentViewBeginTime = timeSecond.offset(currentCenterTime, -this.convertZoomLevelToOffsetSeconds()) + currentViewEndTime = timeSecond.offset(currentCenterTime, this.convertZoomLevelToOffsetSeconds()) + // gantt.timeDomain([currentViewBeginTime, currentViewEndTime]); + this.tickFormat(this.determineTimeFormat(zoomLevels[currentZoomLevel].split(':')[1])) + this.redraw(eventList) + } + + gantt.convertZoomLevelToOffsetSeconds = function () { + var zoomLevelString = zoomLevels[currentZoomLevel] + var rangeNumber = zoomLevelString.split(':')[0] + var rangeScale = zoomLevelString.split(':')[1] + var secondsResult + switch (rangeScale) { + case 'day': + secondsResult = rangeNumber * 86400 + break + case 'hr': + secondsResult = rangeNumber * 3600 + break + case 'min': + secondsResult = rangeNumber * 60 + break + case 'sec': + secondsResult = rangeNumber + break + default: + secondsResult = 86400 + } + return Math.round(secondsResult / 2) + } + + gantt.determineTimeFormat = function (scale) { + switch (scale) { + case 'day': + return '%H:%M' + case 'hr': + return '%H:%M' + case 'min': + return '%H:%M:%S' + case 'sec': + return '%H:%M:%S' + default: + return '%H:%M' + } + } + // ------------------------------------------------------------------------------------------------------------------ + + gantt.panView = function (direction, percentMove) { + // gets the length in MS of X% of the current view. This will be used to determine how far to pan at a time + var shiftTimeLength = ((currentViewEndTime - currentViewBeginTime) * percentMove) + if (direction === 'left') { + shiftTimeLength = -shiftTimeLength + } + // convert our shift length to Sec and offset our current view start/end times. Once shifted, update the current view to these new times + var newStartTime = timeSecond.offset(currentViewBeginTime, (shiftTimeLength / 1000)) + var newEndTime = timeSecond.offset(currentViewEndTime, (shiftTimeLength / 1000)) + // this.timeDomain([ newStartTime , newEndTime ]); + currentViewBeginTime = newStartTime + currentViewEndTime = newEndTime + this.redraw(eventList) + } + + return gantt(eventList) +}; diff --git a/rt_dashboard/javascript/src/components/history/history.json b/rt_dashboard/javascript/src/components/history/history.json new file mode 100644 index 00000000..d69fbcfd --- /dev/null +++ b/rt_dashboard/javascript/src/components/history/history.json @@ -0,0 +1,78 @@ +{ + "rows": [ + { + "group": "maintenance.feature", + "subgroup": 0, + "start": 1585200000, + "end": 1585300000, + "title": "Maintenance of feature 1", + "type": "range" + }, + { + "group": "maintenance.feature", + "subgroup": 1, + "start": 1585220000, + "end": 1585400000, + "title": "Maintenance of feature 2", + "type": "range" + }, + { + "group": "accident.feature", + "subgroup": 0, + "start": 1585500000, + "end": 1585550000, + "title": "Accident of feature 1", + "type": "range" + }, + { + "group": "maintenance.feature", + "subgroup": 0, + "start": 1585600000, + "end": 1585650000, + "title": "Maintenance of feature 1", + "type": "range" + }, + { + "group": "point.run", + "subgroup": 0, + "start": "1585210000", + "title": "Run 1", + "type": "point" + }, + { + "group": "point.run", + "subgroup": 0, + "start": "1585220000", + "title": "Run 2", + "type": "point" + }, + { + "group": "point.run", + "subgroup": 0, + "start": "1585230000", + "title": "Run 3", + "type": "point" + }, + { + "group": "point.run", + "subgroup": 1, + "start": "1585215000", + "title": "Run 1", + "type": "point" + }, + { + "group": "point.run", + "subgroup": 1, + "start": "1585225000", + "title": "Run 2", + "type": "point" + }, + { + "group": "point.run", + "subgroup": 1, + "start": "1585235000", + "title": "Run 3", + "type": "point" + } + ] +} diff --git a/rt_dashboard/javascript/src/components/history/index.js b/rt_dashboard/javascript/src/components/history/index.js new file mode 100644 index 00000000..e2404107 --- /dev/null +++ b/rt_dashboard/javascript/src/components/history/index.js @@ -0,0 +1,5 @@ +import HistoryPage from './HistoryPage' +import HistoryChart from './HistoryChart' + +customElements.define('rt-history', HistoryPage) +customElements.define('rt-history-chart', HistoryChart) diff --git a/rt_dashboard/javascript/src/components/pager/index.js b/rt_dashboard/javascript/src/components/pager/index.js new file mode 100644 index 00000000..a191882f --- /dev/null +++ b/rt_dashboard/javascript/src/components/pager/index.js @@ -0,0 +1,3 @@ +import Pager from './pager' + +window.customElements.define('pager-component', Pager) diff --git a/rt_dashboard/javascript/src/components/pager/pager.html b/rt_dashboard/javascript/src/components/pager/pager.html new file mode 100644 index 00000000..99204abf --- /dev/null +++ b/rt_dashboard/javascript/src/components/pager/pager.html @@ -0,0 +1,4 @@ + + + diff --git a/rt_dashboard/javascript/src/components/pager/pager.js b/rt_dashboard/javascript/src/components/pager/pager.js new file mode 100644 index 00000000..8e39415d --- /dev/null +++ b/rt_dashboard/javascript/src/components/pager/pager.js @@ -0,0 +1,104 @@ +import templateHtml from './pager.html' +import styles from '../../../styles/main.scss' +import { loadTemplate, mapDataToElements, appendElement } from '../../utils/dom' + +export default class Pager extends HTMLElement { + get pagination () { + return this.paging + } + + /** + * Sets current paggination settings + * @param {object} value New paggination settings + * @param {{ number: number, url: string }[]} value.pages_in_window Array of page numbers + * @param {string} value.next_page Url to the next page data + * @param {string} value.prev_page Url to the previous page data + * @param {number} value.currentPage Current page number + */ + set pagination (value) { + this.paging = value + this.update(value) + } + + constructor () { + super() + + loadTemplate(this.attachShadow({ mode: 'open' }), templateHtml, styles) + + this.mapToPageLinks = this.mapToPageLinks.bind(this) + this.onPageClicked = this.onPageClicked.bind(this) + + this.current = 1 + } + + disconnectedCallback () { + this.removeEventListeners() + } + + onPageClicked (e) { + const { target: pageLink } = e + const number = pageLink.page + this.current = number + + this.dispatchEvent( + new CustomEvent('change', { + detail: { number }, + bubbles: true, + }), + ) + + e.preventDefault() + return false + } + + update ({ + pages_in_window: pagesArray, + next_page: nextPage, + prev_page: prevPage, + currentPage, + }) { + let nextPageNum = null + let prevPageNum = null + if (prevPage) { + prevPageNum = prevPage.url.split('/').pop() + } + if (nextPage) { + nextPageNum = nextPage.url.split('/').pop() + } + if (currentPage) { + this.current = currentPage + } + + const pages = [ + { text: '«', number: prevPageNum }, + ...pagesArray.map(({ number }) => ({ text: number, number })), + { text: '»', number: nextPageNum }, + ] + this.pageLinks = [] + this.removeEventListeners() + + const pagesList = this.shadowRoot.querySelector('ul.pagination') + mapDataToElements(pagesList, pages, this.mapToPageLinks) + } + + removeEventListeners () { + this.pageLinks.forEach(l => { + l.removeEventListener('click', this.onPageClicked) + }) + this.pageLinks = [] + } + + mapToPageLinks (parent, page) { + const disabledClass = page.number ? '' : 'disabled' + const activeClass = `${this.current}` === `${page.number}` ? 'active' : '' + const li = appendElement('li', parent, `page-item ${disabledClass} ${activeClass}`) + + const link = appendElement('a', li, 'page-link', page.text) + if (page.number) { + link.setAttribute('href', `#${page.number}`) + link.page = page.number + link.addEventListener('click', this.onPageClicked) + this.pageLinks.push(link) + } + } +} diff --git a/rt_dashboard/javascript/src/components/queues/index.js b/rt_dashboard/javascript/src/components/queues/index.js new file mode 100644 index 00000000..c9f15fdc --- /dev/null +++ b/rt_dashboard/javascript/src/components/queues/index.js @@ -0,0 +1,3 @@ +import Queues from './queues' + +window.customElements.define('queues-component', Queues) diff --git a/rt_dashboard/javascript/src/components/queues/queues.html b/rt_dashboard/javascript/src/components/queues/queues.html new file mode 100644 index 00000000..88ac1971 --- /dev/null +++ b/rt_dashboard/javascript/src/components/queues/queues.html @@ -0,0 +1,19 @@ +
+

Queues

+

This list below contains all the registered queues with the number of tasks currently + in the queue. Select a queue from above to view all tasks currently pending on the queue.

+ + + + + + + + + + + + + +
QueueTasks
Loading...
+
diff --git a/rt_dashboard/javascript/src/components/queues/queues.js b/rt_dashboard/javascript/src/components/queues/queues.js new file mode 100644 index 00000000..a6190f91 --- /dev/null +++ b/rt_dashboard/javascript/src/components/queues/queues.js @@ -0,0 +1,107 @@ +import templateHtml from './queues.html' +import styles from '../../../styles/main.scss' +import { appendElement, loadTemplate, mapDataToElements, appendNoDataRow } from '../../utils/dom' +import { getQueues } from '../../api' + +export default class Queues extends HTMLElement { + constructor () { + super() + + loadTemplate(this.attachShadow({ mode: 'open' }), templateHtml, styles) + + this.queuesLinks = [] + + this.onQueueClicked = this.onQueueClicked.bind(this) + this.onRefresh = this.onRefresh.bind(this) + this.refreshQueues = this.refreshQueues.bind(this) + this.removeQueueClickHandlers = this.removeQueueClickHandlers.bind(this) + } + + connectedCallback () { + document.addEventListener('refresh', this.onRefresh) + this.refreshQueues() + } + + disconnectedCallback () { + document.removeEventListener('refresh', this.onRefresh) + this.removeQueueClickHandlers() + } + + async refreshQueues () { + const data = await getQueues() + if (!data) { + return + } + const { queues } = data + const tbody = this.shadowRoot.querySelector('tbody') + this.removeQueueClickHandlers() + + if (!queues || queues.length <= 0) { + appendNoDataRow(tbody, 'No queues.', 2) + return + } + + mapDataToElements(tbody, queues, this.fillRow) + Array.from(this.shadowRoot.querySelectorAll('td a')).forEach(link => { + this.queuesLinks.push(link) + link.addEventListener('click', this.onQueueClicked) + }) + + let [first] = queues + first = queues.find(q => q.name.startsWith('[running')) ?? first + const selectedQueue = this.selectedQueue ? queues.find(q => q.name === this.selectedQueue.name) : first + this.sendChangedEvent(selectedQueue) + } + + onRefresh () { + this.refreshQueues() + } + + removeQueueClickHandlers () { + this.queuesLinks.forEach(link => { + link.removeEventListener('click', this.onQueueClicked) + }) + this.queuesLinks = [] + } + + fillRow (parent, { name, url, count }) { + const row = appendElement('tr', parent) + row.setAttribute('data-role', 'queue') + + const td = appendElement('td', row) + appendElement('i', td, 'fas fa-inbox') + + const link = appendElement('a', td, 'ml-1', name) + link.setAttribute('name', name) + link.setAttribute('data-count', count) + link.setAttribute('href', url) + + appendElement('td', row, 'narrow', count) + } + + onQueueClicked (e) { + const { target: selectedQueue } = e + + this.sendChangedEvent(selectedQueue) + e.preventDefault() + return false + } + + sendChangedEvent ({ name: queueName, count }) { + if (this.selectedQueue && this.selectedQueue.name === queueName && this.selectedQueue.count === count) { + return + } + + this.dispatchEvent( + new CustomEvent('selectedQueueChange', { + detail: { queueName, count }, + bubbles: true, + }), + ) + + this.selectedQueue = { + name: queueName, + count, + } + } +} diff --git a/rt_dashboard/javascript/src/components/rt-dashboard/index.js b/rt_dashboard/javascript/src/components/rt-dashboard/index.js new file mode 100644 index 00000000..5760bb6c --- /dev/null +++ b/rt_dashboard/javascript/src/components/rt-dashboard/index.js @@ -0,0 +1,3 @@ +import RtDashboard from './rt-dashboard' + +window.customElements.define('rt-dashboard', RtDashboard) diff --git a/rt_dashboard/javascript/src/components/rt-dashboard/rt-dashboard.html b/rt_dashboard/javascript/src/components/rt-dashboard/rt-dashboard.html new file mode 100644 index 00000000..578a3249 --- /dev/null +++ b/rt_dashboard/javascript/src/components/rt-dashboard/rt-dashboard.html @@ -0,0 +1,9 @@ +
+ + + +
diff --git a/rt_dashboard/javascript/src/components/rt-dashboard/rt-dashboard.js b/rt_dashboard/javascript/src/components/rt-dashboard/rt-dashboard.js new file mode 100644 index 00000000..4c1a320b --- /dev/null +++ b/rt_dashboard/javascript/src/components/rt-dashboard/rt-dashboard.js @@ -0,0 +1,43 @@ +import templateHtml from './rt-dashboard.html' +import styles from '../../../styles/main.scss' +import { loadTemplate } from '../../utils/dom' + +const DASHBOARD = 'dashboard-component' +const HISTORY = 'rt-history' + +export default class RtDashboard extends HTMLElement { + constructor () { + super() + loadTemplate(this.attachShadow({ mode: 'open' }), templateHtml, styles) + + this.tabClicked = this.tabClicked.bind(this) + } + + connectedCallback () { + this.navLinks = [] + Array.from(this.shadowRoot.querySelectorAll('li a.nav-link')).forEach(link => { + this.navLinks.push(link) + link.addEventListener('click', this.tabClicked) + }) + + this.panels = [] + const panels = [DASHBOARD, HISTORY] + panels.forEach(panel => { + this.panels.push(this.shadowRoot.querySelector(panel)) + }) + } + + disconnectedCallback () { + this.navLinks.forEach(link => { + link.removeEventListener('click', this.tabClicked) + }) + } + + tabClicked (e) { + const { target: activeLink } = e + this.navLinks.forEach(l => { l.classList.remove('active') }) + activeLink.classList.add('active') + this.panels.forEach(p => { p.hidden = true }) + this.panels.find(p => p.tagName.toLowerCase() === activeLink.name).hidden = false + } +} diff --git a/rt_dashboard/javascript/src/components/tasks/index.js b/rt_dashboard/javascript/src/components/tasks/index.js new file mode 100644 index 00000000..255a8a47 --- /dev/null +++ b/rt_dashboard/javascript/src/components/tasks/index.js @@ -0,0 +1,3 @@ +import Tasks from './tasks' + +window.customElements.define('tasks-component', Tasks) diff --git a/rt_dashboard/javascript/src/components/tasks/tasks.html b/rt_dashboard/javascript/src/components/tasks/tasks.html new file mode 100644 index 00000000..ad353afb --- /dev/null +++ b/rt_dashboard/javascript/src/components/tasks/tasks.html @@ -0,0 +1,38 @@ +
+

+ Tasks on +

+

+ + + + + This list below contains all the registered tasks on queue + {{ queue.name }}, sorted by age + + +

+ + + + + + + + + + + + + + +
NameAgeActions
Loading...
+ + +
diff --git a/rt_dashboard/javascript/src/components/tasks/tasks.js b/rt_dashboard/javascript/src/components/tasks/tasks.js new file mode 100644 index 00000000..3929fc80 --- /dev/null +++ b/rt_dashboard/javascript/src/components/tasks/tasks.js @@ -0,0 +1,232 @@ +import html from './tasks.html' +import styles from '../../../styles/main.scss' +import { + loadTemplate, + appendElement, + appendNoDataRow, + mapDataToElements, + removeChildNodes, + createElement, +} from '../../utils/dom' +import { getJobs, cancelJob, deleteQueue, emptyQueue } from '../../api' +import { duration, relative } from '../../utils/utils' + +export default class Tasks extends HTMLElement { + get queueInfo () { + return this.queueInfo_ + } + + set queueInfo (val) { + this.queueInfo_ = val + this.queueInfoChanged(val) + } + + constructor () { + super() + loadTemplate(this.attachShadow({ mode: 'open' }), html, styles) + + this.mapToRow = this.mapToRow.bind(this) + this.onEmptyClicked = this.onEmptyClicked.bind(this) + this.onDeleteClicked = this.onDeleteClicked.bind(this) + this.selectedPageChange = this.selectedPageChange.bind(this) + } + + connectedCallback () { + this.emptyBtn = this.shadowRoot.querySelector('p.intro #empty-btn') + this.deleteBtn = this.shadowRoot.querySelector('p.intro #delete-btn') + this.emptyBtn.addEventListener('click', this.onEmptyClicked) + this.deleteBtn.addEventListener('click', this.onDeleteClicked) + + const pagerComponent = this.shadowRoot.querySelector('pager-component') + pagerComponent.addEventListener('change', this.selectedPageChange) + } + + disconnectedCallback () { + this.removeEventListener.addEventListener('click', this.onEmptyClicked) + this.removeEventListener.addEventListener('click', this.onDeleteClicked) + + this.removeTableClickListeners() + + const pagerComponent = this.shadowRoot.querySelector('pager-component') + pagerComponent.removeEventListener('selectedPageChanged', this.selectedPageChange) + } + + selectedPageChange ({ detail: { number } }) { + this.loadQueueTasks(this.queueInfo_.queue, number) + } + + queueInfoChanged (queueInfo) { + const { queue, count } = queueInfo + + this.queue = queue + this.count = count + this.updateTitles(queue, count) + this.toggleEmptyBtns(queue, count) + + this.loadQueueTasks(queue) + } + + onEmptyClicked () { + emptyQueue(this.queue) + } + + onDeleteClicked (e) { + deleteQueue(this.queue) + } + + updateTitles (queue, count) { + const queueName = this.shadowRoot.querySelector('h1 strong') + queueName.innerHTML = queue + if (queue === '[failed]') { + queueName.classList.add('failed') + } else { + queueName.classList.remove('failed') + } + + const queueNameSubTitle = this.shadowRoot.querySelector('p.intro strong') + queueNameSubTitle.innerHTML = queue + + const orderDescInfo = this.shadowRoot.querySelector('p.intro #oldest-on-top') + const orderAscInfo = this.shadowRoot.querySelector('p.intro #newes-on-top') + orderDescInfo.hidden = queue.startsWith('[') + orderAscInfo.hidden = !queue.startsWith('[') + } + + toggleEmptyBtns (queue, count) { + const emptyBtn = this.shadowRoot.querySelector('p.intro #empty-btn') + const deleteBtn = this.shadowRoot.querySelector('p.intro #delete-btn') + emptyBtn.hidden = count <= 0 + deleteBtn.hidden = count > 0 || (queue.startsWith('[')) + } + + async loadQueueTasks (queue, page = 1) { + this.removeTableClickListeners() + + const tbody = this.shadowRoot.querySelector('tbody') + if (tbody.childNodes.length === 0) { + removeChildNodes(tbody) + appendNoDataRow(tbody, 'Loading...', 3) + } + + const data = await getJobs(queue, page) + if (!data) { + return + } + const { jobs, pagination } = data + if (!jobs || jobs.length <= 0) { + appendNoDataRow(tbody, 'No jobs.', 3) + return + } + mapDataToElements(tbody, jobs, this.mapToRow) + + Array.from(this.shadowRoot.querySelectorAll('td a')).forEach(link => { + this.cancelLinks.push(link) + link.addEventListener('click', this.onCancelClicked) + }) + + this.updatePager(pagination, page) + } + + removeTableClickListeners () { + this.cancelLinks = this.cancelLinks || [] + this.cancelLinks.forEach(link => { + link.removeEventListener('click', this.onQueueClicked) + }) + + this.cancelLinks = [] + } + + onCancelClicked (e) { + const { target: cancelTaskBtn } = e + cancelJob(cancelTaskBtn.getAttribute('data-jobid')) + e.preventDefault() + } + + mapToRow (parent, { + id, + description, + origin, + worker, + status, + error_message: error, + enqueued_at: enqueued, + started_at: started, + ended_at: ended, + }) { + const row = createElement('tr') + row.setAttribute('data-role', 'job') + row.setAttribute('data-job-id', id) + + this.mapFirstColumn(row, { + id, + description, + origin, + worker, + status, + error, + enqueued, + started, + ended, + }) + + const col2 = appendElement('td', row) + if (status === 'running') { + appendElement('span', col2, 'creation_date', `${duration(started, new Date().getTime())}`) + } else { + appendElement('span', col2, 'creation_date', `${relative(ended ?? enqueued)}`) + } + + const col3 = appendElement('td', row, 'actions narrow') + const actionLink = appendElement('a', col3, 'btn btn-outline-secondary btn-sm mx-auto', ' Cancel') + actionLink.setAttribute('data-role', 'cancel-job-btn') + actionLink.setAttribute('data-jobid', id) + + parent.appendChild(row) + } + + mapFirstColumn (row, { + id, + description, + origin, + worker, + status, + error, + enqueued, + started, + ended, + }) { + const td = appendElement('td', row) + appendElement('i', td, 'fas fa-file') + appendElement('span', td, 'description ml-1', description) + + if (this.queue.startsWith('[')) { + let originInfo = ` from ${origin}` + if (worker) { + originInfo += ` running on ${worker}` + } + appendElement('span', td, 'origin', originInfo) + } + + appendElement('div', td, 'job_id d-block', id) + + if (status === 'running') { + appendElement('span', td, 'end_date', `Enqueued ${relative(enqueued)}`) + } else if (status === 'failed') { + appendElement('span', td, 'end_date', + `Enqueued ${relative(enqueued)}, failed ${relative(ended)}, ran for ${duration(started, ended)}`) + appendElement('pre', td, 'exc_info', `
${error}
`) + } else if (status === 'finished') { + appendElement('span', td, 'end_date', + `Enqueued ${relative(enqueued)}, finished ${relative(ended)}, ran for ${duration(started, ended)}`) + } + } + + updatePager (pagination, currentPage = 1) { + const pagerComponent = this.shadowRoot.querySelector('pager-component') + pagerComponent.pagination = { + pages_in_window: [{ number: 1 }], + ...pagination, + currentPage, + } + } +} diff --git a/rt_dashboard/javascript/src/components/workers/index.js b/rt_dashboard/javascript/src/components/workers/index.js new file mode 100644 index 00000000..8794ca71 --- /dev/null +++ b/rt_dashboard/javascript/src/components/workers/index.js @@ -0,0 +1,3 @@ +import Workers from './workers' + +window.customElements.define('workers-component', Workers) diff --git a/rt_dashboard/javascript/src/components/workers/workers.html b/rt_dashboard/javascript/src/components/workers/workers.html new file mode 100644 index 00000000..9331999a --- /dev/null +++ b/rt_dashboard/javascript/src/components/workers/workers.html @@ -0,0 +1,32 @@ +
+ +

Workers

+ + +

+ + No workers registered! +

+ + + + + + + + + + + + + + +
StateWorkerQueues
Loading...
+ + + +
diff --git a/rt_dashboard/javascript/src/components/workers/workers.js b/rt_dashboard/javascript/src/components/workers/workers.js new file mode 100644 index 00000000..1dcefa3f --- /dev/null +++ b/rt_dashboard/javascript/src/components/workers/workers.js @@ -0,0 +1,80 @@ +import templateHtml from './workers.html' +import styles from '../../../styles/main.scss' +import { + loadTemplate, + mapDataToElements, + appendElement, + appendNoDataRow, +} from '../../utils/dom' +import { getWorkers } from '../../api' + +export default class Workers extends HTMLElement { + constructor () { + super() + loadTemplate(this.attachShadow({ mode: 'open' }), templateHtml, styles) + + this.onWorkersBtnClick = this.onWorkersBtnClick.bind(this) + this.onRefresh = this.onRefresh.bind(this) + this.refreshWorkers = this.refreshWorkers.bind(this) + } + + connectedCallback () { + const btn = this.shadowRoot.querySelector('button#workers-btn') + btn.addEventListener('click', this.onWorkersBtnClick) + + document.addEventListener('refresh', this.onRefresh) + + this.refreshWorkers() + } + + disconnectedCallback () { + this.removeEventListener('click', this.onWorkersBtnClick) + document.removeEventListener('refresh', this.onRefresh) + } + + mapToRow (parent, { name, state, queues }) { + const row = appendElement('tr', parent) + row.setAttribute('data-role', 'worker') + + const stateIcon = state === 'busy' ? 'play' : 'pause' + const td = appendElement('td', row) + appendElement('i', td, `fas fa-${stateIcon}`) + + appendElement('td', row, null, name) + + appendElement('td', row, null, queues.join(',')) + } + + async refreshWorkers () { + const data = await getWorkers() + if (!data) { + return + } + const { workers } = data + const tbody = this.shadowRoot.querySelector('tbody') + const workersCount = this.shadowRoot.querySelector('#workers-count span') + + if (!workers || workers.length <= 0) { + workersCount.innerHTML = 'No workers registered!' + appendNoDataRow(tbody, 'No workers.', 3) + return + } + + mapDataToElements(tbody, workers, this.mapToRow) + workersCount.innerHTML = workers.length + ' workers registered' + } + + onRefresh () { + this.refreshWorkers() + } + + onWorkersBtnClick (event) { + const workersTable = this.shadowRoot.querySelector('#workers') + const isHidden = workersTable.getAttribute('hidden') + if (isHidden) { + workersTable.removeAttribute('hidden') + } else { + workersTable.setAttribute('hidden', true) + } + } +} diff --git a/rt_dashboard/javascript/src/utils/dom.js b/rt_dashboard/javascript/src/utils/dom.js new file mode 100644 index 00000000..0617123d --- /dev/null +++ b/rt_dashboard/javascript/src/utils/dom.js @@ -0,0 +1,53 @@ + +export const appendElement = (tag, parent, classes = '', innerHtml = null) => { + const element = createElement(tag, classes, innerHtml) + parent.appendChild(element) + return element +} + +export const createElement = (tag, classes = '', innerHtml = null) => { + const element = document.createElement(tag) + if (classes) { + classes.split(' ').filter(c => c && c.length > 0) + .forEach((className) => element.classList.add(className)) + } + element.innerHTML = innerHtml + return element +} + +export const loadTemplate = (parent, templateHtml, styles) => { + const template = document.createElement('template') + template.innerHTML = `${styles != null ? `` : ''}${templateHtml}` + + parent.appendChild(template.content.cloneNode(true)) +} + +export const removeChildNodes = (node) => { + Array.from(node.childNodes).forEach((el) => { + node.removeChild(el) + }) +} + +export const mapDataToElements = (parent, data, itemMapper) => { + const parentTemplate = createElement(parent.tagName) + + data.forEach(item => itemMapper(parentTemplate, item)) + + parent.innerHTML = parentTemplate.innerHTML +} + +export const appendNoDataRow = (parent, text, colspan) => { + Array.from(parent.childNodes).forEach((el) => { + parent.removeChild(el) + }) + + const row = appendElement('tr', parent) + const td = appendElement('td', row) + td.innerHTML = text + td.setAttribute('colspan', colspan) +} + +export const whenUpgraded = async (node) => { + await customElements.whenDefined(node.localName) + customElements.upgrade(node) +} diff --git a/rt_dashboard/javascript/src/utils/utils.js b/rt_dashboard/javascript/src/utils/utils.js new file mode 100644 index 00000000..f57e3253 --- /dev/null +++ b/rt_dashboard/javascript/src/utils/utils.js @@ -0,0 +1,21 @@ +import { formatDistanceStrict, parseISO } from 'date-fns' + +export const duration = (startDate, endDate) => { + let start = startDate + let end = endDate + if (typeof startDate === 'string') { + start = parseISO(startDate) + } + if (typeof endDate === 'string') { + end = parseISO(endDate) + } + return `${formatDistanceStrict(start, end)}` +} + +export const relative = (startDate) => { + let start = startDate + if (typeof startDate === 'string') { + start = parseISO(startDate) + } + return `${formatDistanceStrict(start, new Date(), { addSuffix: true })}` +} diff --git a/rt_dashboard/javascript/src/utils/utils.test.js b/rt_dashboard/javascript/src/utils/utils.test.js new file mode 100644 index 00000000..d61ed2a0 --- /dev/null +++ b/rt_dashboard/javascript/src/utils/utils.test.js @@ -0,0 +1,48 @@ +/* globals describe, test, expect */ +import { duration, relative } from './utils' + +describe('duration', () => { + test('handles miliseconds args ', () => { + const interval = 5 * 1000 + const start = new Date().getTime() - interval + const end = new Date().getTime() + + expect(duration(start, end)).toBe('5 seconds') + }) + + test('handles ISO string arg', () => { + const interval = 5 * 1000 + const start = new Date(new Date().getTime() - interval).toISOString() + const end = new Date().toISOString() + + expect(duration(start, end)).toBe('5 seconds') + }) + + test('handles Date args ', () => { + const start = new Date() + const end = new Date() + + expect(duration(start, end)).toBe('0 seconds') + }) +}) + +describe('relative', () => { + test('handles miliseconds arg', () => { + const interval = 5 * 1000 + const start = new Date().getTime() - interval + + expect(relative(start)).toBe('5 seconds ago') + }) + + test('handles Date arg', () => { + const start = new Date() + + expect(relative(start)).toBe('0 seconds ago') + }) + + test('handles ISO string arg', () => { + const start = new Date().toISOString() + + expect(relative(start)).toBe('0 seconds ago') + }) +}) diff --git a/rt_dashboard/javascript/styles/main.scss b/rt_dashboard/javascript/styles/main.scss new file mode 100644 index 00000000..f0ce3c1e --- /dev/null +++ b/rt_dashboard/javascript/styles/main.scss @@ -0,0 +1,135 @@ +/* @override http://localhost:9181/static/css/main.css */ + +@import '~bootstrap'; + +$fa-font-path: "~@fortawesome/fontawesome-free/webfonts"; +@import "~@fortawesome/fontawesome-free/scss/fontawesome"; +@import "~@fortawesome/fontawesome-free/scss/solid"; +@import '~@fortawesome/fontawesome-free/scss/regular'; + +.container { + background: white; + margin-top: 0; + width: 100%; + max-width: 100%; +} + +#content { + background: yellow; +} + +.section +{ + padding: 2em; +} + +p.intro { + min-height: 2em; + padding-top: .8em; + color: #888; +} + +p.fixed.intro { + height: 5em; +} + +h1 { + font-family: 'Helvetica Neue', helvetica, sans-serif; + font-weight: 300; + color: steelblue; + text-shadow: 0 -1px #229; +} + +h1 strong { + font-weight: 500; +} + +h1 strong.failed { + color: red; + text-shadow: 0 -1px #922; +} + +table, table tr, table tr td { + background-color: snow; +} + +table tbody tr td.narrow { + width: 100px; +} + +table#queues { + /*width: 300px;*/ +} + +table#jobs td.actions a { + font-size: 80%; +} + +table#jobs td.actions { + width: auto; +} + +table#jobs span.description { + color: #555; + font-weight: 600; +} + +table#jobs span.origin { + color: #888; +} + +table#jobs div.job_id { + font-size: 70%; + color: gray; + margin-left: 24px; +} + +table#jobs pre.exc_info { + color: #555; + margin-left: 24px; + border-left: 2px solid darkorange; + background-color: lightgoldenrodyellow; +} + +table#jobs span.creation_date { + font-size: 80%; +} + +table#jobs span.end_date { + font-size: 80%; + margin-left: 24px; +} + +table#workers { + /*width: 600px;*/ +} + +table tbody tr td { + vertical-align: middle; +} + +table tr.failed { + color: red; + border-top: 2px solid red; +} + +table tr.failed a { + color: red; +} + +table tr.failed td { + background-color: lightgoldenrodyellow; +} + +span.loading { + font-size: .8em; + color: #555; +} + +.tooltip { + background: #333; + border: #111 1px solid; + color: #eee; + border-radius: 0.25em; + padding: 0.25em 0.5em; +} diff --git a/rt_dashboard/static/css/main.css b/rt_dashboard/static/css/main.css deleted file mode 100644 index 91929207..00000000 --- a/rt_dashboard/static/css/main.css +++ /dev/null @@ -1,119 +0,0 @@ -/* @override http://localhost:9181/static/css/main.css */ - -body { - background-color: black; -} - -.container { - background: white; - margin-top: 3em; -} - -#content { - background: yellow; -} - -.section -{ - padding: 2em; -} - -p.intro { - min-height: 2em; - padding-top: .8em; - color: #888; -} - -p.fixed.intro { - height: 5em; -} - -h1 { - font-family: 'Helvetica Neue', helvetica, sans-serif; - font-weight: 300; - color: steelblue; - text-shadow: 0 -1px #229; -} - -h1 strong { - font-weight: 500; -} - -h1 strong.failed { - color: red; - text-shadow: 0 -1px #922; -} - -table, table tr, table tr td { - background-color: snow; -} - -table tbody tr td.narrow { - width: 100px; -} - -table#queues { - /*width: 300px;*/ -} - -table#jobs td.actions a { - margin-right: 8px; - margin-bottom: 8px; -} - -table#jobs span.description { - color: #555; - font-weight: 600; -} - -table#jobs span.origin { - color: #888; -} - -table#jobs div.job_id { - font-size: 70%; - color: gray; - margin-left: 24px; -} - -table#jobs pre.exc_info { - color: #555; - margin-left: 24px; - border-left: 2px solid darkorange; - background-color: lightgoldenrodyellow; -} - -table#jobs span.creation_date { - font-size: 80%; -} - -table#jobs span.end_date { - font-size: 80%; - margin-left: 24px; -} - -table#workers { - /*width: 600px;*/ -} - -table tbody tr td { - vertical-align: middle; -} - -table tr.failed { - color: red; - border-top: 2px solid red; -} - -table tr.failed a { - color: red; -} - -table tr.failed td { - background-color: lightgoldenrodyellow; -} - -span.loading { - font-size: .8em; - color: #555; -} diff --git a/rt_dashboard/static/history.html b/rt_dashboard/static/history.html new file mode 100644 index 00000000..870c85f3 --- /dev/null +++ b/rt_dashboard/static/history.html @@ -0,0 +1,17 @@ + + + + + + + RQ dashboard + + + + + + + + + + diff --git a/rt_dashboard/static/index.html b/rt_dashboard/static/index.html new file mode 100644 index 00000000..7d02fa59 --- /dev/null +++ b/rt_dashboard/static/index.html @@ -0,0 +1,17 @@ + + + + + + + RQ dashboard + + + + + + + + + + diff --git a/rt_dashboard/static/js/api.js b/rt_dashboard/static/js/api.js deleted file mode 100644 index 5a0aa045..00000000 --- a/rt_dashboard/static/js/api.js +++ /dev/null @@ -1,30 +0,0 @@ -define(['jquery'], function($) { - - var getQueues = function(cb) { - $.getJSON('/queues', function(data) { // TODO: Fix static URL - var queues = data.queues; - cb(queues); - }); - }; - - var getJobs = function(queue_name, cb) { - $.getJSON('/jobs/' + encodeURIComponent(queue_name), function(data) { // TODO: Fix static URL - var jobs = data.jobs; - cb(jobs); - }); - }; - - var getWorkers = function(cb) { - $.getJSON('/workers', function(data) { // TODO: Fix static URL - var workers = data.workers; - cb(workers); - }); - }; - - return { - 'getQueues': getQueues, - 'getJobs': getJobs, - 'getWorkers': getWorkers - }; - -}); diff --git a/rt_dashboard/static/js/bootstrap-tooltip.js b/rt_dashboard/static/js/bootstrap-tooltip.js deleted file mode 100644 index a3bbd580..00000000 --- a/rt_dashboard/static/js/bootstrap-tooltip.js +++ /dev/null @@ -1,361 +0,0 @@ -/* =========================================================== - * bootstrap-tooltip.js v2.3.2 - * http://twitter.github.com/bootstrap/javascript.html#tooltips - * Inspired by the original jQuery.tipsy by Jason Frame - * =========================================================== - * Copyright 2012 Twitter, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ========================================================== */ - - -!function ($) { - - "use strict"; // jshint ;_; - - - /* TOOLTIP PUBLIC CLASS DEFINITION - * =============================== */ - - var Tooltip = function (element, options) { - this.init('tooltip', element, options) - } - - Tooltip.prototype = { - - constructor: Tooltip - - , init: function (type, element, options) { - var eventIn - , eventOut - , triggers - , trigger - , i - - this.type = type - this.$element = $(element) - this.options = this.getOptions(options) - this.enabled = true - - triggers = this.options.trigger.split(' ') - - for (i = triggers.length; i--;) { - trigger = triggers[i] - if (trigger == 'click') { - this.$element.on('click.' + this.type, this.options.selector, $.proxy(this.toggle, this)) - } else if (trigger != 'manual') { - eventIn = trigger == 'hover' ? 'mouseenter' : 'focus' - eventOut = trigger == 'hover' ? 'mouseleave' : 'blur' - this.$element.on(eventIn + '.' + this.type, this.options.selector, $.proxy(this.enter, this)) - this.$element.on(eventOut + '.' + this.type, this.options.selector, $.proxy(this.leave, this)) - } - } - - this.options.selector ? - (this._options = $.extend({}, this.options, { trigger: 'manual', selector: '' })) : - this.fixTitle() - } - - , getOptions: function (options) { - options = $.extend({}, $.fn[this.type].defaults, this.$element.data(), options) - - if (options.delay && typeof options.delay == 'number') { - options.delay = { - show: options.delay - , hide: options.delay - } - } - - return options - } - - , enter: function (e) { - var defaults = $.fn[this.type].defaults - , options = {} - , self - - this._options && $.each(this._options, function (key, value) { - if (defaults[key] != value) options[key] = value - }, this) - - self = $(e.currentTarget)[this.type](options).data(this.type) - - if (!self.options.delay || !self.options.delay.show) return self.show() - - clearTimeout(this.timeout) - self.hoverState = 'in' - this.timeout = setTimeout(function() { - if (self.hoverState == 'in') self.show() - }, self.options.delay.show) - } - - , leave: function (e) { - var self = $(e.currentTarget)[this.type](this._options).data(this.type) - - if (this.timeout) clearTimeout(this.timeout) - if (!self.options.delay || !self.options.delay.hide) return self.hide() - - self.hoverState = 'out' - this.timeout = setTimeout(function() { - if (self.hoverState == 'out') self.hide() - }, self.options.delay.hide) - } - - , show: function () { - var $tip - , pos - , actualWidth - , actualHeight - , placement - , tp - , e = $.Event('show') - - if (this.hasContent() && this.enabled) { - this.$element.trigger(e) - if (e.isDefaultPrevented()) return - $tip = this.tip() - this.setContent() - - if (this.options.animation) { - $tip.addClass('fade') - } - - placement = typeof this.options.placement == 'function' ? - this.options.placement.call(this, $tip[0], this.$element[0]) : - this.options.placement - - $tip - .detach() - .css({ top: 0, left: 0, display: 'block' }) - - this.options.container ? $tip.appendTo(this.options.container) : $tip.insertAfter(this.$element) - - pos = this.getPosition() - - actualWidth = $tip[0].offsetWidth - actualHeight = $tip[0].offsetHeight - - switch (placement) { - case 'bottom': - tp = {top: pos.top + pos.height, left: pos.left + pos.width / 2 - actualWidth / 2} - break - case 'top': - tp = {top: pos.top - actualHeight, left: pos.left + pos.width / 2 - actualWidth / 2} - break - case 'left': - tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth} - break - case 'right': - tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width} - break - } - - this.applyPlacement(tp, placement) - this.$element.trigger('shown') - } - } - - , applyPlacement: function(offset, placement){ - var $tip = this.tip() - , width = $tip[0].offsetWidth - , height = $tip[0].offsetHeight - , actualWidth - , actualHeight - , delta - , replace - - $tip - .offset(offset) - .addClass(placement) - .addClass('in') - - actualWidth = $tip[0].offsetWidth - actualHeight = $tip[0].offsetHeight - - if (placement == 'top' && actualHeight != height) { - offset.top = offset.top + height - actualHeight - replace = true - } - - if (placement == 'bottom' || placement == 'top') { - delta = 0 - - if (offset.left < 0){ - delta = offset.left * -2 - offset.left = 0 - $tip.offset(offset) - actualWidth = $tip[0].offsetWidth - actualHeight = $tip[0].offsetHeight - } - - this.replaceArrow(delta - width + actualWidth, actualWidth, 'left') - } else { - this.replaceArrow(actualHeight - height, actualHeight, 'top') - } - - if (replace) $tip.offset(offset) - } - - , replaceArrow: function(delta, dimension, position){ - this - .arrow() - .css(position, delta ? (50 * (1 - delta / dimension) + "%") : '') - } - - , setContent: function () { - var $tip = this.tip() - , title = this.getTitle() - - $tip.find('.tooltip-inner')[this.options.html ? 'html' : 'text'](title) - $tip.removeClass('fade in top bottom left right') - } - - , hide: function () { - var that = this - , $tip = this.tip() - , e = $.Event('hide') - - this.$element.trigger(e) - if (e.isDefaultPrevented()) return - - $tip.removeClass('in') - - function removeWithAnimation() { - var timeout = setTimeout(function () { - $tip.off($.support.transition.end).detach() - }, 500) - - $tip.one($.support.transition.end, function () { - clearTimeout(timeout) - $tip.detach() - }) - } - - $.support.transition && this.$tip.hasClass('fade') ? - removeWithAnimation() : - $tip.detach() - - this.$element.trigger('hidden') - - return this - } - - , fixTitle: function () { - var $e = this.$element - if ($e.attr('title') || typeof($e.attr('data-original-title')) != 'string') { - $e.attr('data-original-title', $e.attr('title') || '').attr('title', '') - } - } - - , hasContent: function () { - return this.getTitle() - } - - , getPosition: function () { - var el = this.$element[0] - return $.extend({}, (typeof el.getBoundingClientRect == 'function') ? el.getBoundingClientRect() : { - width: el.offsetWidth - , height: el.offsetHeight - }, this.$element.offset()) - } - - , getTitle: function () { - var title - , $e = this.$element - , o = this.options - - title = $e.attr('data-original-title') - || (typeof o.title == 'function' ? o.title.call($e[0]) : o.title) - - return title - } - - , tip: function () { - return this.$tip = this.$tip || $(this.options.template) - } - - , arrow: function(){ - return this.$arrow = this.$arrow || this.tip().find(".tooltip-arrow") - } - - , validate: function () { - if (!this.$element[0].parentNode) { - this.hide() - this.$element = null - this.options = null - } - } - - , enable: function () { - this.enabled = true - } - - , disable: function () { - this.enabled = false - } - - , toggleEnabled: function () { - this.enabled = !this.enabled - } - - , toggle: function (e) { - var self = e ? $(e.currentTarget)[this.type](this._options).data(this.type) : this - self.tip().hasClass('in') ? self.hide() : self.show() - } - - , destroy: function () { - this.hide().$element.off('.' + this.type).removeData(this.type) - } - - } - - - /* TOOLTIP PLUGIN DEFINITION - * ========================= */ - - var old = $.fn.tooltip - - $.fn.tooltip = function ( option ) { - return this.each(function () { - var $this = $(this) - , data = $this.data('tooltip') - , options = typeof option == 'object' && option - if (!data) $this.data('tooltip', (data = new Tooltip(this, options))) - if (typeof option == 'string') data[option]() - }) - } - - $.fn.tooltip.Constructor = Tooltip - - $.fn.tooltip.defaults = { - animation: true - , placement: 'top' - , selector: false - , template: '
' - , trigger: 'hover focus' - , title: '' - , delay: 0 - , html: false - , container: false - } - - - /* TOOLTIP NO CONFLICT - * =================== */ - - $.fn.tooltip.noConflict = function () { - $.fn.tooltip = old - return this - } - -}(window.jQuery); diff --git a/rt_dashboard/templates/rt_dashboard/app.html b/rt_dashboard/templates/rt_dashboard/app.html new file mode 100644 index 00000000..fbf125cd --- /dev/null +++ b/rt_dashboard/templates/rt_dashboard/app.html @@ -0,0 +1,20 @@ + + + + + + RT dashboard + + + + + + +
+ + + +
+ + + diff --git a/rt_dashboard/templates/rt_dashboard/base.html b/rt_dashboard/templates/rt_dashboard/base.html index 74eb1206..4b711c70 100644 --- a/rt_dashboard/templates/rt_dashboard/base.html +++ b/rt_dashboard/templates/rt_dashboard/base.html @@ -7,7 +7,7 @@ - + diff --git a/rt_dashboard/templates/rt_dashboard/dashboard.html b/rt_dashboard/templates/rt_dashboard/dashboard.html index 43e86930..64f6bcbf 100644 --- a/rt_dashboard/templates/rt_dashboard/dashboard.html +++ b/rt_dashboard/templates/rt_dashboard/dashboard.html @@ -2,216 +2,12 @@ {% block content %} - -
-
-
- -

Queues

-

This list below contains all the registered queues with the number of tasks currently in the queue. Select a queue from above to view all tasks currently pending on the queue.

- - - - - - - - - - - - - -
QueueTasks
Loading...
- - - - - -
-
- -
-
- -

Workers

- - -

No workers registered!

- - - - - - - - - - - - - - -
StateWorkerQueues
Loading...
- - - - - -
-
-
- -
-
-
- -

Tasks on {{ queue.name }}

-

- {% if queue.count() %} - - Empty - - {% elif not queue.name.startswith('[') %} - - Delete - - {% endif %} - This list below contains all the registered tasks on queue - {{ queue.name }}, sorted by age - {% if queue.name.startswith('[') %} - (newest on top). - {% else %} - (oldest on top). - {% endif %} -

- - - - - - - - - - - - - - -
NameAgeActions
Loading...
- - - - - - - - - - - - - - - - - -
-
-
- + {% endblock %} {% block inline_js %} -var POLL_INTERVAL = {{ poll_interval }}; -document.addEventListener("DOMContentLoaded", function(){ - {% include "rt_dashboard/dashboard.js" with context %} -}); + + +