diff options
author | Matthew Sartori <msartori@chromium.org> | 2015-07-20 15:02:47 -0700 |
---|---|---|
committer | ChromeOS Commit Bot <chromeos-commit-bot@chromium.org> | 2015-08-20 01:38:02 +0000 |
commit | db5cb304ecf29e9d9855375c2c2630c6a487496d (patch) | |
tree | 66a575ded9493e5339607b2aa28bfcbf66037b1f /mobmonitor | |
parent | 170286d7fd116a98d17098830a6c7afece3148cf (diff) | |
download | chromite-db5cb304ecf29e9d9855375c2c2630c6a487496d.tar.gz |
mobmonitor: Mob* Monitor Web UI.
This CL adds a simple and clean web interface
for viewing service health statuses and requesting
repair operators.
CQ-DEPEND=CL:290308
BUG=chromium:490822
TEST=Deployed to moblab and tested the web interface manually.
Change-Id: I67f57ab578e2732fabc8a414dfaf27ed550a1d7b
Reviewed-on: https://chromium-review.googlesource.com/290238
Tested-by: Matthew Sartori <msartori@chromium.org>
Reviewed-by: Nick Matthijssen <nickam@google.com>
Commit-Queue: Matthew Sartori <msartori@chromium.org>
Diffstat (limited to 'mobmonitor')
-rwxr-xr-x | mobmonitor/scripts/mobmonitor.py | 43 | ||||
-rw-r--r-- | mobmonitor/scripts/mobmonitor_unittest.py | 18 | ||||
-rw-r--r-- | mobmonitor/static/css/style.css | 126 | ||||
-rw-r--r-- | mobmonitor/static/js/actionrepairdialog.js | 131 | ||||
-rw-r--r-- | mobmonitor/static/js/healthdisplay.js | 144 | ||||
-rw-r--r-- | mobmonitor/static/js/main.js | 44 | ||||
-rw-r--r-- | mobmonitor/static/js/rpc.js | 40 | ||||
-rw-r--r-- | mobmonitor/static/js/template.js | 31 | ||||
-rw-r--r-- | mobmonitor/static/js/util.js | 20 | ||||
-rw-r--r-- | mobmonitor/static/templates/actionrepairdialog.html | 16 | ||||
-rw-r--r-- | mobmonitor/static/templates/healthstatuscontainer.html | 36 | ||||
-rw-r--r-- | mobmonitor/static/templates/index.html | 26 |
12 files changed, 664 insertions, 11 deletions
diff --git a/mobmonitor/scripts/mobmonitor.py b/mobmonitor/scripts/mobmonitor.py index c6a637d39..ed709b919 100755 --- a/mobmonitor/scripts/mobmonitor.py +++ b/mobmonitor/scripts/mobmonitor.py @@ -9,6 +9,7 @@ from __future__ import print_function import cherrypy import json +import os import sys from chromite.lib import remote_access @@ -16,16 +17,23 @@ from chromite.lib import commandline from chromite.mobmonitor.checkfile import manager +STATICDIR = '/etc/mobmonitor/static' + + class MobMonitorRoot(object): """The central object supporting the Mob* Monitor web interface.""" - def __init__(self, checkfile_manager): + def __init__(self, checkfile_manager, staticdir=STATICDIR): + if not os.path.exists(staticdir): + raise IOError('Static directory does not exist: %s' % staticdir) + + self.staticdir = staticdir self.checkfile_manager = checkfile_manager @cherrypy.expose def index(self): """Presents a welcome message.""" - return 'Welcome to the Mob* Monitor!' + return open(os.path.join(self.staticdir, 'templates', 'index.html')) @cherrypy.expose def GetServiceList(self): @@ -87,6 +95,8 @@ def ParseArguments(argv): help='The Mob* Monitor checkfile directory.') parser.add_argument('-p', '--port', type=int, default=9991, help='The Mob* Monitor port.') + parser.add_argument('-s', '--staticdir', default=STATICDIR, + help='Mob* monitor web ui static content directory') return parser.parse_args(argv) @@ -95,18 +105,37 @@ def main(argv): options = ParseArguments(argv) options.Freeze() - # Start the Mob* Monitor web interface. - cherrypy.config.update({'server.socket_port': - remote_access.NormalizePort(options.port)}) + # Configure global cherrypy parameters. + cherrypy.config.update( + {'server.socket_host': '0.0.0.0', + 'server.socket_port': remote_access.NormalizePort(options.port) + }) + + mobmon_appconfig = { + '/': + {'tools.staticdir.root': options.staticdir + }, + '/static': + {'tools.staticdir.on': True, + 'tools.staticdir.dir': '' + }, + '/static/css': + {'tools.staticdir.dir': 'css' + }, + '/static/js': + {'tools.staticdir.dir': 'js' + } + } # Setup the mobmonitor checkfile_manager = manager.CheckFileManager(checkdir=options.checkdir) - mobmonitor = MobMonitorRoot(checkfile_manager) + mobmonitor = MobMonitorRoot(checkfile_manager, staticdir=options.staticdir) # Start the checkfile collection and execution background task. checkfile_manager.StartCollectionExecution() - cherrypy.quickstart(mobmonitor) + # Start the Mob* Monitor. + cherrypy.quickstart(mobmonitor, config=mobmon_appconfig) if __name__ == '__main__': diff --git a/mobmonitor/scripts/mobmonitor_unittest.py b/mobmonitor/scripts/mobmonitor_unittest.py index 8e4e90875..babce4bd9 100644 --- a/mobmonitor/scripts/mobmonitor_unittest.py +++ b/mobmonitor/scripts/mobmonitor_unittest.py @@ -7,8 +7,10 @@ from __future__ import print_function import json +import os from chromite.lib import cros_test_lib +from chromite.lib import osutils from chromite.mobmonitor.checkfile import manager from chromite.mobmonitor.scripts import mobmonitor @@ -39,19 +41,27 @@ class MockCheckFileManager(object): return self.service_statuses[0] -class MobMonitorRootTest(cros_test_lib.MockTestCase): +class MobMonitorRootTest(cros_test_lib.MockTempDirTestCase): """Unittests for the MobMonitorRoot.""" + STATICDIR = 'static' + + def setUp(self): + """Setup directories expected by the Mob* Monitor.""" + self.mobmondir = self.tempdir + self.staticdir = os.path.join(self.mobmondir, self.STATICDIR) + osutils.SafeMakedirs(self.staticdir) + def testGetServiceList(self): """Test the GetServiceList RPC.""" cfm = MockCheckFileManager() - root = mobmonitor.MobMonitorRoot(cfm) + root = mobmonitor.MobMonitorRoot(cfm, staticdir=self.staticdir) self.assertEqual(cfm.GetServiceList(), json.loads(root.GetServiceList())) def testGetStatus(self): """Test the GetStatus RPC.""" cfm = MockCheckFileManager() - root = mobmonitor.MobMonitorRoot(cfm) + root = mobmonitor.MobMonitorRoot(cfm, staticdir=self.staticdir) # Test the result for a single service. status = cfm.service_statuses[0] @@ -73,7 +83,7 @@ class MobMonitorRootTest(cros_test_lib.MockTestCase): def testRepairService(self): """Test the RepairService RPC.""" cfm = MockCheckFileManager() - root = mobmonitor.MobMonitorRoot(cfm) + root = mobmonitor.MobMonitorRoot(cfm, staticdir=self.staticdir) status = cfm.service_statuses[0] expect = {'service': status.service, 'health': status.health, diff --git a/mobmonitor/static/css/style.css b/mobmonitor/static/css/style.css new file mode 100644 index 000000000..957ee230a --- /dev/null +++ b/mobmonitor/static/css/style.css @@ -0,0 +1,126 @@ +/* General Style Settings */ +body { + background-color: #788999; + margin: 0; + padding: 0; +} + +table { + border-collapse: collapse; +} + +td { + border: 1px solid #999; + padding: 5px; + text-align: left; +} + +.bold { + font-weight: bold; +} + +.site-header { + background-color: #20262c; + padding-top: 50px; + padding-right: 0px; + padding-left: 0px; + padding-bottom: 50px; + margin-bottom: 50px; +} + +.site-header-title { + max-width: 1000px; + width: 90%; + margin: 0px auto; + margin-top: 0px; + margin-right: auto; + margin-bottom: 0px; + margin-left: auto; + text-align: center; + font-size: 32pt; + font-weight: bold; + color: #ACD6FF; +} + +/* Health Display Style Settings */ +.health-display { + max-width: 1000px; + width: 90%; + margin: 0px auto; + margin-top: 0px; + margin-right: auto; + margin-bottom: 0px; + margin-left: auto; +} + +.health-container { + border: 1px solid black; + background-color: #FFF; +} + +.health-container-header { + background-color: #FFFAAA; + + float: left; + width: 100%; +} + +.header-service-name { + float: left; + width: 80%; +} + +.header-service-status { + float: left; +} + +.header-service-status-button { +} + +.health-container-content { + background-color: #FFF; + padding: 10px; +} + +.healthcheck-table { + margin: auto; + border: 1px solid black; +} + +.healthcheck { + background-color: grey; +} + +.color-healthy { + color: green; +} + +.color-unhealthy { + color: red; +} + +.color-quasi-healthy { + color: orange; +} + +/* Action Repair Style Settings */ +.actionlist-dropdown { + width:95%; +} + +.input-text { + margin-bottom: 10px; + width: 95%; +} + +.actiondisplay { + width: 95%; + height: 150px; + resize: none; +} + +fieldset { + padding: 0; + border: 0; + margin-top: 10px; +} diff --git a/mobmonitor/static/js/actionrepairdialog.js b/mobmonitor/static/js/actionrepairdialog.js new file mode 100644 index 000000000..8888df446 --- /dev/null +++ b/mobmonitor/static/js/actionrepairdialog.js @@ -0,0 +1,131 @@ +// Copyright 2015 The Chromium OS Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +'use strict'; + + +function ActionRepairDialog(service, actions) { + var actionRepairDialog = this; + + var templateData = { + actions: actions + }; + + this.service = service; + this.actionsToSubmit = []; + + this.dialogElement_ = $( + renderTemplate('actionrepairdialog', templateData)).dialog({ + autoOpen: false, + height: 525, + width: 550, + modal: true, + + close: function(event, ui) { + $(this).dialog('destroy').remove(); + }, + + buttons: { + 'Clear Actions': function() { + actionRepairDialog.clear(); + }, + 'Stage Action': function() { + actionRepairDialog.stage(); + }, + 'Submit': function() { + actionRepairDialog.submit(); + } + } + }); + + var d = this.dialogElement_; + this.dialogActionListDropdown = $(d).find('.actionlist-dropdown')[0]; + this.dialogArgs = $(d).find('#args')[0]; + this.dialogKwargs = $(d).find('#kwargs')[0]; + this.dialogStagedActions = $(d).find('#stagedActions')[0]; +} + +ActionRepairDialog.prototype.open = function() { + this.dialogElement_.dialog('open'); +}; + +ActionRepairDialog.prototype.close = function() { + this.dialogElement_.dialog('close'); +}; + +ActionRepairDialog.prototype.clear = function() { + this.dialogActionListDropdown.value = ''; + this.dialogArgs.value = ''; + this.dialogKwargs.value = ''; + this.dialogStagedActions.value = ''; + this.actionsToSubmit = []; +}; + +ActionRepairDialog.prototype.stage = function() { + var action = this.dialogActionListDropdown.value; + var args = this.dialogArgs.value; + var kwargs = this.dialogKwargs.value; + + // Validate the action input. + if (!action) { + alert('An action must be selected to stage.'); + return; + } + + if (args && !/^([^,]+,)*[^,]+$/g.test(args)) { + alert('Arguments are not well-formed.\n' + + 'Expected form: a1,a2,...,aN'); + return; + } + + if (kwargs && !/^([^,=]+=[^,=]+,)*[^,]+=[^,=]+$/g.test(kwargs)) { + alert('Keyword argumetns are not well-formed.\n' + + 'Expected form: kw1=foo,...,kwN=bar'); + return; + } + + // Store the action and add it to the staged action display. + var storedArgs = args ? args.split(',') : []; + var storedKwargs = {}; + kwargs.split(',').forEach(function(elem, index, array) { + var kv = elem.split('='); + storedKwargs[kv[0]] = kv[1]; + }); + + var stagedAction = { + action: action, + args: storedArgs, + kwargs: storedKwargs + }; + + this.actionsToSubmit.push(stagedAction); + this.dialogStagedActions.value += JSON.stringify(stagedAction); +}; + +ActionRepairDialog.prototype.submit = function() { + // Caller must define the function 'submitHandler' on the created dialog. + // The submitHandler will be passed the following arguments: + // service: A string. + // action: A string. + // args: An array. + // kwargs: An object. + + if (!this.submitHandler) { + alert('Caller must define submitHandler for ActionRepairDialog.'); + return; + } + + if (isEmpty(this.actionsToSubmit)) { + alert('Actions must be staged prior to submission.'); + return; + } + + for (var i = 0; i < this.actionsToSubmit.length; i++) { + var stagedAction = this.actionsToSubmit[i]; + this.submitHandler(this.service, stagedAction.action, stagedAction.args, + stagedAction.kwargs); + } + + this.close(); +}; diff --git a/mobmonitor/static/js/healthdisplay.js b/mobmonitor/static/js/healthdisplay.js new file mode 100644 index 000000000..50af418b0 --- /dev/null +++ b/mobmonitor/static/js/healthdisplay.js @@ -0,0 +1,144 @@ +// Copyright 2015 The Chromium OS Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +'use strict'; + +var RECORD_TTL_MS = 15000; + + +$.widget('mobmonitor.healthDisplay', { + options: {}, + + _create: function() { + this.element.addClass('health-display'); + + // Service status information. The variable serviceHealthStatusInfo + // is a mapping of the following form: + // + // {serviceName: {lastUpdatedTimestampInMs: lastUpdatedTimestampInMs, + // serviceStatus: serviceStatus}} + // + // Where serviceStatus objects are of the following form: + // + // {serviceName: serviceNameString, + // health: boolean, + // healthchecks: [ + // {name: healthCheckName, health: boolean, + // description: descriptionString, + // actions: [actionNameString]} + // ] + // } + this.serviceHealthStatusInfo = {}; + }, + + _destroy: function() { + this.element.removeClass('health-display'); + this.element.empty(); + }, + + _setOption: function(key, value) { + this._super(key, value); + }, + + // Private widget methods. + // TODO (msartori): Implement crbug.com/520746. + _updateHealthDisplayServices: function(services) { + var self = this; + + function _removeService(service) { + // Remove the UI elements. + var id = '#' + SERVICE_CONTAINER_PREFIX + service; + $(id).empty(); + $(id).remove(); + + // Remove raw service status info that we are holding. + delete self.serviceHealthStatusInfo[service]; + } + + function _addService(serviceStatus) { + // This function is used as a callback to the rpcGetStatus. + // rpcGetStatus returns a list of service health statuses. + // In this widget, we add services one at a time, so take + // the first element. + serviceStatus = serviceStatus[0]; + + // Create the new content for the healthDisplay widget. + var templateData = jQuery.extend({}, serviceStatus); + templateData.serviceId = SERVICE_CONTAINER_PREFIX + serviceStatus.service; + + var healthContainer = renderTemplate('healthstatuscontainer', + templateData); + + // Insert the new container into the display widget. + $(healthContainer).appendTo(self.element); + + // Maintain alphabetical order in our display. + self.element.children().sort(function(a, b) { + return $(a).attr('id') < $(b).attr('id') ? -1 : 1; + }).appendTo(self.element); + + // Save information to do with this service. + var curtime = $.now(); + var service = serviceStatus.service; + + self.serviceHealthStatusInfo[service] = { + lastUpdatedTimestampInMs: curtime, + serviceStatus: serviceStatus + }; + } + + // Remove services that are no longer monitored or are stale. + var now = $.now(); + + Object.keys(this.serviceHealthStatusInfo).forEach( + function(elem, index, array) { + if ($.inArray(elem, services) < 0 || + now > self.serviceHealthStatusInfo[elem].lastUpdatedTimestampInMs + + RECORD_TTL_MS) { + _removeService(elem); + } + }); + + // Get sublist of services to update. + var updateList = + $(services).not(Object.keys(this.serviceHealthStatusInfo)).get(); + + // Update the services. + updateList.forEach(function(elem, index, array) { + rpcGetStatus(elem, _addService); + }); + }, + + // Public widget methods. + refreshHealthDisplay: function() { + var self = this; + rpcGetServiceList(function(services) { + self._updateHealthDisplayServices(services); + }); + }, + + needsRepair: function(service) { + var serviceStatus = this.serviceHealthStatusInfo[service].serviceStatus; + return serviceStatus.health == 'false' || + serviceStatus.healthchecks.length > 0; + }, + + markStale: function(service) { + this.serviceHealthStatusInfo[service].lastUpdatedTimestampInMs = 0; + }, + + getServiceActions: function(service) { + var actionSet = {}; + var healthchecks = + this.serviceHealthStatusInfo[service].serviceStatus.healthchecks; + + for (var i = 0; i < healthchecks.length; i++) { + for (var j = 0; j < healthchecks[i].actions.length; j++) { + actionSet[healthchecks[i].actions[j]] = true; + } + } + + return Object.keys(actionSet); + } +}); diff --git a/mobmonitor/static/js/main.js b/mobmonitor/static/js/main.js new file mode 100644 index 000000000..ff80d4614 --- /dev/null +++ b/mobmonitor/static/js/main.js @@ -0,0 +1,44 @@ +// Copyright 2015 The Chromium OS Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +'use strict'; + + +$(document).ready(function() { + // Setup the health status widget. + $('#healthStatusDisplay').healthDisplay(); + $('#healthStatusDisplay').healthDisplay('refreshHealthDisplay'); + + setInterval(function() { + $('#healthStatusDisplay').healthDisplay('refreshHealthDisplay'); + }, 3000); + + // Setup the action repair dialog popup. + // TODO(msartori): Implement crbug.com/520749. + $(document).on('click', '.header-service-status-button', function() { + // Get the service that this button corresponds to. + var service = $(this).closest('.health-container').attr('id'); + if (service.indexOf(SERVICE_CONTAINER_PREFIX) === 0) { + service = service.replace(SERVICE_CONTAINER_PREFIX, ''); + } + + // Do not launch dialog if this service does not need repair. + if (!$('#healthStatusDisplay').healthDisplay('needsRepair', service)) { + return; + } + + // Get the actions for this service. + var actions = $('#healthStatusDisplay').healthDisplay( + 'getServiceActions', service); + + // Create and launch the action repair dialog. + var dialog = new ActionRepairDialog(service, actions); + dialog.submitHandler = function(service, action, args, kwargs) { + rpcRepairService(service, action, args, kwargs, function(response) { + $('#healthStatusDisplay').healthDisplay('markStale', response.service); + }); + }; + dialog.open(); + }); +}); diff --git a/mobmonitor/static/js/rpc.js b/mobmonitor/static/js/rpc.js new file mode 100644 index 000000000..b85367afb --- /dev/null +++ b/mobmonitor/static/js/rpc.js @@ -0,0 +1,40 @@ +// Copyright 2015 The Chromium OS Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +'use strict'; + + +function rpcGetServiceList(callback) { + $.getJSON('/GetServiceList', function(response) { + callback(response); + }); +} + +function rpcGetStatus(service, callback) { + $.getJSON('/GetStatus', {service: service}, function(response) { + callback(response); + }); +} + +function rpcRepairService(service, action, args, kwargs, callback) { + + if (isEmpty(service)) + throw new InvalidRpcArgumentError( + 'Must specify service in RepairService RPC'); + + if (isEmpty(action)) + throw new InvalidRpcArgumentError( + 'Must specify action in RepairService RPC'); + + var data = { + service: service, + action: action, + args: JSON.stringify(args), + kwargs: JSON.stringify(kwargs) + }; + + $.post('/RepairService', data, function(response) { + callback(response); + }, 'json'); +} diff --git a/mobmonitor/static/js/template.js b/mobmonitor/static/js/template.js new file mode 100644 index 000000000..7950f0ce8 --- /dev/null +++ b/mobmonitor/static/js/template.js @@ -0,0 +1,31 @@ +// Copyright 2015 The Chromium OS Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +'use strict'; + + +function renderTemplate(templateName, templateData) { + if (!renderTemplate.cache) { + renderTemplate.cache = {}; + } + + if (!renderTemplate.cache[templateName]) { + var dir = '/static/templates'; + var url = dir + '/' + templateName + '.html'; + + var templateString; + $.ajax({ + url: url, + method: 'GET', + async: false, + success: function(data) { + templateString = data; + } + }); + + renderTemplate.cache[templateName] = Handlebars.compile(templateString); + } + + return renderTemplate.cache[templateName](templateData); +} diff --git a/mobmonitor/static/js/util.js b/mobmonitor/static/js/util.js new file mode 100644 index 000000000..1609c9b6a --- /dev/null +++ b/mobmonitor/static/js/util.js @@ -0,0 +1,20 @@ +// Copyright 2015 The Chromium OS Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +'use strict'; + +var SERVICE_CONTAINER_PREFIX = 'serviceHealthContainer'; + + +function InvalidRpcArgumentError(message) { + this.message = message; +} +InvalidRpcArgumentError.prototype = new Error; + +function isEmpty(x) { + if (typeof(x) === 'undefined' || x === null) + return true; + + return $.isEmptyObject(x); +} diff --git a/mobmonitor/static/templates/actionrepairdialog.html b/mobmonitor/static/templates/actionrepairdialog.html new file mode 100644 index 000000000..1b65a0c27 --- /dev/null +++ b/mobmonitor/static/templates/actionrepairdialog.html @@ -0,0 +1,16 @@ +<div id="actionRepairDialog" title="Setup Repair Actions"> + <label for="actionlist">Actions</label> + <input list="actionlist" class="actionlist-dropdown"> + <datalist id="actionlist"> + {{#each actions}} + <option value={{this}}> + {{/each}} + </datalist> + <label for="args">Arguments</label> + <input type="text" name="args" id="args" value="" class="input-text"> + <label for="kwargs">Keyword Arguments</label> + <input type="text" name="kwargs" id="kwargs" value="" class="input-text"> + <label for="stagedActions">Actions to Execute</label> + <textarea readonly name="stagedActions" id="stagedActions" class="actiondisplay"> + </textarea> +</div> diff --git a/mobmonitor/static/templates/healthstatuscontainer.html b/mobmonitor/static/templates/healthstatuscontainer.html new file mode 100644 index 000000000..014160bed --- /dev/null +++ b/mobmonitor/static/templates/healthstatuscontainer.html @@ -0,0 +1,36 @@ +<div id={{serviceId}} class="health-container"> + <div class="health-container-header"> + <div class="header-service-name">{{service}}</div> + <div class="header-service-status"> + {{#if healthchecks.length}} + {{#if health}} + <button class="header-service-status-button color-quasi-healthy">Healthy</button> + {{else}} + <button class="header-service-status-button color-unhealthy">Unhealthy</button> + {{/if}} + {{else}} + <button class="header-service-status-button color-healthy">Healthy</button> + {{/if}} + </div> + </div> + {{#if healthchecks.length}} + <div class="health-container-content"> + <table class="healthcheck-table"> + <tr> + <td class="bold">Health Check Name</td> + <td class="bold">Health</td> + <td class="bold">Description</td> + <td class="bold">Actions</td> + </tr> + {{#each healthchecks}} + <tr> + <td>{{name}}</td> + <td>{{health}}</td> + <td>{{description}}</td> + <td>{{actions}}</td> + </tr> + {{/each}} + </table> + </div> + {{/if}} +</div> diff --git a/mobmonitor/static/templates/index.html b/mobmonitor/static/templates/index.html new file mode 100644 index 000000000..761f63aa5 --- /dev/null +++ b/mobmonitor/static/templates/index.html @@ -0,0 +1,26 @@ +<html> + <head> + <title>Mob* Monitor</title> + <link href="/static/css/style.css" rel="stylesheet"> + <link href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.11.4/themes/smoothness/jquery-ui.css" rel="stylesheet"> + <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script> + <script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.11.4/jquery-ui.min.js"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/3.0.3/handlebars.js"></script> + <script src="/static/js/util.js"></script> + <script src="/static/js/template.js"></script> + <script src="/static/js/rpc.js"></script> + <script src="/static/js/actionrepairdialog.js"></script> + <script src="/static/js/healthdisplay.js"></script> + <script src="/static/js/main.js"></script> + </head> + + <body> + <header class="site-header"> + <div class="site-header-title">Mob* Monitor</div> + </header> + + <div id="healthStatusDisplay"> + </div> + + </body> +</html> |