Add proper status page for datasync

or rather, it's a good start..  plenty more could be added
This commit is contained in:
Lance Edgar 2022-08-15 21:06:19 -05:00
parent 839c4e0c28
commit 065f845707
6 changed files with 361 additions and 113 deletions

View file

@ -4,7 +4,7 @@
<%def name="context_menu_items()"> <%def name="context_menu_items()">
${parent.context_menu_items()} ${parent.context_menu_items()}
% if request.has_perm('datasync.list'): % if request.has_perm('datasync.list'):
<li>${h.link_to("View DataSync Threads", url('datasync'))}</li> <li>${h.link_to("View DataSync Status", url('datasync.status'))}</li>
% endif % endif
</%def> </%def>

View file

@ -53,29 +53,30 @@
<p class="block"> <p class="block">
This tool works by modifying settings in the DB.&nbsp; It This tool works by modifying settings in the DB.&nbsp; It
does <span class="is-italic">not</span> modify any config does <span class="is-italic">not</span> modify any config
files.&nbsp; If you intend to manage datasync config via files files.&nbsp; If you intend to manage datasync watcher/consumer
only then you should config via files only then you should be sure to UNCHECK the
<span class="is-italic">not</span> use this tool! "Use these Settings.." checkbox near the top of page.
</p> </p>
<p class="block"> <p class="block">
If you have managed config via files thus far, and want to use If you have managed config via files thus far, and want to
this tool anyway/instead, that&apos;s fine - but after saving start using this tool to manage via DB settings instead,
the settings via this tool you should probably remove all that&apos;s fine - but after saving the settings via this tool
you should probably remove all
<span class="is-family-code">[rattail.datasync]</span> entries <span class="is-family-code">[rattail.datasync]</span> entries
from your config file (and restart apps) so as to avoid from your config file (and restart apps) so as to avoid
confusion. confusion.
</p> </p>
<p class="block">
Finally, you should know that this tool will
<span class="is-italic">overwrite</span> the entire
<span class="is-family-code">rattail.datasync</span> namespace
within the DB settings.&nbsp; In other words if you have
manually created any ${h.link_to("Raw Settings", url('settings'))}
within that namepsace, they will be lost when you save settings
with this tool.
</p>
</b-notification> </b-notification>
<b-field>
<b-checkbox name="use_profile_settings"
v-model="useProfileSettings"
native-value="true"
@input="settingsNeedSaved = true">
Use these Settings to configure watchers and consumers
</b-checkbox>
</b-field>
<div class="level"> <div class="level">
<div class="level-left"> <div class="level-left">
<div class="level-item"> <div class="level-item">
@ -83,7 +84,8 @@
</div> </div>
</div> </div>
<div class="level-right"> <div class="level-right">
<div class="level-item"> <div class="level-item"
v-show="useProfileSettings">
<b-button type="is-primary" <b-button type="is-primary"
@click="newProfile()" @click="newProfile()"
icon-pack="fas" icon-pack="fas"
@ -130,7 +132,8 @@
<b-table-column field="enabled" label="Enabled"> <b-table-column field="enabled" label="Enabled">
{{ props.row.enabled ? "Yes" : "No" }} {{ props.row.enabled ? "Yes" : "No" }}
</b-table-column> </b-table-column>
<b-table-column label="Actions"> <b-table-column label="Actions"
v-if="useProfileSettings">
<a href="#" <a href="#"
class="grid-action" class="grid-action"
@click.prevent="editProfile(props.row)"> @click.prevent="editProfile(props.row)">
@ -397,7 +400,15 @@
<h3 class="is-size-3">Misc.</h3> <h3 class="is-size-3">Misc.</h3>
<b-field grouped> <b-field label="Supervisor Process Name"
message="This should be the complete name, including group - e.g. poser:poser_datasync"
expanded>
<b-input name="supervisor_process_name"
v-model="supervisorProcessName"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
<b-field label="Restart Command" <b-field label="Restart Command"
message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart poser:poser_datasync" message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart poser:poser_datasync"
expanded> expanded>
@ -406,7 +417,6 @@
@input="settingsNeedSaved = true"> @input="settingsNeedSaved = true">
</b-input> </b-input>
</b-field> </b-field>
</b-field>
</%def> </%def>
@ -417,6 +427,7 @@
ThisPageData.showConfigFilesNote = false ThisPageData.showConfigFilesNote = false
ThisPageData.profilesData = ${json.dumps(profiles_data)|n} ThisPageData.profilesData = ${json.dumps(profiles_data)|n}
ThisPageData.showDisabledProfiles = false ThisPageData.showDisabledProfiles = false
ThisPageData.useProfileSettings = ${json.dumps(use_profile_settings)|n}
ThisPageData.editProfileShowDialog = false ThisPageData.editProfileShowDialog = false
ThisPageData.editingProfile = null ThisPageData.editingProfile = null
@ -441,6 +452,7 @@
ThisPageData.editingConsumerRunas = null ThisPageData.editingConsumerRunas = null
ThisPageData.editingConsumerEnabled = true ThisPageData.editingConsumerEnabled = true
ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n}
ThisPageData.restartCommand = ${json.dumps(restart_command)|n} ThisPageData.restartCommand = ${json.dumps(restart_command)|n}
ThisPage.computed.filteredProfilesData = function() { ThisPage.computed.filteredProfilesData = function() {

View file

@ -1,19 +0,0 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/index.mako" />
<%def name="context_menu_items()">
${parent.context_menu_items()}
% if request.has_perm('datasync_changes.list'):
<li>${h.link_to("View DataSync Changes", url('datasyncchanges'))}</li>
% endif
</%def>
<%def name="render_grid_component()">
<b-notification :closable="false">
TODO: this page coming soon...
</b-notification>
${parent.render_grid_component()}
</%def>
${parent.body()}

View file

@ -0,0 +1,121 @@
## -*- coding: utf-8; -*-
<%inherit file="/page.mako" />
<%def name="content_title()"></%def>
<%def name="context_menu_items()">
${parent.context_menu_items()}
% if request.has_perm('datasync_changes.list'):
<li>${h.link_to("View DataSync Changes", url('datasyncchanges'))}</li>
% endif
</%def>
<%def name="page_content()">
<b-field label="Supervisor Status">
<div style="display: flex;">
% if process_info:
<pre class="has-background-${'success' if process_info['statename'] == 'RUNNING' else 'danger'}">${process_info['group']}:${process_info['name']} ${process_info['statename']} ${process_info['description']}</pre>
% else:
<pre class="has-background-warning">${supervisor_error}</pre>
% endif
<div style="margin-left: 1rem;">
% if request.has_perm('datasync.restart'):
${h.form(url('datasync.restart'), **{'@submit': 'restartProcess'})}
${h.csrf_token(request)}
<b-button type="is-primary"
native-type="submit"
icon-pack="fas"
icon-left="redo"
:disabled="restartingProcess">
{{ restartingProcess ? "Working, please wait..." : "Restart Process" }}
</b-button>
${h.end_form()}
% endif
</div>
</div>
</b-field>
<b-field label="Watcher Status">
<b-table :data="watchers">
<template slot-scope="props">
<b-table-column field="key"
label="Watcher">
{{ props.row.key }}
</b-table-column>
<b-table-column field="spec"
label="Spec">
{{ props.row.spec }}
</b-table-column>
<b-table-column field="dbkey"
label="DB Key">
{{ props.row.dbkey }}
</b-table-column>
<b-table-column field="delay"
label="Delay">
{{ props.row.delay }} second(s)
</b-table-column>
<b-table-column field="lastrun"
label="Last Watched">
<span v-html="props.row.lastrun"></span>
</b-table-column>
<b-table-column field="status"
label="Status"
:class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'">
{{ props.row.status }}
</b-table-column>
</template>
</b-table>
</b-field>
<b-field label="Consumer Status">
<b-table :data="consumers">
<template slot-scope="props">
<b-table-column field="key"
label="Consumer">
{{ props.row.key }}
</b-table-column>
<b-table-column field="spec"
label="Spec">
{{ props.row.spec }}
</b-table-column>
<b-table-column field="dbkey"
label="DB Key">
{{ props.row.dbkey }}
</b-table-column>
<b-table-column field="delay"
label="Delay">
{{ props.row.delay }} second(s)
</b-table-column>
<b-table-column field="changes"
label="Pending Changes">
{{ props.row.changes }}
</b-table-column>
<b-table-column field="status"
label="Status"
:class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'">
{{ props.row.status }}
</b-table-column>
</template>
</b-table>
</b-field>
</%def>
<%def name="modify_this_page_vars()">
<script type="text/javascript">
ThisPageData.restartingProcess = false
ThisPageData.watchers = ${json.dumps(watcher_data)|n}
ThisPageData.consumers = ${json.dumps(consumer_data)|n}
ThisPage.methods.restartProcess = function() {
this.restartingProcess = true
}
</script>
</%def>
${parent.body()}

View file

@ -127,6 +127,8 @@ def raw_datetime(config, value, verbose=False, as_date=False):
if not value: if not value:
return '' return ''
app = config.get_app()
# Make sure we're dealing with a tz-aware value. If we're given a naive # Make sure we're dealing with a tz-aware value. If we're given a naive
# value, we assume it to be local to the UTC timezone. # value, we assume it to be local to the UTC timezone.
if not value.tzinfo: if not value.tzinfo:
@ -150,10 +152,8 @@ def raw_datetime(config, value, verbose=False, as_date=False):
else: else:
kwargs['c'] = six.text_type(value) kwargs['c'] = six.text_type(value)
# avoid humanize error when calculating huge time diff time_diff = app.render_time_ago(time_ago, fallback=None)
time_diff = None if time_diff is not None:
if abs(time_ago.days) < 100000:
time_diff = humanize.naturaltime(time_ago)
# by "verbose" we mean the result text to look like "YYYY-MM-DD (X days ago)" # by "verbose" we mean the result text to look like "YYYY-MM-DD (X days ago)"
if verbose: if verbose:

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2021 Lance Edgar # Copyright © 2010-2022 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -31,11 +31,15 @@ import json
import subprocess import subprocess
import logging import logging
import six
import sqlalchemy as sa
from rattail.db import model from rattail.db import model
from rattail.datasync.config import load_profiles
from rattail.datasync.util import purge_datasync_settings from rattail.datasync.util import purge_datasync_settings
from rattail.util import simple_error
from tailbone.views import MasterView from tailbone.views import MasterView
from tailbone.util import raw_datetime
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -49,11 +53,12 @@ class DataSyncThreadView(MasterView):
index view, with status for each, sort of akin to "dashboard". index view, with status for each, sort of akin to "dashboard".
For now it only serves the config view. For now it only serves the config view.
""" """
normalized_model_name = 'datasyncthread'
model_title = "DataSync Thread" model_title = "DataSync Thread"
model_title_plural = "DataSync Daemon"
model_key = 'key' model_key = 'key'
route_prefix = 'datasync' route_prefix = 'datasync'
url_prefix = '/datasync' url_prefix = '/datasync'
listable = False
viewable = False viewable = False
creatable = False creatable = False
editable = False editable = False
@ -68,24 +73,120 @@ class DataSyncThreadView(MasterView):
'key', 'key',
] ]
def __init__(self, request, context=None):
super(DataSyncThreadView, self).__init__(request, context=context)
app = self.get_rattail_app()
self.datasync_handler = app.get_datasync_handler()
def status(self):
"""
View to list/filter/sort the model data.
If this view receives a non-empty 'partial' parameter in the query
string, then the view will return the rendered grid only. Otherwise
returns the full page.
"""
app = self.get_rattail_app()
model = self.model
try:
process_info = self.datasync_handler.get_supervisor_process_info()
supervisor_error = None
except Exception as error:
process_info = None
supervisor_error = simple_error(error)
profiles = self.datasync_handler.get_configured_profiles()
sql = """
select source, consumer, count(*) as changes
from datasync_change
group by source, consumer
"""
result = self.Session.execute(sql)
all_changes = {}
for row in result:
all_changes[(row.source, row.consumer)] = row.changes
watcher_data = []
consumer_data = []
now = app.localtime()
for key, profile in six.iteritems(profiles):
watcher = profile.watcher
lastrun = self.datasync_handler.get_watcher_lastrun(
watcher.key, local=True, session=self.Session())
status = "okay"
if (now - lastrun).total_seconds() >= (watcher.delay * 2):
status = "dead watcher"
watcher_data.append({
'key': watcher.key,
'spec': profile.watcher_spec,
'dbkey': watcher.dbkey,
'delay': watcher.delay,
'lastrun': raw_datetime(self.rattail_config, lastrun, verbose=True),
'status': status,
})
for consumer in profile.consumers:
if consumer.watcher is watcher:
changes = all_changes.get((watcher.key, consumer.key), 0)
if changes:
oldest = self.Session.query(sa.func.min(model.DataSyncChange.obtained))\
.filter(model.DataSyncChange.source == watcher.key)\
.filter(model.DataSyncChange.consumer == consumer.key)\
.scalar()
oldest = app.localtime(oldest, from_utc=True)
changes = "{} (oldest from {})".format(
changes,
app.render_time_ago(now - oldest))
status = "okay"
if changes:
status = "processing changes"
consumer_data.append({
'key': '{} -> {}'.format(watcher.key, consumer.key),
'spec': consumer.spec,
'dbkey': consumer.dbkey,
'delay': consumer.delay,
'changes': changes,
'status': status,
})
watcher_data.sort(key=lambda w: w['key'])
consumer_data.sort(key=lambda c: c['key'])
context = {
'index_title': "DataSync Status",
'index_url': None,
'process_info': process_info,
'supervisor_error': supervisor_error,
'watcher_data': watcher_data,
'consumer_data': consumer_data,
}
return self.render_to_response('status', context)
def get_data(self, session=None): def get_data(self, session=None):
data = [] data = []
return data return data
def restart(self): def restart(self):
cmd = self.rattail_config.getlist('tailbone', 'datasync.restart', try:
# nb. simulate by default self.datasync_handler.restart_supervisor_process()
default='/bin/sleep 3')
log.debug("attempting datasync restart with command: %s", cmd)
result = subprocess.call(cmd)
if result == 0:
self.request.session.flash("DataSync daemon has been restarted.") self.request.session.flash("DataSync daemon has been restarted.")
else:
self.request.session.flash("DataSync daemon could not be restarted; result was: {}".format(result), 'error') except Exception as error:
return self.redirect(self.request.get_referrer(default=self.request.route_url('datasyncchanges'))) self.request.session.flash(simple_error(error), 'error')
return self.redirect(self.request.get_referrer(
default=self.request.route_url('datasyncchanges')))
def configure_get_context(self): def configure_get_context(self):
profiles = load_profiles(self.rattail_config, profiles = self.datasync_handler.get_configured_profiles(
include_disabled=True, include_disabled=True,
ignore_problems=True) ignore_problems=True)
@ -125,7 +226,12 @@ class DataSyncThreadView(MasterView):
return { return {
'profiles': profiles, 'profiles': profiles,
'profiles_data': profiles_data, 'profiles_data': profiles_data,
'restart_command': self.rattail_config.get('tailbone', 'datasync.restart'), 'use_profile_settings': self.rattail_config.getbool(
'rattail.datasync', 'use_profile_settings'),
'supervisor_process_name': self.rattail_config.get(
'rattail.datasync', 'supervisor_process_name'),
'restart_command': self.rattail_config.get(
'tailbone', 'datasync.restart'),
'system_user': getpass.getuser(), 'system_user': getpass.getuser(),
} }
@ -133,6 +239,12 @@ class DataSyncThreadView(MasterView):
settings = [] settings = []
watch = [] watch = []
use_profile_settings = data.get('use_profile_settings') == 'true'
settings.append({'name': 'rattail.datasync.use_profile_settings',
'value': 'true' if use_profile_settings else 'false'})
if use_profile_settings:
for profile in json.loads(data['profiles']): for profile in json.loads(data['profiles']):
pkey = profile['key'] pkey = profile['key']
if profile['enabled']: if profile['enabled']:
@ -186,6 +298,9 @@ class DataSyncThreadView(MasterView):
settings.append({'name': 'rattail.datasync.watch', settings.append({'name': 'rattail.datasync.watch',
'value': ', '.join(watch)}) 'value': ', '.join(watch)})
settings.append({'name': 'rattail.datasync.supervisor_process_name',
'value': data['supervisor_process_name']})
settings.append({'name': 'tailbone.datasync.restart', settings.append({'name': 'tailbone.datasync.restart',
'value': data['restart_command']}) 'value': data['restart_command']})
@ -204,6 +319,25 @@ class DataSyncThreadView(MasterView):
permission_prefix = cls.get_permission_prefix() permission_prefix = cls.get_permission_prefix()
route_prefix = cls.get_route_prefix() route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix() url_prefix = cls.get_url_prefix()
index_title = cls.get_index_title()
# view status
config.add_tailbone_permission(permission_prefix,
'{}.status'.format(permission_prefix),
"View status for DataSync daemon")
# nb. simple 'datasync' route points to 'datasync.status' for now..
config.add_route(route_prefix,
'{}/status/'.format(url_prefix))
config.add_route('{}.status'.format(route_prefix),
'{}/status/'.format(url_prefix))
config.add_view(cls, attr='status',
route_name=route_prefix,
permission='{}.status'.format(permission_prefix))
config.add_view(cls, attr='status',
route_name='{}.status'.format(route_prefix),
permission='{}.status'.format(permission_prefix))
config.add_tailbone_index_page(route_prefix, index_title,
'{}.status'.format(permission_prefix))
# restart # restart
config.add_tailbone_permission(permission_prefix, config.add_tailbone_permission(permission_prefix,