diff --git a/taccsite_cms/contrib/taccsite_sysmon/README.md b/taccsite_cms/contrib/taccsite_sysmon/README.md new file mode 100644 index 000000000..b6f479db5 --- /dev/null +++ b/taccsite_cms/contrib/taccsite_sysmon/README.md @@ -0,0 +1,12 @@ +# Sysmon + +This is a _minimal_ conversion of the Frontera Sysmon snippet to a Django CMS plugin. + +> There is a functionally more advanced plugin, [bitbucket:rochaa/tacc-sysmon](https://bitbucket.org/rochaa/tacc-sysmon), which allows: +> +> - server-side requests (Python) instead of client-side requests (JavaScript) +> - multi-system tables + +## To Do + +- [GH-295](https://github.com/TACC/Core-CMS/issues/295) diff --git a/taccsite_cms/contrib/taccsite_sysmon/__init__.py b/taccsite_cms/contrib/taccsite_sysmon/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/taccsite_cms/contrib/taccsite_sysmon/cms_plugins.py b/taccsite_cms/contrib/taccsite_sysmon/cms_plugins.py new file mode 100644 index 000000000..b573c05a8 --- /dev/null +++ b/taccsite_cms/contrib/taccsite_sysmon/cms_plugins.py @@ -0,0 +1,53 @@ +from cms.plugin_base import CMSPluginBase +from cms.plugin_pool import plugin_pool +from django.utils.translation import gettext_lazy as _ + +from taccsite_cms.contrib.helpers import concat_classnames +from taccsite_cms.contrib.taccsite_offset.cms_plugins import get_direction_classname + +from .models import TaccsiteSysmon + +@plugin_pool.register_plugin +class TaccsiteSysmonPlugin(CMSPluginBase): + """ + Components > "System Monitor" Plugin + https://confluence.tacc.utexas.edu/x/FIEjCQ + """ + module = 'TACC Site' + model = TaccsiteSysmon + name = _('System Monitor') + render_template = 'sysmon.html' + + cache = True + text_enabled = True + allow_children = False + + fieldsets = [ + (_('Single System'), { + # NOTE: Can GH-295 fix the reload caveat? + 'description': 'Only a single system may be shown. After editing this plugin, reload the page to load system data.', + 'fields': ( + 'system', + ) + }), + (_('Advanced settings'), { + 'classes': ('collapse',), + 'fields': ( + 'attributes', + ) + }), + ] + + # Render + + def render(self, context, instance, placeholder): + context = super().render(context, instance, placeholder) + request = context['request'] + + classes = concat_classnames([ + 's-sysmon', + instance.attributes.get('class'), + ]) + instance.attributes['class'] = classes + + return context diff --git a/taccsite_cms/contrib/taccsite_sysmon/constants.py b/taccsite_cms/contrib/taccsite_sysmon/constants.py new file mode 100644 index 000000000..5aacadb9c --- /dev/null +++ b/taccsite_cms/contrib/taccsite_sysmon/constants.py @@ -0,0 +1 @@ +DEFAULT_SYSTEM = 'frontera.tacc.utexas.edu' diff --git a/taccsite_cms/contrib/taccsite_sysmon/migrations/0001_initial.py b/taccsite_cms/contrib/taccsite_sysmon/migrations/0001_initial.py new file mode 100644 index 000000000..14b87aebd --- /dev/null +++ b/taccsite_cms/contrib/taccsite_sysmon/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.16 on 2021-07-30 22:21 + +from django.db import migrations, models +import django.db.models.deletion +import djangocms_attributes_field.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('cms', '0022_auto_20180620_1551'), + ] + + operations = [ + migrations.CreateModel( + name='TaccsiteSysmon', + fields=[ + ('cmsplugin_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='taccsite_sysmon_taccsitesysmon', serialize=False, to='cms.CMSPlugin')), + ('system', models.CharField(choices=[('frontera.tacc.utexas.edu', 'Frontera'), ('stampede2.tacc.utexas.edu', 'Stampede2'), ('maverick2.tacc.utexas.edu', 'Maverick2'), ('longhorn.tacc.utexas.edu', 'Longhorn')], default='frontera.tacc.utexas.edu', max_length=255, verbose_name='System')), + ('attributes', djangocms_attributes_field.fields.AttributesField(default=dict)), + ], + options={ + 'abstract': False, + }, + bases=('cms.cmsplugin',), + ), + ] diff --git a/taccsite_cms/contrib/taccsite_sysmon/migrations/__init__.py b/taccsite_cms/contrib/taccsite_sysmon/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/taccsite_cms/contrib/taccsite_sysmon/models.py b/taccsite_cms/contrib/taccsite_sysmon/models.py new file mode 100644 index 000000000..78e108992 --- /dev/null +++ b/taccsite_cms/contrib/taccsite_sysmon/models.py @@ -0,0 +1,46 @@ +from cms.models.pluginmodel import CMSPlugin +from django.utils.translation import gettext_lazy as _ + +from django.db import models + +from djangocms_attributes_field import fields + +from taccsite_cms.contrib.helpers import get_choices + +from .constants import DEFAULT_SYSTEM + +# TODO: (Maybe in GH-295) Do not replicate `display_name` data from API +SYSTEM_DICT = { + 'frontera.tacc.utexas.edu': { + 'description': 'Frontera' + }, + 'stampede2.tacc.utexas.edu': { + 'description': 'Stampede2' + }, + 'maverick2.tacc.utexas.edu': { + 'description': 'Maverick2' + }, + 'longhorn.tacc.utexas.edu': { + 'description': 'Longhorn' + }, +} +SYSTEM_CHOICES = get_choices(SYSTEM_DICT) + +class TaccsiteSysmon(CMSPlugin): + """ + Components > "System Monitor" Model + https://confluence.tacc.utexas.edu/x/FIEjCQ + """ + system = models.CharField( + verbose_name=_('System'), + choices=SYSTEM_CHOICES, + blank=False, + max_length=255, + default=DEFAULT_SYSTEM, + ) + + attributes = fields.AttributesField() + + def get_short_description(self): + system_choice = SYSTEM_DICT[self.system] + return system_choice['description'] diff --git a/taccsite_cms/contrib/taccsite_sysmon/static/taccsite_sysmon/css/sysmon.css b/taccsite_cms/contrib/taccsite_sysmon/static/taccsite_sysmon/css/sysmon.css new file mode 100644 index 000000000..f9329b813 --- /dev/null +++ b/taccsite_cms/contrib/taccsite_sysmon/static/taccsite_sysmon/css/sysmon.css @@ -0,0 +1,86 @@ +/* +System Monitor a.k.a. SysMon + +Styles for the system monitor table that assumes external code: + +- custom properties + (for `--global-...`) +- Bootstrap + (for badge) +- Iconworks + (for icon) + +Styleguide Trumps.Scopes.SystemMonitor +*/ + +/* Container */ + +.s-sysmon { + font-size: 1.4rem; + min-width: 320px; +} + +/* Table */ + +.s-sysmon table { + /* vert. `padding` + vert. `border-spacing` + `border-width` = 14px */ + padding: 6px 0; + + border: 1px solid var(--global-color-primary--xx-light); + border-radius: 9px; + border-spacing: 14px 7px; /* Overwrite Bootstrap 3 */ + border-collapse: separate; /* Overwrite Bootstrap 4 */ +} +.s-sysmon thead > tr { + margin-left: 5px; + margin-right: 5px; +} +.s-sysmon th { + font-weight: var(--bold); +} +.s-sysmon td { + font-weight: var(--medium); +} + +/* Overwrite Bootstrap Class */ +.s-sysmon .table { + margin-bottom: 0px; +} +.s-sysmon .table-dark { + color: var(--global-color-primary--normal); + background-color: var(--global-color-primary--xx-dark); +} +.s-sysmon .table thead th { + border-bottom: 1px solid var(--global-color-primary--dark); +} +.s-sysmon .table th, +.s-sysmon .table td { + vertical-align: middle; + border: none; + padding: 0 0 5px; +} +.s-sysmon .table td { + padding: 0; +} + +/* Status Label */ + +.s-sysmon .badge { + font-family: Roboto; +} + +/* Overwrite Bootstrap */ +.s-sysmon .badge { + border-radius: 3px; + font-size: 1.3rem; + font-weight: normal; +} +.s-sysmon .badge-warning { + background-color: var(--global-color-warning--normal); + color: var(--global-color-primary--xx-light); +} + +/* Overwrite IconWorks */ +.s-sysmon .iconworks:before { + padding-right: 0.5em; +} diff --git a/taccsite_cms/contrib/taccsite_sysmon/static/taccsite_sysmon/js/sysmon.js b/taccsite_cms/contrib/taccsite_sysmon/static/taccsite_sysmon/js/sysmon.js new file mode 100644 index 000000000..22251d75d --- /dev/null +++ b/taccsite_cms/contrib/taccsite_sysmon/static/taccsite_sysmon/js/sysmon.js @@ -0,0 +1,168 @@ +// GH-295: Use server-side logic instead of client-side + +/** + * All system data + * @typedef {array} AllSystems + * @see https://frontera-portal.tacc.utexas.edu/api/system-monitor/ + */ + +/** + * Single system data + * @typedef {object} System + * @see https://frontera-portal.tacc.utexas.edu/api/system-monitor/ + */ + +// Allow system mointor to work(-ish) on local server +const USE_SAMPLE_DATA = (window.location.hostname === 'localhost'); +const API_SAMPLE_DATA = JSON.parse('[{"hostname": "frontera.tacc.utexas.edu", "display_name": "Frontera", "ssh": {"type": "ssh", "status": true, "timestamp": "2021-07-30T19:45:02Z"}, "heartbeat": {"type": "heartbeat", "status": true, "timestamp": "2021-07-30T19:45:02Z"}, "status_tests": {"ssh": {"type": "ssh", "status": true, "timestamp": "2021-07-30T19:45:02.176Z"}, "heartbeat": {"type": "heartbeat", "status": true, "timestamp": "2021-07-30T19:45:02.174Z"}}, "resource_type": "compute", "jobs": {"running": 322, "queued": 1468, "other": 364}, "load_percentage": 99, "cpu_count": 472760, "cpu_used": 468616, "is_operational": true}, {"hostname": "stampede2.tacc.utexas.edu", "display_name": "Stampede2", "ssh": {"type": "ssh", "status": true, "timestamp": "2021-07-30T19:45:03Z"}, "heartbeat": {"type": "heartbeat", "status": true, "timestamp": "2021-07-30T19:45:03Z"}, "status_tests": {"heartbeat": {"type": "heartbeat", "status": true, "timestamp": "2021-07-30T19:45:03.069Z"}, "ssh": {"type": "ssh", "status": true, "timestamp": "2021-07-30T19:45:03.074Z"}}, "resource_type": "compute", "jobs": {"running": 1115, "queued": 1032, "other": 444}, "load_percentage": 96, "cpu_count": 1309056, "cpu_used": 1257184, "is_operational": true}]'); + +/** + * The URL of the API endpoint + * @type {string} + */ +const API_URL = '/api/system-monitor'; + +/** + * The systems to show + * + * _Notice: This value is expected to be available from another script_ + * @type {string} + */ +const SYSTEM_HOSTNAME = window.SYSMON_SYSTEM_HOSTNAME; + +/** + * Load system status + * @param {string} path + * @param {function} onSuccess - Callback for success (receives JSON) + * @param {function} onError - Callback for success (receives XMLHttpRequest) + */ +function loadStatus(path, onSuccess, onError) { + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function () { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + if (onSuccess) onSuccess(JSON.parse(xhr.responseText)); + } else { + if (onError) onError(xhr); + } + } + }; + xhr.open('GET', path, true); + xhr.send(); +} + +/** + * Whether system is operational + * @param {System} system + * @return {boolean} + */ +function isOperational(system) { + if (system['load_percentage'] < 1 || system['load_percentage'] > 99) { + system['load_percentage'] = 0; + return system['jobs']['running'] > 1; + } + return true; +} + +/** + * Show system content in UI + */ +function showStatus() { + document.getElementById('status').classList.remove('d-none'); +} + +/** + * Style system status + * @param {string} type - A type: "warning" + */ +function setStatusStyle(type) { + switch (type) { + case 'warning': + document + .getElementById('status') + .classList.remove('badge-success'); + document.getElementById('status').removeAttribute('data-icon'); + document.getElementById('status').innerHTML = 'Maintenance'; + document.getElementById('status').classList.add('badge-warning'); + + default: + break; + } +} + +/** + * Populate system status content in markup + * @param {System} status + */ +function setStatusMarkup(status) { + document.getElementById('load_percentage').innerHTML = + status['load_percentage'] + '%'; + document.getElementById('jobs_running').innerHTML = + status['jobs']['running']; + document.getElementById('jobs_queued').innerHTML = + status['jobs']['queued']; +} + +/** + * Populate system status in UI + * @param {System} status + */ +function setStatus(status) { + const isFound = status; + const isWorking = isOperational(status); + + if (isFound && isWorking) { + setStatusMarkup(status); + } else { + setStatusStyle('warning'); + if (isFound) { + setStatusMarkup(status); + } + } + showStatus(); +} + +/** + * Populate monitor based on data + * @param {AllSystems} systems + */ +function populate(systems) { + let status; + + systems.forEach(function (system) { + if (system['hostname'] === SYSTEM_HOSTNAME) { + status = system; + console.info(`System Monitor: System found (${SYSTEM_HOSTNAME})`); + return false; + } + }); + + setStatus(status); +} + +/** Load and populate UI */ +function init() { + document.addEventListener( + 'DOMContentLoaded', + function () { + loadStatus( + API_URL, + function (data) { + populate(data); + }, + function (xhr) { + if (USE_SAMPLE_DATA) { + populate(API_SAMPLE_DATA); + } else { + console.error(xhr); + } + } + ); + + console.log('System Monitor: Load complete'); + }, + false + ); +} + +init(); diff --git a/taccsite_cms/contrib/taccsite_sysmon/templates/sysmon.html b/taccsite_cms/contrib/taccsite_sysmon/templates/sysmon.html new file mode 100644 index 000000000..f265c4c64 --- /dev/null +++ b/taccsite_cms/contrib/taccsite_sysmon/templates/sysmon.html @@ -0,0 +1,34 @@ +{% load static %} + + + +
+ + + + + + + + + + + + + + + + + +
STATUSLOADRUNNINGQUEUED
+ Operational + ---
+
+ + + diff --git a/taccsite_cms/settings.py b/taccsite_cms/settings.py index 93ead3cfd..b36bb48fa 100644 --- a/taccsite_cms/settings.py +++ b/taccsite_cms/settings.py @@ -248,6 +248,7 @@ def getsecrets(): 'taccsite_cms.contrib.taccsite_static_article_preview', 'taccsite_cms.contrib.taccsite_blockquote', 'taccsite_cms.contrib.taccsite_offset', + 'taccsite_cms.contrib.taccsite_sysmon', ] # Convert list of paths to list of dotted module names