Source code for signac_dashboard.dashboard

# Copyright (c) 2018 The Regents of the University of Michigan
# All rights reserved.
# This software is licensed under the BSD 3-Clause License.

from flask import Flask, session, request, url_for, render_template, flash, g
from werkzeug import url_encode
import jinja2
from flask_assets import Environment, Bundle
from flask_turbolinks import turbolinks
import os
import sys
import logging
import warnings
import shlex
import argparse
from importlib import import_module
from functools import lru_cache
from numbers import Real
import json
import signac

from .version import __version__
from .module import ModuleEncoder
from .pagination import Pagination
from .util import LazyView

logger = logging.getLogger(__name__)


[docs]class Dashboard: def __init__(self, config={}, project=None, modules=[]): if project is None: self.project = signac.get_project() else: self.project = project try: # This dashboard document is read-only dash_doc = self.project.document.dashboard() except AttributeError: dash_doc = {} self.config = config or dash_doc.get('config', {}) self.modules = modules or Dashboard.decode_modules( dash_doc.get('module_views', {}).get('Default', [])) # Try to update the project cache. Requires signac 0.9.2 or later. with warnings.catch_warnings(): warnings.simplefilter(action='ignore', category=FutureWarning) try: self.project.update_cache() except Exception: pass
[docs] @classmethod def encode_modules(cls, modules, target='dict'): json_modules = json.dumps(modules, cls=ModuleEncoder, sort_keys=True, indent=4) if target == 'json': return json_modules else: return json.loads(json_modules)
@property def encoded_modules(self): return Dashboard.encode_modules(self.modules)
[docs] @classmethod def decode_modules(cls, json_modules, enabled_modules=None): modules = [] if type(json_modules) == str: json_modules = json.loads(json_modules) logger.info("Loading modules: {}".format(json_modules)) if enabled_modules is None: enabled_modules = list(range(len(json_modules))) for i, module in enumerate(json_modules): if type(module) == dict and \ '_module' in module and \ '_moduletype' in module: try: _module = module['_module'] _moduletype = module['_moduletype'] modulecls = getattr(import_module(_module), _moduletype) module = modulecls( **{k: v for k, v in module.items() if not k.startswith('_')}) if i in enabled_modules: module.enable() else: module.disable() modules.append(module) except Exception as e: logger.error(e) logger.warning('Could not import module:', module) return modules
[docs] def create_app(self, config={}): app = Flask('signac-dashboard') app.config.update({ 'SECRET_KEY': os.urandom(24), 'SEND_FILE_MAX_AGE_DEFAULT': 300, # Cache control for static files }) # Load the provided config app.config.update(config) # Enable profiling if app.config.get('PROFILE'): logger.warning("Application profiling is enabled.") from werkzeug.contrib.profiler import ProfilerMiddleware app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[10]) # Set up default signac-dashboard static and template paths signac_dashboard_path = os.path.dirname(__file__) app.static_folder = signac_dashboard_path + '/static' app.template_folder = signac_dashboard_path + '/templates' # Set up custom template paths # The paths in DASHBOARD_PATHS give the preferred order of template # loading loader_list = [] for dashpath in list(app.config.get('DASHBOARD_PATHS', [])): logger.warning("Adding '{}' to dashboard paths.".format(dashpath)) loader_list.append( jinja2.FileSystemLoader(dashpath + '/templates')) # The default loader goes last and is overridden by any custom paths loader_list.append(app.jinja_loader) app.jinja_loader = jinja2.ChoiceLoader(loader_list) turbolinks(app) return app
[docs] def create_assets(self): assets = Environment(self.app) # jQuery is served as a standalone file jquery = Bundle('js/jquery-*.min.js', output='gen/jquery.min.js') # JavaScript is combined into one file and minified js_all = Bundle('js/js_all/*.js', filters='jsmin', output='gen/app.min.js') # SCSS (Sassy CSS) is compiled to CSS scss_all = Bundle('scss/app.scss', filters='libsass', output='gen/app.css') assets.register('jquery', jquery) assets.register('js_all', js_all) assets.register('scss_all', scss_all) return assets
[docs] def register_module_asset(self, asset): self.module_assets.append(asset)
[docs] def prepare(self): # Set configuration defaults and save to the project document self.config.setdefault('PAGINATION', True) self.config.setdefault('PER_PAGE', 25) self.project.document.setdefault('dashboard', {}) # This dash_doc is synced to the project document dash_doc = self.project.document.dashboard dash_doc.setdefault('config', self.config) dash_doc.setdefault('module_views', {}) # Set the Default module view to the modules provided by the user dash_doc['module_views']['Default'] = self.encoded_modules self.app = self.create_app(self.config) self.assets = self.create_assets() self.register_routes() self.module_assets = [] for module in self.modules: try: module.register_assets(self) module.register_routes(self) except Exception as e: logger.error('Error while registering {} module: {}'.format( module.name, e)) logger.error('Removing module {} from dashboard.'.format( module.name)) self.modules.remove(module)
[docs] def run(self, host='localhost', port=8888, *args, **kwargs): max_retries = 5 for _ in range(max_retries): try: self.app.run(host, port, *args, **kwargs) break except OSError as e: logger.warning(e) if port: port += 1 pass
@lru_cache() def _project_basic_index(self, include_job_document=False): index = [] for item in self.project.index( include_job_document=include_job_document ): index.append(item) return index @lru_cache() def _schema_variables(self): _index = self._project_basic_index() sp_index = self.project.build_job_statepoint_index( exclude_const=True, index=_index) schema_variables = [] for keys, _ in sp_index: schema_variables.append(keys) return schema_variables
[docs] def job_title(self, job): # Overload this method with a function that returns # a human-readable form of the job title. def _format_num(num): if isinstance(num, bool): return str(num) elif isinstance(num, Real): return str(round(num, 2)) return str(num) try: s = [] for keys in sorted(self._schema_variables()): v = job.statepoint()[keys[0]] try: for key in keys[1:]: v = v[key] except KeyError: # Particular key is present in overall continue # schema, but not this state point. else: s.append('{}={}'.format('.'.join(keys), _format_num(v))) return ' '.join(s) except Exception as error: logger.warning( "Error while generating job title: '{}'. " "Returning job-id as fallback.".format(error)) return str(job)
@lru_cache() def _project_min_len_unique_id(self): return self.project.min_len_unique_id()
[docs] def job_subtitle(self, job): # Overload this method with a function that returns # a human-readable form of the job subtitle. return str(job)[:max(8, self._project_min_len_unique_id())]
[docs] def job_sorter(self, job): # Overload this method to return a value that # can be used as a sorting index. return self.job_title(job)
[docs] @lru_cache() def get_all_jobs(self): all_jobs = sorted(self.project.find_jobs(), key=lambda job: self.job_sorter(job)) return all_jobs
@lru_cache(maxsize=65536) def _job_details(self, job, show_labels=False): return { 'job': job, 'title': self.job_title(job), 'subtitle': self.job_subtitle(job), 'labels': job.document['stages'] if show_labels and 'stages' in job.document else [], } def _setup_pagination(self, jobs): total_count = len(jobs) if isinstance(jobs, list) else 0 try: page = int(request.args.get('page', 1)) assert page >= 1 except Exception as e: flash('Pagination Error. Defaulting to page 1.', 'danger') page = 1 pagination = Pagination(page, self.config['PER_PAGE'], total_count) try: assert page <= pagination.pages except Exception as e: page = pagination.pages flash('Pagination Error. Displaying page {}.'.format(page), 'danger') pagination = Pagination( page, self.config['PER_PAGE'], total_count) return pagination def _render_job_view(self, *args, **kwargs): g.active_page = 'jobs' view_mode = request.args.get('view', kwargs.get( 'default_view', 'list')) if view_mode == 'grid': return render_template('jobs_grid.html', *args, **kwargs) elif view_mode == 'list': return render_template('jobs_list.html', *args, **kwargs) else: return self._render_error( ValueError('Invalid view mode: {}'.format(view_mode))) def _render_error(self, error): if isinstance(error, Exception): error_string = "{}: {}".format(type(error).__name__, error) logger.error(error_string) flash(error_string, 'danger') else: logger.error(error) flash(error, 'danger') return render_template('error.html')
[docs] def get_job_details(self, jobs): show_labels = self.config.get('labels', False) return [self._job_details(job, show_labels) for job in list(jobs)]
[docs] def url(self, import_name, url_rules=[], import_file='signac_dashboard', **options): if import_file is not None: import_name = import_file + '.' + import_name view = LazyView(dashboard=self, import_name=import_name) for url_rule in url_rules: self.app.add_url_rule(url_rule, view_func=view, **options)
[docs] def register_routes(self): dashboard = self @dashboard.app.after_request def prevent_caching(response): if 'Cache-Control' not in response.headers: response.headers['Cache-Control'] = 'no-store' return response @dashboard.app.context_processor def injections(): session.setdefault('modules', self.encoded_modules) session.setdefault('enabled_modules', [i for i in range(len(self.modules)) if self.modules[i].enabled]) return { 'APP_NAME': 'signac-dashboard', 'APP_VERSION': __version__, 'PROJECT_NAME': self.project.config['project'], 'PROJECT_DIR': self.project.config['project_dir'], 'modules': Dashboard.decode_modules( session['modules'], session['enabled_modules']), 'enabled_modules': session['enabled_modules'], 'module_assets': self.module_assets } # Add pagination support from http://flask.pocoo.org/snippets/44/ @dashboard.app.template_global() def url_for_other_page(page): args = request.args.copy() args['page'] = page return url_for(request.endpoint, **args) @dashboard.app.template_global() def modify_query(**new_values): args = request.args.copy() for key, value in new_values.items(): args[key] = value return '{}?{}'.format(request.path, url_encode(args)) @dashboard.app.errorhandler(404) def page_not_found(error): return self._render_error(str(error)) self.url('views.home', ['/']) self.url('views.settings', ['/settings']) self.url('views.search', ['/search']) self.url('views.jobs_list', ['/jobs/']) self.url('views.show_job', ['/jobs/<jobid>']) self.url('views.get_file', ['/jobs/<jobid>/file/<filename>']) self.url('views.change_modules', ['/modules'], methods=['POST'])
[docs] def main(self): """Call this function to use the dashboard command line interface.""" def _run(args): kwargs = vars(args) host = kwargs.pop('host') port = kwargs.pop('port') self.config['PROFILE'] = kwargs.pop('profile') self.config['DEBUG'] = kwargs.pop('debug') self.prepare() self.run(host=host, port=port) parser = argparse.ArgumentParser( description="signac-dashboard is a web-based data visualization " "and analysis tool, part of the signac framework.") parser.add_argument( '--debug', action='store_true', help="Show traceback on error for debugging.") parser.add_argument( '--version', action='store_true', help="Display the version number and exit.") subparsers = parser.add_subparsers() parser_run = subparsers.add_parser('run') parser_run.add_argument( '-p', '--profile', action='store_true', help='Enable flask performance profiling.') parser_run.add_argument( '-d', '--debug', action='store_true', help='Enable flask debug mode.') parser_run.add_argument( '--host', type=str, default='localhost', help='Host (binding address). Default: localhost') parser_run.add_argument( '--port', type=int, default=8888, help='Port to listen on. Default: 8888') parser_run.set_defaults(func=_run) # This is a hack, as argparse itself does not # allow to parse only --version without any # of the other required arguments. if '--version' in sys.argv: print('signac-dashboard', __version__) sys.exit(0) args = parser.parse_args() if args.debug: logger.setLevel(logging.DEBUG) if not hasattr(args, 'func'): parser.print_usage() sys.exit(2) try: args.func(args) except KeyboardInterrupt: logger.error("Interrupted.") if args.debug: raise sys.exit(1) except RuntimeWarning as warning: logger.warning("Warning: {}".format(warning)) if args.debug: raise sys.exit(1) except Exception as error: logger.error('Error: {}'.format(error)) if args.debug: raise sys.exit(1) else: sys.exit(0)