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()">
${parent.context_menu_items()}
% 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
</%def>

View file

@ -53,29 +53,30 @@
<p class="block">
This tool works by modifying settings in the DB.&nbsp; It
does <span class="is-italic">not</span> modify any config
files.&nbsp; If you intend to manage datasync config via files
only then you should
<span class="is-italic">not</span> use this tool!
files.&nbsp; If you intend to manage datasync watcher/consumer
config via files only then you should be sure to UNCHECK the
"Use these Settings.." checkbox near the top of page.
</p>
<p class="block">
If you have managed config via files thus far, and want to use
this tool anyway/instead, that&apos;s fine - but after saving
the settings via this tool you should probably remove all
If you have managed config via files thus far, and want to
start using this tool to manage via DB settings instead,
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
from your config file (and restart apps) so as to avoid
confusion.
</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-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-left">
<div class="level-item">
@ -83,7 +84,8 @@
</div>
</div>
<div class="level-right">
<div class="level-item">
<div class="level-item"
v-show="useProfileSettings">
<b-button type="is-primary"
@click="newProfile()"
icon-pack="fas"
@ -130,7 +132,8 @@
<b-table-column field="enabled" label="Enabled">
{{ props.row.enabled ? "Yes" : "No" }}
</b-table-column>
<b-table-column label="Actions">
<b-table-column label="Actions"
v-if="useProfileSettings">
<a href="#"
class="grid-action"
@click.prevent="editProfile(props.row)">
@ -397,15 +400,22 @@
<h3 class="is-size-3">Misc.</h3>
<b-field grouped>
<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"
expanded>
<b-input name="restart_command"
v-model="restartCommand"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
<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"
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>
<b-input name="restart_command"
v-model="restartCommand"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</%def>
@ -417,6 +427,7 @@
ThisPageData.showConfigFilesNote = false
ThisPageData.profilesData = ${json.dumps(profiles_data)|n}
ThisPageData.showDisabledProfiles = false
ThisPageData.useProfileSettings = ${json.dumps(use_profile_settings)|n}
ThisPageData.editProfileShowDialog = false
ThisPageData.editingProfile = null
@ -441,6 +452,7 @@
ThisPageData.editingConsumerRunas = null
ThisPageData.editingConsumerEnabled = true
ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n}
ThisPageData.restartCommand = ${json.dumps(restart_command)|n}
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:
return ''
app = config.get_app()
# 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.
if not value.tzinfo:
@ -150,10 +152,8 @@ def raw_datetime(config, value, verbose=False, as_date=False):
else:
kwargs['c'] = six.text_type(value)
# avoid humanize error when calculating huge time diff
time_diff = None
if abs(time_ago.days) < 100000:
time_diff = humanize.naturaltime(time_ago)
time_diff = app.render_time_ago(time_ago, fallback=None)
if time_diff is not None:
# by "verbose" we mean the result text to look like "YYYY-MM-DD (X days ago)"
if verbose:

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2021 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -31,11 +31,15 @@ import json
import subprocess
import logging
import six
import sqlalchemy as sa
from rattail.db import model
from rattail.datasync.config import load_profiles
from rattail.datasync.util import purge_datasync_settings
from rattail.util import simple_error
from tailbone.views import MasterView
from tailbone.util import raw_datetime
log = logging.getLogger(__name__)
@ -49,11 +53,12 @@ class DataSyncThreadView(MasterView):
index view, with status for each, sort of akin to "dashboard".
For now it only serves the config view.
"""
normalized_model_name = 'datasyncthread'
model_title = "DataSync Thread"
model_title_plural = "DataSync Daemon"
model_key = 'key'
route_prefix = 'datasync'
url_prefix = '/datasync'
listable = False
viewable = False
creatable = False
editable = False
@ -68,26 +73,122 @@ class DataSyncThreadView(MasterView):
'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):
data = []
return data
def restart(self):
cmd = self.rattail_config.getlist('tailbone', 'datasync.restart',
# nb. simulate by default
default='/bin/sleep 3')
log.debug("attempting datasync restart with command: %s", cmd)
result = subprocess.call(cmd)
if result == 0:
try:
self.datasync_handler.restart_supervisor_process()
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')
return self.redirect(self.request.get_referrer(default=self.request.route_url('datasyncchanges')))
except Exception as error:
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):
profiles = load_profiles(self.rattail_config,
include_disabled=True,
ignore_problems=True)
profiles = self.datasync_handler.get_configured_profiles(
include_disabled=True,
ignore_problems=True)
profiles_data = []
for profile in sorted(profiles.values(), key=lambda p: p.key):
@ -125,7 +226,12 @@ class DataSyncThreadView(MasterView):
return {
'profiles': profiles,
'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(),
}
@ -133,58 +239,67 @@ class DataSyncThreadView(MasterView):
settings = []
watch = []
for profile in json.loads(data['profiles']):
pkey = profile['key']
if profile['enabled']:
watch.append(pkey)
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'})
settings.extend([
{'name': 'rattail.datasync.{}.watcher'.format(pkey),
'value': profile['watcher_spec']},
{'name': 'rattail.datasync.{}.watcher.db'.format(pkey),
'value': profile['watcher_dbkey']},
{'name': 'rattail.datasync.{}.watcher.delay'.format(pkey),
'value': profile['watcher_delay']},
{'name': 'rattail.datasync.{}.watcher.retry_attempts'.format(pkey),
'value': profile['watcher_retry_attempts']},
{'name': 'rattail.datasync.{}.watcher.retry_delay'.format(pkey),
'value': profile['watcher_retry_delay']},
{'name': 'rattail.datasync.{}.consumers.runas'.format(pkey),
'value': profile['watcher_default_runas']},
])
if use_profile_settings:
consumers = []
if profile['watcher_consumes_self']:
consumers = ['self']
else:
for profile in json.loads(data['profiles']):
pkey = profile['key']
if profile['enabled']:
watch.append(pkey)
for consumer in profile['consumers_data']:
ckey = consumer['key']
if consumer['enabled']:
consumers.append(ckey)
settings.extend([
{'name': 'rattail.datasync.{}.consumer.{}'.format(pkey, ckey),
'value': consumer['consumer_spec']},
{'name': 'rattail.datasync.{}.consumer.{}.db'.format(pkey, ckey),
'value': consumer['consumer_dbkey']},
{'name': 'rattail.datasync.{}.consumer.{}.delay'.format(pkey, ckey),
'value': consumer['consumer_delay']},
{'name': 'rattail.datasync.{}.consumer.{}.retry_attempts'.format(pkey, ckey),
'value': consumer['consumer_retry_attempts']},
{'name': 'rattail.datasync.{}.consumer.{}.retry_delay'.format(pkey, ckey),
'value': consumer['consumer_retry_delay']},
{'name': 'rattail.datasync.{}.consumer.{}.runas'.format(pkey, ckey),
'value': consumer['consumer_runas']},
])
settings.extend([
{'name': 'rattail.datasync.{}.watcher'.format(pkey),
'value': profile['watcher_spec']},
{'name': 'rattail.datasync.{}.watcher.db'.format(pkey),
'value': profile['watcher_dbkey']},
{'name': 'rattail.datasync.{}.watcher.delay'.format(pkey),
'value': profile['watcher_delay']},
{'name': 'rattail.datasync.{}.watcher.retry_attempts'.format(pkey),
'value': profile['watcher_retry_attempts']},
{'name': 'rattail.datasync.{}.watcher.retry_delay'.format(pkey),
'value': profile['watcher_retry_delay']},
{'name': 'rattail.datasync.{}.consumers.runas'.format(pkey),
'value': profile['watcher_default_runas']},
])
settings.extend([
{'name': 'rattail.datasync.{}.consumers'.format(pkey),
'value': ', '.join(consumers)},
])
consumers = []
if profile['watcher_consumes_self']:
consumers = ['self']
else:
if watch:
settings.append({'name': 'rattail.datasync.watch',
'value': ', '.join(watch)})
for consumer in profile['consumers_data']:
ckey = consumer['key']
if consumer['enabled']:
consumers.append(ckey)
settings.extend([
{'name': 'rattail.datasync.{}.consumer.{}'.format(pkey, ckey),
'value': consumer['consumer_spec']},
{'name': 'rattail.datasync.{}.consumer.{}.db'.format(pkey, ckey),
'value': consumer['consumer_dbkey']},
{'name': 'rattail.datasync.{}.consumer.{}.delay'.format(pkey, ckey),
'value': consumer['consumer_delay']},
{'name': 'rattail.datasync.{}.consumer.{}.retry_attempts'.format(pkey, ckey),
'value': consumer['consumer_retry_attempts']},
{'name': 'rattail.datasync.{}.consumer.{}.retry_delay'.format(pkey, ckey),
'value': consumer['consumer_retry_delay']},
{'name': 'rattail.datasync.{}.consumer.{}.runas'.format(pkey, ckey),
'value': consumer['consumer_runas']},
])
settings.extend([
{'name': 'rattail.datasync.{}.consumers'.format(pkey),
'value': ', '.join(consumers)},
])
if watch:
settings.append({'name': 'rattail.datasync.watch',
'value': ', '.join(watch)})
settings.append({'name': 'rattail.datasync.supervisor_process_name',
'value': data['supervisor_process_name']})
settings.append({'name': 'tailbone.datasync.restart',
'value': data['restart_command']})
@ -204,6 +319,25 @@ class DataSyncThreadView(MasterView):
permission_prefix = cls.get_permission_prefix()
route_prefix = cls.get_route_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
config.add_tailbone_permission(permission_prefix,