Commit bb86ce58 authored by Daniele Venzano's avatar Daniele Venzano 🏇

Merge branch 'devel/zapp-shop' into 'master'

New web UI and lots of fixes

See merge request !15
parents c331579e 7c1b6dae
...@@ -7,7 +7,7 @@ The main Zoe Components are: ...@@ -7,7 +7,7 @@ The main Zoe Components are:
* zoe master: the core component that performs application scheduling and talks to Swarm * zoe master: the core component that performs application scheduling and talks to Swarm
* zoe api: the Zoe frontend, offering a web interface and a REST API * zoe api: the Zoe frontend, offering a web interface and a REST API
* zoe: command-line client * command-line clients (zoe.py and zoe-admin.py)
The Zoe master is the core component of Zoe and communicates with the clients by using an internal ZeroMQ-based protocol. This protocol is designed to be robust, using the best practices from ZeroMQ documentation. A crash of the Api or of the Master process will not leave the other component inoperable, and when the faulted process restarts, work will restart where it was left. The Zoe master is the core component of Zoe and communicates with the clients by using an internal ZeroMQ-based protocol. This protocol is designed to be robust, using the best practices from ZeroMQ documentation. A crash of the Api or of the Master process will not leave the other component inoperable, and when the faulted process restarts, work will restart where it was left.
......
...@@ -62,7 +62,7 @@ Master options: ...@@ -62,7 +62,7 @@ Master options:
Authentication: Authentication:
* ``auth-type = text`` : Authentication type (text or ldap) * ``auth-type = text`` : Authentication type (text, ldap or ldapsasl)
* ``auth-file = zoepass.csv`` : Path to the CSV file containing user,pass,role lines for text authentication * ``auth-file = zoepass.csv`` : Path to the CSV file containing user,pass,role lines for text authentication
* ``ldap-server-uri = ldap://localhost`` : LDAP server to use for user authentication * ``ldap-server-uri = ldap://localhost`` : LDAP server to use for user authentication
* ``ldap-base-dn = ou=something,dc=any,dc=local`` : LDAP base DN for users * ``ldap-base-dn = ou=something,dc=any,dc=local`` : LDAP base DN for users
...@@ -77,6 +77,10 @@ Scheduler options: ...@@ -77,6 +77,10 @@ Scheduler options:
Default options for the scheduler enable the traditional Zoe scheduler that was already available in the previous releases. Default options for the scheduler enable the traditional Zoe scheduler that was already available in the previous releases.
ZApp shop:
* ``zapp-shop-path = /var/lib/zoe-apps`` : Path where ZApp folders are stored
Backend choice: Backend choice:
* ``backend = <Swarm|Kubernetes>`` : cluster back-end to use to run ZApps * ``backend = <Swarm|Kubernetes>`` : cluster back-end to use to run ZApps
......
...@@ -9,6 +9,12 @@ To better work together we have established some rules on how to contribute. ...@@ -9,6 +9,12 @@ To better work together we have established some rules on how to contribute.
If you need ideas on features that are waiting to be implemented, you can check the `roadmap <https://github.com/DistributedSystemsGroup/zoe/wiki/RoadMap>`_. If you need ideas on features that are waiting to be implemented, you can check the `roadmap <https://github.com/DistributedSystemsGroup/zoe/wiki/RoadMap>`_.
Development repository
----------------------
Development happens at `Eurecom's GitLab repository <https://gitlab.eurecom.fr/zoe/main>`_. The GitHub repository is a read-only mirror.
The choice of GitLab over GitHub is due to the CI pipeline that we set-up to test Zoe.
Bug reports and feature requests Bug reports and feature requests
-------------------------------- --------------------------------
......
This diff is collapsed.
...@@ -13,3 +13,4 @@ pykube>=0.14.0 ...@@ -13,3 +13,4 @@ pykube>=0.14.0
sphinx sphinx
jsonschema jsonschema
tabulate tabulate
markdown
...@@ -17,7 +17,6 @@ ...@@ -17,7 +17,6 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import re
import os import os
import zoe_api.exceptions import zoe_api.exceptions
...@@ -29,6 +28,8 @@ from zoe_lib.config import get_conf ...@@ -29,6 +28,8 @@ from zoe_lib.config import get_conf
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
GUEST_QUOTA_MAX_EXECUTIONS = 1
class APIEndpoint: class APIEndpoint:
""" """
...@@ -71,10 +72,14 @@ class APIEndpoint: ...@@ -71,10 +72,14 @@ class APIEndpoint:
except zoe_lib.exceptions.InvalidApplicationDescription as e: except zoe_lib.exceptions.InvalidApplicationDescription as e:
raise zoe_api.exceptions.ZoeException('Invalid application description: ' + e.message) raise zoe_api.exceptions.ZoeException('Invalid application description: ' + e.message)
if 3 > len(exec_name) > 128: # quota check
raise zoe_api.exceptions.ZoeException("Execution name must be between 4 and 128 characters long") if role == "guest":
if not re.match(r'^[a-zA-Z0-9\-]+$', exec_name): running_execs = self.execution_list(uid, role, **{'status': 'running'})
raise zoe_api.exceptions.ZoeException("Execution name can contain only letters, numbers and dashes. '{}' is not valid.".format(exec_name)) running_execs += self.execution_list(uid, role, **{'status': 'starting'})
running_execs += self.execution_list(uid, role, **{'status': 'scheduled'})
running_execs += self.execution_list(uid, role, **{'status': 'submitted'})
if len(running_execs) > GUEST_QUOTA_MAX_EXECUTIONS:
raise zoe_api.exceptions.ZoeException('Guest users cannot run more than one execution at a time, quota exceeded.')
new_id = self.sql.execution_new(exec_name, uid, application_description) new_id = self.sql.execution_new(exec_name, uid, application_description)
success, message = self.master.execution_start(new_id) success, message = self.master.execution_start(new_id)
...@@ -100,6 +105,9 @@ class APIEndpoint: ...@@ -100,6 +105,9 @@ class APIEndpoint:
def execution_delete(self, uid, role, exec_id): def execution_delete(self, uid, role, exec_id):
"""Delete an execution.""" """Delete an execution."""
if role != "admin":
raise zoe_api.exceptions.ZoeAuthException()
e = self.sql.execution_list(id=exec_id, only_one=True) e = self.sql.execution_list(id=exec_id, only_one=True)
assert isinstance(e, zoe_lib.state.sql_manager.Execution) assert isinstance(e, zoe_lib.state.sql_manager.Execution)
if e is None: if e is None:
...@@ -194,7 +202,7 @@ class APIEndpoint: ...@@ -194,7 +202,7 @@ class APIEndpoint:
for port in service.description['ports']: for port in service.description['ports']:
port_key = str(port['port_number']) + "/" + port['protocol'] port_key = str(port['port_number']) + "/" + port['protocol']
backend_port = self.sql.port_list(only_one=True, service_id=service.id, internal_name=port_key) backend_port = self.sql.port_list(only_one=True, service_id=service.id, internal_name=port_key)
if backend_port.external_ip is not None: if backend_port is not None and backend_port.external_ip is not None:
endpoint = port['url_template'].format(**{"ip_port": backend_port.external_ip + ":" + str(backend_port.external_port)}) endpoint = port['url_template'].format(**{"ip_port": backend_port.external_ip + ":" + str(backend_port.external_port)})
endpoints.append((port['name'], endpoint)) endpoints.append((port['name'], endpoint))
......
...@@ -48,6 +48,8 @@ def zoe_web_main() -> int: ...@@ -48,6 +48,8 @@ def zoe_web_main() -> int:
if args.log_file != "stderr": if args.log_file != "stderr":
log_args['filename'] = args.log_file log_args['filename'] = args.log_file
logging.basicConfig(**log_args) logging.basicConfig(**log_args)
logging.getLogger("MARKDOWN").setLevel(logging.WARNING)
logging.getLogger("tornado").setLevel(logging.WARNING)
if config.get_conf().auth_type == 'ldap' and not zoe_api.auth.ldap.LDAP_AVAILABLE: if config.get_conf().auth_type == 'ldap' and not zoe_api.auth.ldap.LDAP_AVAILABLE:
log.error("LDAP authentication requested, but 'pyldap' module not installed.") log.error("LDAP authentication requested, but 'pyldap' module not installed.")
......
...@@ -73,7 +73,9 @@ def get_auth(handler: tornado.web.RequestHandler): ...@@ -73,7 +73,9 @@ def get_auth(handler: tornado.web.RequestHandler):
if handler.get_secure_cookie('zoe'): if handler.get_secure_cookie('zoe'):
cookie_val = str(handler.get_secure_cookie('zoe')) cookie_val = str(handler.get_secure_cookie('zoe'))
uid, role = cookie_val[2:-1].split('.') uid, role = cookie_val[2:-1].split('.')
log.info('Authentication done using cookie') log.debug('Authentication done using cookie')
if role == "guest":
raise ZoeRestAPIException('Guest users cannot use the API, ask for a role upgrade', 401, {'WWW-Authenticate': 'Basic realm="Login Required"'})
return uid, role return uid, role
auth_header = handler.request.headers.get('Authorization') auth_header = handler.request.headers.get('Authorization')
...@@ -118,6 +120,9 @@ def get_auth(handler: tornado.web.RequestHandler): ...@@ -118,6 +120,9 @@ def get_auth(handler: tornado.web.RequestHandler):
raise ZoeRestAPIException('missing or wrong authentication information', 401, {'WWW-Authenticate': 'Basic realm="Login Required"'}) raise ZoeRestAPIException('missing or wrong authentication information', 401, {'WWW-Authenticate': 'Basic realm="Login Required"'})
log.debug('Authentication done using auth-mechanism') log.debug('Authentication done using auth-mechanism')
if role == "guest":
raise ZoeRestAPIException('Guest users cannot use the API, ask for a role upgrade', 401, {'WWW-Authenticate': 'Basic realm="Login Required"'})
return uid, role return uid, role
......
sonar.projectName=zoe_api
sonar.host.url=http://your-sonarqube-server-address
sonar.sources=.
sonar.language=py
sonar.sourceEncoding=UTF-8
...@@ -22,6 +22,7 @@ import tornado.web ...@@ -22,6 +22,7 @@ import tornado.web
import zoe_api.web.start import zoe_api.web.start
import zoe_api.web.websockets import zoe_api.web.websockets
import zoe_api.web.executions import zoe_api.web.executions
import zoe_api.web.zapp_shop
from zoe_lib.version import ZOE_API_VERSION, ZOE_VERSION from zoe_lib.version import ZOE_API_VERSION, ZOE_VERSION
...@@ -38,15 +39,18 @@ def web_init(api_endpoint) -> List[tornado.web.URLSpec]: ...@@ -38,15 +39,18 @@ def web_init(api_endpoint) -> List[tornado.web.URLSpec]:
tornado.web.url(r'/login', zoe_api.web.start.LoginWeb, route_args, name='login'), tornado.web.url(r'/login', zoe_api.web.start.LoginWeb, route_args, name='login'),
tornado.web.url(r'/logout', zoe_api.web.start.LogoutWeb, route_args, name='logout'), tornado.web.url(r'/logout', zoe_api.web.start.LogoutWeb, route_args, name='logout'),
tornado.web.url(r'/executions/new', zoe_api.web.executions.ExecutionDefineWeb, route_args, name='execution_define'), tornado.web.url(r'/executions', zoe_api.web.executions.ExecutionListWeb, route_args, name='execution_list'),
tornado.web.url(r'/executions/start', zoe_api.web.executions.ExecutionStartWeb, route_args, name='execution_start'), tornado.web.url(r'/executions/start', zoe_api.web.executions.ExecutionStartWeb, route_args, name='execution_start'),
tornado.web.url(r'/executions/restart/([0-9]+)', zoe_api.web.executions.ExecutionRestartWeb, route_args, name='execution_restart'), tornado.web.url(r'/executions/restart/([0-9]+)', zoe_api.web.executions.ExecutionRestartWeb, route_args, name='execution_restart'),
tornado.web.url(r'/executions/terminate/([0-9]+)', zoe_api.web.executions.ExecutionTerminateWeb, route_args, name='execution_terminate'), tornado.web.url(r'/executions/terminate/([0-9]+)', zoe_api.web.executions.ExecutionTerminateWeb, route_args, name='execution_terminate'),
tornado.web.url(r'/executions/delete/([0-9]+)', zoe_api.web.executions.ExecutionDeleteWeb, route_args, name='execution_delete'),
tornado.web.url(r'/executions/inspect/([0-9]+)', zoe_api.web.executions.ExecutionInspectWeb, route_args, name='execution_inspect'), tornado.web.url(r'/executions/inspect/([0-9]+)', zoe_api.web.executions.ExecutionInspectWeb, route_args, name='execution_inspect'),
tornado.web.url(r'/service/logs/([0-9]+)', zoe_api.web.executions.ServiceLogsWeb, route_args, name='service_logs'), tornado.web.url(r'/service/logs/([0-9]+)', zoe_api.web.executions.ServiceLogsWeb, route_args, name='service_logs'),
tornado.web.url(r'/websocket', zoe_api.web.websockets.WebSocketEndpointWeb, route_args, name='websocket') tornado.web.url(r'/websocket', zoe_api.web.websockets.WebSocketEndpointWeb, route_args, name='websocket'),
tornado.web.url(r'/zapp-shop', zoe_api.web.zapp_shop.ZAppShopHomeWeb, route_args, name='zappshop'),
tornado.web.url(r'/zapp-shop/logo/([a-z\-.]+)', zoe_api.web.zapp_shop.ZAppLogoWeb, route_args, name='zappshop_logo'),
tornado.web.url(r'/zapp-shop/start/([0-9a-z\-.]+)', zoe_api.web.zapp_shop.ZAppStartWeb, route_args, name='zappshop_start')
] ]
return web_routes return web_routes
......
...@@ -23,27 +23,6 @@ from zoe_api.api_endpoint import APIEndpoint # pylint: disable=unused-import ...@@ -23,27 +23,6 @@ from zoe_api.api_endpoint import APIEndpoint # pylint: disable=unused-import
from zoe_api.web.custom_request_handler import ZoeRequestHandler from zoe_api.web.custom_request_handler import ZoeRequestHandler
class ExecutionDefineWeb(ZoeRequestHandler):
"""Handler class"""
def initialize(self, **kwargs):
"""Initializes the request handler."""
super().initialize(**kwargs)
self.api_endpoint = kwargs['api_endpoint'] # type: APIEndpoint
@catch_exceptions
def get(self):
"""Define a new execution."""
uid, role = get_auth(self)
if uid is None:
return self.redirect(self.get_argument('next', u'/login'))
template_vars = {
"uid": uid,
"role": role,
}
self.render('execution_new.html', **template_vars)
class ExecutionStartWeb(ZoeRequestHandler): class ExecutionStartWeb(ZoeRequestHandler):
"""Handler class""" """Handler class"""
def initialize(self, **kwargs): def initialize(self, **kwargs):
...@@ -67,7 +46,7 @@ class ExecutionStartWeb(ZoeRequestHandler): ...@@ -67,7 +46,7 @@ class ExecutionStartWeb(ZoeRequestHandler):
self.redirect(self.reverse_url('execution_inspect', new_id)) self.redirect(self.reverse_url('execution_inspect', new_id))
class ExecutionRestartWeb(ZoeRequestHandler): class ExecutionListWeb(ZoeRequestHandler):
"""Handler class""" """Handler class"""
def initialize(self, **kwargs): def initialize(self, **kwargs):
"""Initializes the request handler.""" """Initializes the request handler."""
...@@ -75,19 +54,23 @@ class ExecutionRestartWeb(ZoeRequestHandler): ...@@ -75,19 +54,23 @@ class ExecutionRestartWeb(ZoeRequestHandler):
self.api_endpoint = kwargs['api_endpoint'] # type: APIEndpoint self.api_endpoint = kwargs['api_endpoint'] # type: APIEndpoint
@catch_exceptions @catch_exceptions
def get(self, execution_id: int): def get(self):
"""Restart an already defined (and not running) execution.""" """Home page with authentication."""
uid, role = get_auth(self) uid, role = get_auth(self)
if uid is None: if uid is None:
return self.redirect(self.get_argument('next', u'/login')) return self.redirect(self.get_argument('next', u'/login'))
e = self.api_endpoint.execution_by_id(uid, role, execution_id) executions = self.api_endpoint.execution_list(uid, role)
new_id = self.api_endpoint.execution_start(uid, role, e.name, e.description)
self.redirect(self.reverse_url('execution_inspect', new_id)) template_vars = {
"uid": uid,
"role": role,
'executions': sorted(executions, key=lambda e: e.id, reverse=True)
}
self.render('execution_list.html', **template_vars)
class ExecutionTerminateWeb(ZoeRequestHandler): class ExecutionRestartWeb(ZoeRequestHandler):
"""Handler class""" """Handler class"""
def initialize(self, **kwargs): def initialize(self, **kwargs):
"""Initializes the request handler.""" """Initializes the request handler."""
...@@ -96,19 +79,18 @@ class ExecutionTerminateWeb(ZoeRequestHandler): ...@@ -96,19 +79,18 @@ class ExecutionTerminateWeb(ZoeRequestHandler):
@catch_exceptions @catch_exceptions
def get(self, execution_id: int): def get(self, execution_id: int):
"""Terminate an execution.""" """Restart an already defined (and not running) execution."""
uid, role = get_auth(self) uid, role = get_auth(self)
if uid is None: if uid is None:
return self.redirect(self.get_argument('next', u'/login')) return self.redirect(self.get_argument('next', u'/login'))
success, message = self.api_endpoint.execution_terminate(uid, role, execution_id) e = self.api_endpoint.execution_by_id(uid, role, execution_id)
if not success: new_id = self.api_endpoint.execution_start(uid, role, e.name, e.description)
raise zoe_api.exceptions.ZoeException(message)
self.redirect(self.reverse_url('home_user')) self.redirect(self.reverse_url('execution_inspect', new_id))
class ExecutionDeleteWeb(ZoeRequestHandler): class ExecutionTerminateWeb(ZoeRequestHandler):
"""Handler class""" """Handler class"""
def initialize(self, **kwargs): def initialize(self, **kwargs):
"""Initializes the request handler.""" """Initializes the request handler."""
...@@ -117,12 +99,12 @@ class ExecutionDeleteWeb(ZoeRequestHandler): ...@@ -117,12 +99,12 @@ class ExecutionDeleteWeb(ZoeRequestHandler):
@catch_exceptions @catch_exceptions
def get(self, execution_id: int): def get(self, execution_id: int):
"""Delete an execution.""" """Terminate an execution."""
uid, role = get_auth(self) uid, role = get_auth(self)
if uid is None: if uid is None:
return self.redirect(self.get_argument('next', u'/login')) return self.redirect(self.get_argument('next', u'/login'))
success, message = self.api_endpoint.execution_delete(uid, role, execution_id) success, message = self.api_endpoint.execution_terminate(uid, role, execution_id)
if not success: if not success:
raise zoe_api.exceptions.ZoeException(message) raise zoe_api.exceptions.ZoeException(message)
......
...@@ -86,22 +86,46 @@ class HomeWeb(ZoeRequestHandler): ...@@ -86,22 +86,46 @@ class HomeWeb(ZoeRequestHandler):
if uid is None: if uid is None:
return self.redirect(self.get_argument('next', u'/login')) return self.redirect(self.get_argument('next', u'/login'))
if role == 'guest': filters = {
return self._aml_homepage(uid) "user_id": uid,
"limit": 5
}
last_executions = self.api_endpoint.execution_list(uid, role, **filters)
filters = {
"user_id": uid,
"status": "running"
}
last_running_executions = self.api_endpoint.execution_list(uid, role, **filters)
filters = {
"user_id": uid,
"status": "submitted"
}
last_running_executions += self.api_endpoint.execution_list(uid, role, **filters)
filters = {
"user_id": uid,
"status": "scheduled"
}
last_running_executions += self.api_endpoint.execution_list(uid, role, **filters)
filters = {
"user_id": uid,
"status": "starting"
}
last_running_executions += self.api_endpoint.execution_list(uid, role, **filters)
executions = self.api_endpoint.execution_list(uid, role) running_reservations = [e.total_reservations for e in last_running_executions]
total_memory = sum([r.memory.max for r in running_reservations])
total_cores = sum([r.cores.max for r in running_reservations])
template_vars = { template_vars = {
"uid": uid, "uid": uid,
"role": role, "role": role,
'executions': sorted(executions, key=lambda e: e.id), "total_memory": total_memory,
'is_admin': role == 'admin', "total_cores": total_cores,
'last_executions': sorted(last_executions, key=lambda e: e.id),
'running_executions': sorted(last_running_executions, key=lambda e: e.id)
} }
self.render('home_user.html', **template_vars) self.render('home_user.html', **template_vars)
def _aml_homepage(self, uid):
"""Home page for students of the AML course."""
template_vars = {
'uid': uid
}
return self.render('home_guest.html', **template_vars)
This diff is collapsed.
//! moment-timezone.js
//! version : 0.5.13
//! Copyright (c) JS Foundation and other contributors
//! license : MIT
//! github.com/moment/moment-timezone
!function(a,b){"use strict";"function"==typeof define&&define.amd?define(["moment"],b):"object"==typeof module&&module.exports?module.exports=b(require("moment")):b(a.moment)}(this,function(a){"use strict";function b(a){return a>96?a-87:a>64?a-29:a-48}function c(a){var c,d=0,e=a.split("."),f=e[0],g=e[1]||"",h=1,i=0,j=1;for(45===a.charCodeAt(0)&&(d=1,j=-1),d;d<f.length;d++)c=b(f.charCodeAt(d)),i=60*i+c;for(d=0;d<g.length;d++)h/=60,c=b(g.charCodeAt(d)),i+=c*h;return i*j}function d(a){for(var b=0;b<a.length;b++)a[b]=c(a[b])}function e(a,b){for(var c=0;c<b;c++)a[c]=Math.round((a[c-1]||0)+6e4*a[c]);a[b-1]=1/0}function f(a,b){var c,d=[];for(c=0;c<b.length;c++)d[c]=a[b[c]];return d}function g(a){var b=a.split("|"),c=b[2].split(" "),g=b[3].split(""),h=b[4].split(" ");return d(c),d(g),d(h),e(h,g.length),{name:b[0],abbrs:f(b[1].split(" "),g),offsets:f(c,g),untils:h,population:0|b[5]}}function h(a){a&&this._set(g(a))}function i(a){var b=a.toTimeString(),c=b.match(/\([a-z ]+\)/i);c&&c[0]?(c=c[0].match(/[A-Z]/g),c=c?c.join(""):void 0):(c=b.match(/[A-Z]{3,5}/g),c=c?c[0]:void 0),"GMT"===c&&(c=void 0),this.at=+a,this.abbr=c,this.offset=a.getTimezoneOffset()}function j(a){this.zone=a,this.offsetScore=0,this.abbrScore=0}function k(a,b){for(var c,d;d=6e4*((b.at-a.at)/12e4|0);)c=new i(new Date(a.at+d)),c.offset===a.offset?a=c:b=c;return a}function l(){var a,b,c,d=(new Date).getFullYear()-2,e=new i(new Date(d,0,1)),f=[e];for(c=1;c<48;c++)b=new i(new Date(d,c,1)),b.offset!==e.offset&&(a=k(e,b),f.push(a),f.push(new i(new Date(a.at+6e4)))),e=b;for(c=0;c<4;c++)f.push(new i(new Date(d+c,0,1))),f.push(new i(new Date(d+c,6,1)));return f}function m(a,b){return a.offsetScore!==b.offsetScore?a.offsetScore-b.offsetScore:a.abbrScore!==b.abbrScore?a.abbrScore-b.abbrScore:b.zone.population-a.zone.population}function n(a,b){var c,e;for(d(b),c=0;c<b.length;c++)e=b[c],I[e]=I[e]||{},I[e][a]=!0}function o(a){var b,c,d,e=a.length,f={},g=[];for(b=0;b<e;b++){d=I[a[b].offset]||{};for(c in d)d.hasOwnProperty(c)&&(f[c]=!0)}for(b in f)f.hasOwnProperty(b)&&g.push(H[b]);return g}function p(){try{var a=Intl.DateTimeFormat().resolvedOptions().timeZone;if(a){var b=H[r(a)];if(b)return b;z("Moment Timezone found "+a+" from the Intl api, but did not have that data loaded.")}}catch(c){}var d,e,f,g=l(),h=g.length,i=o(g),k=[];for(e=0;e<i.length;e++){for(d=new j(t(i[e]),h),f=0;f<h;f++)d.scoreOffsetAt(g[f]);k.push(d)}return k.sort(m),k.length>0?k[0].zone.name:void 0}function q(a){return D&&!a||(D=p()),D}function r(a){return(a||"").toLowerCase().replace(/\//g,"_")}function s(a){var b,c,d,e;for("string"==typeof a&&(a=[a]),b=0;b<a.length;b++)d=a[b].split("|"),c=d[0],e=r(c),F[e]=a[b],H[e]=c,d[5]&&n(e,d[2].split(" "))}function t(a,b){a=r(a);var c,d=F[a];return d instanceof h?d:"string"==typeof d?(d=new h(d),F[a]=d,d):G[a]&&b!==t&&(c=t(G[a],t))?(d=F[a]=new h,d._set(c),d.name=H[a],d):null}function u(){var a,b=[];for(a in H)H.hasOwnProperty(a)&&(F[a]||F[G[a]])&&H[a]&&b.push(H[a]);return b.sort()}function v(a){var b,c,d,e;for("string"==typeof a&&(a=[a]),b=0;b<a.length;b++)c=a[b].split("|"),d=r(c[0]),e=r(c[1]),G[d]=e,H[d]=c[0],G[e]=d,H[e]=c[1]}function w(a){s(a.zones),v(a.links),A.dataVersion=a.version}function x(a){return x.didShowError||(x.didShowError=!0,z("moment.tz.zoneExists('"+a+"') has been deprecated in favor of !moment.tz.zone('"+a+"')")),!!t(a)}function y(a){return!(!a._a||void 0!==a._tzm)}function z(a){"undefined"!=typeof console&&"function"==typeof console.error&&console.error(a)}function A(b){var c=Array.prototype.slice.call(arguments,0,-1),d=arguments[arguments.length-1],e=t(d),f=a.utc.apply(null,c);return e&&!a.isMoment(b)&&y(f)&&f.add(e.parse(f),"minutes"),f.tz(d),f}function B(a){return function(){return this._z?this._z.abbr(this):a.call(this)}}function C(a){return function(){return this._z=null,a.apply(this,arguments)}}var D,E="0.5.13",F={},G={},H={},I={},J=a.version.split("."),K=+J[0],L=+J[1];(K<2||2===K&&L<6)&&z("Moment Timezone requires Moment.js >= 2.6.0. You are using Moment.js "+a.version+". See momentjs.com"),h.prototype={_set:function(a){this.name=a.name,this.abbrs=a.abbrs,this.untils=a.untils,this.offsets=a.offsets,this.population=a.population},_index:function(a){var b,c=+a,d=this.untils;for(b=0;b<d.length;b++)if(c<d[b])return b},parse:function(a){var b,c,d,e,f=+a,g=this.offsets,h=this.untils,i=h.length-1;for(e=0;e<i;e++)if(b=g[e],c=g[e+1],d=g[e?e-1:e],b<c&&A.moveAmbiguousForward?b=c:b>d&&A.moveInvalidForward&&(b=d),f<h[e]-6e4*b)return g[e];return g[i]},abbr:function(a){return this.abbrs[this._index(a)]},offset:function(a){return this.offsets[this._index(a)]}},j.prototype.scoreOffsetAt=function(a){this.offsetScore+=Math.abs(this.zone.offset(a.at)-a.offset),this.zone.abbr(a.at).replace(/[^A-Z]/g,"")!==a.abbr&&this.abbrScore++},A.version=E,A.dataVersion="",A._zones=F,A._links=G,A._names=H,A.add=s,A.link=v,A.load=w,A.zone=t,A.zoneExists=x,A.guess=q,A.names=u,A.Zone=h,A.unpack=g,A.unpackBase60=c,A.needsOffset=y,A.moveInvalidForward=!0,A.moveAmbiguousForward=!1;var M=a.fn;a.tz=A,a.defaultZone=null,a.updateOffset=function(b,c){var d,e=a.defaultZone;void 0===b._z&&(e&&y(b)&&!b._isUTC&&(b._d=a.utc(b._a)._d,b.utc().add(e.parse(b),"minutes")),b._z=e),b._z&&(d=b._z.offset(b),Math.abs(d)<16&&(d/=60),void 0!==b.utcOffset?b.utcOffset(-d,c):b.zone(d,c))},M.tz=function(b){return b?(this._z=t(b),this._z?a.updateOffset(this):z("Moment Timezone has no data for "+b+". See http://momentjs.com/timezone/docs/#/data-loading/."),this):this._z?this._z.name:void 0},M.zoneName=B(M.zoneName),M.zoneAbbr=B(M.zoneAbbr),M.utc=C(M.utc),a.tz.setDefault=function(b){return(K<2||2===K&&L<9)&&z("Moment Timezone setDefault() requires Moment.js >= 2.9.0. You are using Moment.js "+a.version+"."),a.defaultZone=b?t(b):null,a};var N=a.momentProperties;return"[object Array]"===Object.prototype.toString.call(N)?(N.push("_z"),N.push("_a")):N&&(N._z=null),a});
This source diff could not be displayed because it is too large. You can view the blob instead.
body { body {
font-family: sans-serif; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
max-width: 90%; max-width: 90%;
margin-left: 20px; margin-left: 20px;
font-size: 95%;
} }
a:link { a:link {
color: rgba(79, 140, 30, 1); color: rgb(53, 51, 144);
text-decoration: none; text-decoration: none;
} }
a:visited { a:visited {
color: rgba(79, 140, 30, 1); color: rgb(53, 51, 144);
text-decoration: none; text-decoration: none;
} }
div.user_info { /* header */
font-size: smaller;
clear: both;
padding-top: 20px;
}
table.app_list { div.header {
border-collapse: collapse; width: 100%;
height: 5em;
display: flex;
align-items: center;
border-bottom: 1px solid rgb(0, 169, 225);
} }
table.app_list tr { #logos img {
border-top: 1px black solid; height: 4em;
} }
table.app_list td { #user_info {
padding-right: 1.5em; padding-right: 1em;
padding-top: 0.4em;
position: absolute;
right: 0;
} }
table.app_list tr.even { #nav {
background-color: #F3FEEA; font-size: 1.4em;
} }
div.status_line { .nav-item {
float: left; float: left;
padding-left: 1em;
} }
#footer { .nav-item:hover a {
font-size: smaller; color: #000;
}
#wrapper {
width: 800px;
} }
#navigation { /* content */
background-color: #fff; #content {
border: #ddd 1px solid; clear: both;
border-radius: 10px;
margin: 10px;
padding: 10px;
} }
#navigation li { table.app_list {
margin: 2px 0; border-collapse: collapse;
width: 95%;
} }
label.error { table.app_list tr {
color: #ff0000; border-top: 1px #bad5e1 solid;
margin-left: 10px; text-align: left;
position: relative;
} }
.navigation { table.app_list td, table.app_list th {
border-top: #ddd 1px solid; padding-right: 1em;
margin-top: 10px; padding-bottom: 0.5em;
padding-top: 10px; padding-top: 0.5em;
} }
.navigation ul { table.app_list tbody tr:nth-child(2n+1) {
margin: 0; background-color: #F3FEEA;
padding: 0;
list-style: none;
} }
.navigation li { table.sortable th:not(.sorttable_sorted):not(.sorttable_sorted_reverse):not(.sorttable_nosort):after {
float: left; content: "\00A0\2195";
margin-right: 10px;
} }
table.sortable th::after, th.sorttable_sorted::after, th.sorttable_sorted_reverse::after {
.clearfix:before, .clearfix:after { content: " ";
content: "\0020"; display: inline-block;
display: block;
height: 0;
visibility: hidden;
} }
.clearfix:after { label.filter {
clear: both; font-size: 1.4em;
font-weight: bold;
} }
input { input {
margin-top: 5px; margin-top: 5px;
} }
section { input.filter {
padding-bottom: 10px; margin-top: 1.4em;
margin-bottom: 1.5em;
}
input.error input:invalid {
box-shadow: 0 0 4px red;
} }
#loginbox { #loginbox {
...@@ -112,10 +110,6 @@ section { ...@@ -112,10 +110,6 @@ section {
width: 100%; width: 100%;
} }