Mercurial > piecrust2
changeset 602:c6bc0ef03f82
admin: Better UI for publishing websites.
* Support multiple publish targets.
* Dedicated UI for publishing.
* Some UI polish.
author | Ludovic Chabant <ludovic@chabant.com> |
---|---|
date | Wed, 27 Jan 2016 18:02:25 -0800 |
parents | effbc78b5528 |
children | 7e4e567377cd |
files | foodtruck/main.py foodtruck/pubutil.py foodtruck/sites.py foodtruck/templates/dashboard.html foodtruck/templates/error.html foodtruck/templates/layouts/master.html foodtruck/templates/publish.html foodtruck/views/baking.py foodtruck/views/dashboard.py foodtruck/views/menu.py foodtruck/views/publish.py foodtruck/web.py foodtruckui/assets/js/foodtruck.js foodtruckui/assets/sass/foodtruck/_base.scss |
diffstat | 14 files changed, 280 insertions(+), 218 deletions(-) [+] |
line wrap: on
line diff
--- a/foodtruck/main.py Sun Jan 24 10:42:33 2016 -0800 +++ b/foodtruck/main.py Wed Jan 27 18:02:25 2016 -0800 @@ -16,8 +16,8 @@ # This is needed for Werkzeug's code reloader to be able to correctly # shutdown the child process in order to restart it (otherwise, SSE # generators will keep it alive). - from .views import baking + from . import pubutil logger.debug("Shutting down SSE generators from main...") - baking.server_shutdown = True + pubutil.server_shutdown = True raise
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/pubutil.py Wed Jan 27 18:02:25 2016 -0800 @@ -0,0 +1,93 @@ +import os +import os.path +import time +import signal +import logging +from .web import app + + +logger = logging.getLogger(__name__) + +server_shutdown = False + + +def _shutdown_server_and_raise_sigint(): + if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true': + # This is needed when hitting CTRL+C to shutdown the Werkzeug server, + # otherwise SSE generators will keep it alive. + logger.debug("Shutting down SSE generators...") + global server_shutdown + server_shutdown = True + raise KeyboardInterrupt() + + +if app.config['FOODTRUCK_CMDLINE_MODE']: + # Make sure CTRL+C works correctly. + signal.signal(signal.SIGINT, + lambda *args: _shutdown_server_and_raise_sigint()) + + +class PublishLogReader(object): + _pub_max_time = 10 * 60 # Don't bother about pubs older than 10mins. + _poll_interval = 2 # Check the PID file every 2 seconds. + _ping_interval = 30 # Send a ping message every 30 seconds. + + def __init__(self, pid_path, log_path): + self.pid_path = pid_path + self.log_path = log_path + self._pub_pid_mtime = 0 + self._last_seek = 0 + self._last_ping_time = 0 + + def run(self): + logger.debug("Opening publish log...") + + try: + while not server_shutdown: + # PING! + interval = time.time() - self._last_ping_time + if interval > self._ping_interval: + logger.debug("Sending ping...") + self._last_ping_time = time.time() + yield bytes("event: ping\ndata: 1\n\n", 'utf8') + + # Check pid file. + prev_mtime = self._pub_pid_mtime + try: + self._pub_pid_mtime = os.path.getmtime(self.pid_path) + if time.time() - self._pub_pid_mtime > \ + self._pub_max_time: + self._pub_pid_mtime = 0 + except OSError: + self._pub_pid_mtime = 0 + + # Send data. + new_data = None + if self._pub_pid_mtime > 0 or prev_mtime > 0: + if self._last_seek == 0: + outstr = 'event: message\ndata: Publish started.\n\n' + yield bytes(outstr, 'utf8') + + try: + with open(self.log_path, 'r', encoding='utf8') as fp: + fp.seek(self._last_seek) + new_data = fp.read() + self._last_seek = fp.tell() + except OSError: + pass + if self._pub_pid_mtime == 0: + self._last_seek = 0 + + if new_data: + logger.debug("SSE: %s" % outstr) + for line in new_data.split('\n'): + outstr = 'event: message\ndata: %s\n\n' % line + yield bytes(outstr, 'utf8') + + time.sleep(self._poll_interval) + + except GeneratorExit: + pass + + logger.debug("Closing publish log...") +
--- a/foodtruck/sites.py Sun Jan 24 10:42:33 2016 -0800 +++ b/foodtruck/sites.py Wed Jan 27 18:02:25 2016 -0800 @@ -6,7 +6,7 @@ import threading import subprocess from piecrust.app import PieCrust -from piecrust.configuration import merge_dicts +from piecrust.configuration import merge_dicts, Configuration logger = logging.getLogger(__name__) @@ -24,10 +24,11 @@ def __init__(self, name, root_dir, config): self.name = name self.root_dir = root_dir - self.config = config + self.config = Configuration(values=config.get('sites/%s' % name, {})) + self._global_config = config self._piecrust_app = None self._scm = None - self._bake_thread = None + self._publish_thread = None logger.debug("Creating site object for %s" % self.name) @property @@ -41,70 +42,70 @@ @property def scm(self): if self._scm is None: - cfg = None - scm_cfg = self.config.get('sites/%s/scm' % self.name) - global_scm_cfg = self.config.get('scm') - if scm_cfg: - if global_scm_cfg: - cfg = copy.deepcopy(global_scm_cfg) - merge_dicts(cfg, scm_cfg) - else: - cfg = copy.deepcopy(scm_cfg) - elif global_scm_cfg: - cfg = copy.deepcopy(global_scm_cfg) + cfg = copy.deepcopy(self._global_config.get('scm', {})) + merge_dicts(cfg, self.config.get('scm', {})) - if not cfg or 'type' not in cfg: - raise Exception("No SCM available for site: %s" % self.name) - - if cfg['type'] == 'hg': + if os.path.isdir(os.path.join(self.root_dir, '.hg')): from .scm.mercurial import MercurialSourceControl self._scm = MercurialSourceControl(self.root_dir, cfg) + elif os.path.isdir(os.path.join(self.root_dir, '.git')): + from .scm.git import GitSourceControl + self._scm = GitSourceControl(self.root_dir, cfg) else: - raise NotImplementedError() + self._scm = False return self._scm @property - def is_bake_running(self): - return self._bake_thread is not None and self._bake_thread.is_alive() + def is_publish_running(self): + return (self._publish_thread is not None and + self._publish_thread.is_alive()) @property - def bake_thread(self): - return self._bake_thread + def publish_thread(self): + return self._publish_thread + + def publish(self, target): + target_cfg = self.config.get('publish/%s' % target) + if not target_cfg: + raise Exception("No such publish target: %s" % target) - def bake(self): - bake_cmd = self.config.get('triggers/bake') - bake_args = shlex.split(bake_cmd) + target_cmd = target_cfg.get('cmd') + if not target_cmd: + raise Exception("No command specified for publish target: %s" % + target) + publish_args = shlex.split(target_cmd) - logger.debug("Running bake: %s" % bake_args) - proc = subprocess.Popen(bake_args, cwd=self.root_dir, + logger.debug( + "Executing publish target '%s': %s" % (target, publish_args)) + proc = subprocess.Popen(publish_args, cwd=self.root_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - pid_file_path = os.path.join(self.root_dir, 'foodtruck_bake.pid') + pid_file_path = os.path.join(self.root_dir, '.ft_pub.pid') with open(pid_file_path, 'w') as fp: fp.write(str(proc.pid)) - logger.debug("Running bake monitor for PID %d" % proc.pid) - self._bake_thread = _BakeThread(self.name, self.root_dir, proc, - self._onBakeEnd) - self._bake_thread.start() + logger.debug("Running publishing monitor for PID %d" % proc.pid) + self._publish_thread = _PublishThread( + self.name, self.root_dir, proc, self._onPublishEnd) + self._publish_thread.start() - def _onBakeEnd(self): - os.unlink(os.path.join(self.root_dir, 'foodtruck_bake.pid')) - self._bake_thread = None + def _onPublishEnd(self): + os.unlink(os.path.join(self.root_dir, '.ft_pub.pid')) + self._publish_thread = None -class _BakeThread(threading.Thread): +class _PublishThread(threading.Thread): def __init__(self, sitename, siteroot, proc, callback): - super(_BakeThread, self).__init__( - name='%s_bake' % sitename, daemon=True) + super(_PublishThread, self).__init__( + name='%s_publish' % sitename, daemon=True) self.sitename = sitename self.siteroot = siteroot self.proc = proc self.callback = callback - log_file_path = os.path.join(self.siteroot, 'foodtruck_bake.log') + log_file_path = os.path.join(self.siteroot, '.ft_pub.log') self.log_fp = open(log_file_path, 'w', encoding='utf8') def run(self): @@ -114,11 +115,11 @@ self.log_fp.write(line.decode('utf8')) self.proc.communicate() if self.proc.returncode != 0: - self.log_fp.write("Error, bake process returned code %d" % + self.log_fp.write("Error, publish process returned code %d" % self.proc.returncode) self.log_fp.close() - logger.debug("Bake ended for %s." % self.sitename) + logger.debug("Publish ended for %s." % self.sitename) self.callback()
--- a/foodtruck/templates/dashboard.html Sun Jan 24 10:42:33 2016 -0800 +++ b/foodtruck/templates/dashboard.html Wed Jan 27 18:02:25 2016 -0800 @@ -8,7 +8,9 @@ <form action="{{url_switch}}" method="POST"> {% for site in sites %} {% if site.name != site_name %} - <button type="submit" name="site_name" value="{{site.name}}" class="btn">Switch to {{site.display_name}}</button> + <button type="submit" name="site_name" value="{{site.name}}" class="btn"> + <span class="icon ion-shuffle"></span> + Switch to {{site.display_name}}</button> {% endif %} {% endfor %} </form> @@ -55,15 +57,6 @@ </div> </div> - <div class="row"> - <div class="col-md-12"> - <h2><span class="icon ion-radio-waves"></span> Publishing</h2> - <form action="{{url_bake}}" method="POST"> - <button name="do_bake" type="submit" class="btn btn-primary btn-lg"> - <span class="icon ion-upload"></span> Bake!</button> - </form> - </div> - </div> </div> {% endblock %}
--- a/foodtruck/templates/error.html Sun Jan 24 10:42:33 2016 -0800 +++ b/foodtruck/templates/error.html Wed Jan 27 18:02:25 2016 -0800 @@ -4,9 +4,7 @@ {% block content %} <div class="container"> - {% if error == 'invalid_site' %} - <p>There was an error with your configuration file: {{exception}}</p> - {% endif %} + <p>{{error}}</p> </div> {% endblock %}
--- a/foodtruck/templates/layouts/master.html Sun Jan 24 10:42:33 2016 -0800 +++ b/foodtruck/templates/layouts/master.html Wed Jan 27 18:02:25 2016 -0800 @@ -21,7 +21,7 @@ {% block content %}{% endblock %} </section> {% block after_content %}{% endblock %} - <div id="ft-bakelog"></div> + <div id="ft-publog"></div> <footer> <p>Prepared by <a href="http://bolt80.com">BOLT80</a>.</p> <p>Much <span class="icon ion-heart"></span> to
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/templates/publish.html Wed Jan 27 18:02:25 2016 -0800 @@ -0,0 +1,22 @@ +{% extends 'layouts/default.html' %} + +{% block content %} +<div class="container"> + <h1>Publish {{site_title}}</h1> + + {% for target in targets %} + <div> + <h2>{{target.name}}</h2> + {% if target.description %}<div>{{target.description}}</div>{% endif %} + {% if target.cmd %}<p>Run <code>{{target.cmd}}</code>.</p>{% endif %} + <form action="{{url_run}}" method="POST"> + <input type="hidden" name="target" value="{{target.name}}" /> + <button type="submit" class="btn btn-default">Execute</button> + </form> + </div> + {% endfor %} +</div> + +{% endblock %} + +
--- a/foodtruck/views/baking.py Sun Jan 24 10:42:33 2016 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,117 +0,0 @@ -import os -import os.path -import time -import signal -import logging -from werkzeug.wrappers import Response -from flask import g, redirect -from flask.ext.login import login_required -from ..web import app - - -logger = logging.getLogger(__name__) - -server_shutdown = False - - -def _shutdown_server_and_raise_sigint(): - if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true': - # This is needed when hitting CTRL+C to shutdown the Werkzeug server, - # otherwise SSE generators will keep it alive. - logger.debug("Shutting down SSE generators...") - global server_shutdown - server_shutdown = True - raise KeyboardInterrupt() - - -if app.config['FOODTRUCK_CMDLINE_MODE']: - # Make sure CTRL+C works correctly. - signal.signal(signal.SIGINT, - lambda *args: _shutdown_server_and_raise_sigint()) - - -class _BakeLogReader(object): - _bake_max_time = 10 * 60 # Don't bother about bakes older than 10mins. - _poll_interval = 2 # Check the PID file every 2 seconds. - _ping_interval = 30 # Send a ping message every 30 seconds. - - def __init__(self, pid_path, log_path): - self.pid_path = pid_path - self.log_path = log_path - self._bake_pid_mtime = 0 - self._last_seek = 0 - self._last_ping_time = 0 - - def run(self): - logger.debug("Opening bake log...") - - try: - while not server_shutdown: - # PING! - interval = time.time() - self._last_ping_time - if interval > self._ping_interval: - logger.debug("Sending ping...") - self._last_ping_time = time.time() - yield bytes("event: ping\ndata: 1\n\n", 'utf8') - - # Check pid file. - prev_mtime = self._bake_pid_mtime - try: - self._bake_pid_mtime = os.path.getmtime(self.pid_path) - if time.time() - self._bake_pid_mtime > \ - self._bake_max_time: - self._bake_pid_mtime = 0 - except OSError: - self._bake_pid_mtime = 0 - - # Send data. - new_data = None - if self._bake_pid_mtime > 0 or prev_mtime > 0: - if self._last_seek == 0: - outstr = 'event: message\ndata: Bake started.\n\n' - yield bytes(outstr, 'utf8') - - try: - with open(self.log_path, 'r', encoding='utf8') as fp: - fp.seek(self._last_seek) - new_data = fp.read() - self._last_seek = fp.tell() - except OSError: - pass - if self._bake_pid_mtime == 0: - self._last_seek = 0 - - if new_data: - logger.debug("SSE: %s" % outstr) - for line in new_data.split('\n'): - outstr = 'event: message\ndata: %s\n\n' % line - yield bytes(outstr, 'utf8') - - time.sleep(self._poll_interval) - - except GeneratorExit: - pass - - logger.debug("Closing bake log...") - - -@app.route('/bake', methods=['POST']) -@login_required -def bake_site(): - site = g.sites.get() - site.bake() - return redirect('/') - - -@app.route('/bakelog') -@login_required -def stream_bake_log(): - site = g.sites.get() - pid_path = os.path.join(site.root_dir, 'foodtruck_bake.pid') - log_path = os.path.join(site.root_dir, 'foodtruck_bake.log') - rdr = _BakeLogReader(pid_path, log_path) - - response = Response(rdr.run(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - return response -
--- a/foodtruck/views/dashboard.py Sun Jan 24 10:42:33 2016 -0800 +++ b/foodtruck/views/dashboard.py Wed Jan 27 18:02:25 2016 -0800 @@ -54,7 +54,7 @@ data['site_name'] = site.name data['site_title'] = site.piecrust_app.config.get('site/title', site.name) - data['url_bake'] = url_for('bake_site') + data['url_publish'] = url_for('publish') data['url_preview'] = url_for('preview_site_root', sitename=site.name) data['sites'] = []
--- a/foodtruck/views/menu.py Sun Jan 24 10:42:33 2016 -0800 +++ b/foodtruck/views/menu.py Wed Jan 27 18:02:25 2016 -0800 @@ -14,7 +14,7 @@ if s.is_theme_source: continue - source_icon = s.config.get('foodtruck_icon/document') + source_icon = s.config.get('admin_icon', 'document') if s.name == 'pages': source_icon = 'document-text' elif 'blog' in s.name: @@ -34,6 +34,11 @@ } entries.append(ctx) + entries.append({ + 'url': url_for('publish'), + 'title': "Publish", + 'icon': 'upload'}) + # entries.append({ # 'url': url_for('settings'), # 'title': "Settings",
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/views/publish.py Wed Jan 27 18:02:25 2016 -0800 @@ -0,0 +1,63 @@ +import os +import os.path +import copy +import logging +from flask import request, g, url_for, render_template, Response +from flask.ext.login import login_required +from piecrust.configuration import merge_dicts +from ..pubutil import PublishLogReader +from ..views import with_menu_context +from ..web import app + + +logger = logging.getLogger(__name__) + + +@app.route('/publish', methods=['GET', 'POST']) +@login_required +def publish(): + if request.method == 'POST': + target = request.form.get('target') + if not target: + raise Exception("No target specified.") + + site = g.sites.get() + site.publish(target) + + site = g.sites.get() + pub_cfg = copy.deepcopy(g.config.get('publish', {})) + merge_dicts(pub_cfg, site.config.get('publish', {})) + if not pub_cfg: + data = {'error': "There are not publish targets defined in your " + "configuration file."} + return render_template('error.html', **data) + + data = {} + data['url_run'] = url_for('publish') + data['site_title'] = site.piecrust_app.config.get('site/title', site.name) + data['targets'] = [] + for tn in sorted(pub_cfg.keys()): + tc = pub_cfg[tn] + data['targets'].append({ + 'name': tn, + 'description': tc.get('description'), + 'cmd': tc.get('cmd') + }) + + with_menu_context(data) + + return render_template('publish.html', **data) + + +@app.route('/publish-log') +@login_required +def stream_publish_log(): + site = g.sites.get() + pid_path = os.path.join(site.root_dir, '.ft_pub.pid') + log_path = os.path.join(site.root_dir, '.ft_pub.log') + rdr = PublishLogReader(pid_path, log_path) + + response = Response(rdr.run(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + return response +
--- a/foodtruck/web.py Sun Jan 24 10:42:33 2016 -0800 +++ b/foodtruck/web.py Wed Jan 27 18:02:25 2016 -0800 @@ -22,7 +22,7 @@ l = logging.getLogger() l.setLevel(logging.DEBUG) -app.logger.debug("Using FoodTruck admin root: %s" % admin_root) +logger.debug("Using FoodTruck admin root: %s" % admin_root) def after_this_request(f): @@ -73,7 +73,7 @@ if not app.config['DEBUG']: - app.logger.debug("Registering exception handlers.") + logger.debug("Registering exception handlers.") @app.errorhandler(FoodTruckConfigNotFoundError) def _on_config_missing(ex): @@ -81,7 +81,8 @@ @app.errorhandler(InvalidSiteError) def _on_invalid_site(ex): - data = {'error': 'invalid_site', 'exception': str(ex)} + data = {'error': "The was an error with your configuration file: %s" % + str(ex)} return render_template('error.html', **data) @@ -139,12 +140,12 @@ app.bcrypt = Bcrypt(app) -import foodtruck.views.baking # NOQA import foodtruck.views.create # NOQA import foodtruck.views.dashboard # NOQA import foodtruck.views.edit # NOQA import foodtruck.views.menu # NOQA import foodtruck.views.preview # NOQA +import foodtruck.views.publish # NOQA import foodtruck.views.settings # NOQA import foodtruck.views.sources # NOQA
--- a/foodtruckui/assets/js/foodtruck.js Sun Jan 24 10:42:33 2016 -0800 +++ b/foodtruckui/assets/js/foodtruck.js Wed Jan 27 18:02:25 2016 -0800 @@ -3,41 +3,41 @@ $('.ft-nav-collapsed + ul').hide(); }); +var onPublishEvent = function(e) { + var msgEl = $('<div></div>'); + + var removeMsgEl = function() { + msgEl.remove(); + var publogEl = $('#ft-publog'); + if (publogEl.children().length == 0) { + publogEl.hide(); + } + }; + + msgEl.addClass('alert-dismissible'); + msgEl.attr('role', 'alert'); + msgEl.append('<button type="button" class="close" data-dismiss="alert" aria-label="close">' + + '<span aria-hidden="true">×</span></button>'); + msgEl.append('<div>' + e.data + '</div>'); + var timeoutId = window.setTimeout(function() { + msgEl.fadeOut(400, removeMsgEl); + }, 4000); + msgEl.mouseenter(function() { + window.clearTimeout(timeoutId); + }); + $('button', msgEl).click(removeMsgEl); + + var logEl = $('#ft-publog'); + logEl.append(msgEl); + logEl.show(); +}; + if (!!window.EventSource) { - var source = new EventSource('/bakelog'); - + var source = new EventSource('/publish-log'); source.onerror = function(e) { console.log("Error with SSE, closing.", e); source.close(); }; - source.addEventListener('message', function(e) { - var msgEl = $('<div></div>'); - - var removeMsgEl = function() { - msgEl.remove(); - var bakelogEl = $('#ft-bakelog'); - if (bakelogEl.children().length == 0) { - bakelogEl.hide(); - } - }; - - msgEl.addClass('alert-dismissible'); - msgEl.attr('role', 'alert'); - msgEl.append('<button type="button" class="close" data-dismiss="alert" aria-label="close">' + - '<span aria-hidden="true">×</span></button>'); - msgEl.append('<p>' + e.data + '</p>'); - var timeoutId = window.setTimeout(function() { - msgEl.fadeOut(400, removeMsgEl); - }, 4000); - msgEl.mouseenter(function() { - window.clearTimeout(timeoutId); - }); - $('button', msgEl).click(removeMsgEl); - - var logEl = $('#ft-bakelog'); - logEl.append(msgEl); - logEl.show(); - }); + source.addEventListener('message', onPublishEvent); } -
--- a/foodtruckui/assets/sass/foodtruck/_base.scss Sun Jan 24 10:42:33 2016 -0800 +++ b/foodtruckui/assets/sass/foodtruck/_base.scss Wed Jan 27 18:02:25 2016 -0800 @@ -45,18 +45,21 @@ padding: 0 0.2em; } -#ft-bakelog { +#ft-publog { position: fixed; right: 0; bottom: 0; margin: 0.5em; - padding: 1em; width: 50%; max-width: 30em; - color: $ft-color-gray; - background: $ft-color-gray-lighter; - border: 1px solid $ft-color-gray-light; - border-radius: 0.5em; display: none; } +#ft-publog>div { + padding: 1em 35px 1em 1em; + margin: 0.3em; + color: $ft-color-gray-light; + background: $ft-color-gray-darker; + border-radius: 0.5em; + box-shadow: 0 0 10px $ft-color-gray-dark; +}