diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index 7a79010f..632f50ee 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -4,7 +4,7 @@ <%def name="context_menu_items()"> ${parent.context_menu_items()} % if request.has_perm('datasync.list'): -
  • ${h.link_to("View DataSync Threads", url('datasync'))}
  • +
  • ${h.link_to("View DataSync Status", url('datasync.status'))}
  • % endif diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index ca57a468..2d6d6435 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -53,29 +53,30 @@

    This tool works by modifying settings in the DB.  It does not modify any config - files.  If you intend to manage datasync config via files - only then you should - not use this tool! + files.  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.

    - If you have managed config via files thus far, and want to use - this tool anyway/instead, that'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's fine - but after saving the settings via this tool + you should probably remove all [rattail.datasync] entries from your config file (and restart apps) so as to avoid confusion.

    -

    - Finally, you should know that this tool will - overwrite the entire - rattail.datasync namespace - within the DB settings.  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. -

    + + + Use these Settings to configure watchers and consumers + + +
    @@ -83,7 +84,8 @@
    -
    +
    {{ props.row.enabled ? "Yes" : "No" }} - + @@ -397,15 +400,22 @@

    Misc.

    - - - - - + + + + + + + + @@ -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() { diff --git a/tailbone/templates/datasync/index.mako b/tailbone/templates/datasync/index.mako deleted file mode 100644 index fd7c39c6..00000000 --- a/tailbone/templates/datasync/index.mako +++ /dev/null @@ -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'): -
  • ${h.link_to("View DataSync Changes", url('datasyncchanges'))}
  • - % endif - - -<%def name="render_grid_component()"> - - TODO: this page coming soon... - - ${parent.render_grid_component()} - - - -${parent.body()} diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako new file mode 100644 index 00000000..7a36bcd1 --- /dev/null +++ b/tailbone/templates/datasync/status.mako @@ -0,0 +1,121 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="content_title()"> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('datasync_changes.list'): +
  • ${h.link_to("View DataSync Changes", url('datasyncchanges'))}
  • + % endif + + +<%def name="page_content()"> + +
    + + % if process_info: +
    ${process_info['group']}:${process_info['name']}    ${process_info['statename']}    ${process_info['description']}
    + % else: +
    ${supervisor_error}
    + % endif + +
    + % if request.has_perm('datasync.restart'): + ${h.form(url('datasync.restart'), **{'@submit': 'restartProcess'})} + ${h.csrf_token(request)} + + {{ restartingProcess ? "Working, please wait..." : "Restart Process" }} + + ${h.end_form()} + % endif +
    + +
    +
    + + + + + + + + + + + + + + +<%def name="modify_this_page_vars()"> + + + + +${parent.body()} diff --git a/tailbone/util.py b/tailbone/util.py index c7eabae6..cd6c9237 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -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: diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 6c6db9f1..e55c4ee3 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -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,