diff --git a/Dockerfile b/Dockerfile index 6a3fa1f2f..7ba5e7501 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,19 +38,13 @@ ADD . . # Run grunt RUN cd grunt && grunt -ADD conf/init/svlogd_config /svlogd_config ADD conf/init/doupdatelimits.sh /etc/my_init.d/ -ADD conf/init/preplogsdir.sh /etc/my_init.d/ +ADD conf/init/copy_syslog_config.sh /etc/my_init.d/ ADD conf/init/runmigration.sh /etc/my_init.d/ -ADD conf/init/gunicorn_web /etc/service/gunicorn_web -ADD conf/init/gunicorn_registry /etc/service/gunicorn_registry -ADD conf/init/gunicorn_verbs /etc/service/gunicorn_verbs -ADD conf/init/nginx /etc/service/nginx -ADD conf/init/diffsworker /etc/service/diffsworker -ADD conf/init/notificationworker /etc/service/notificationworker -ADD conf/init/buildlogsarchiver /etc/service/buildlogsarchiver -ADD conf/init/buildmanager /etc/service/buildmanager +ADD conf/init/service/ /etc/service/ + +RUN rm -rf /etc/service/syslog-forwarder # Download any external libs. RUN mkdir static/fonts static/ldn diff --git a/app.py b/app.py index 78243de75..33eabf1f8 100644 --- a/app.py +++ b/app.py @@ -39,7 +39,6 @@ OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml' OVERRIDE_CONFIG_PY_FILENAME = 'conf/stack/config.py' OVERRIDE_CONFIG_KEY = 'QUAY_OVERRIDE_CONFIG' -LICENSE_FILENAME = 'conf/stack/license.enc' CONFIG_PROVIDER = FileConfigProvider(OVERRIDE_CONFIG_DIRECTORY, 'config.yaml', 'config.py') diff --git a/auth/auth.py b/auth/auth.py index 79e07e3be..30e2f68db 100644 --- a/auth/auth.py +++ b/auth/auth.py @@ -114,7 +114,8 @@ def _process_basic_auth(auth): logger.debug('Invalid robot or password for robot: %s' % credentials[0]) else: - authenticated = authentication.verify_user(credentials[0], credentials[1]) + (authenticated, error_message) = authentication.verify_user(credentials[0], credentials[1], + basic_auth=True) if authenticated: logger.debug('Successfully validated user: %s' % authenticated.username) diff --git a/avatars/avatars.py b/avatars/avatars.py index 40935df10..220cae9cb 100644 --- a/avatars/avatars.py +++ b/avatars/avatars.py @@ -1,4 +1,5 @@ import hashlib +import math class Avatar(object): def __init__(self, app=None): @@ -7,8 +8,7 @@ class Avatar(object): def _init_app(self, app): return AVATAR_CLASSES[app.config.get('AVATAR_KIND', 'Gravatar')]( - app.config['SERVER_HOSTNAME'], - app.config['PREFERRED_URL_SCHEME']) + app.config['PREFERRED_URL_SCHEME'], app.config['AVATAR_COLORS'], app.config['HTTPCLIENT']) def __getattr__(self, name): return getattr(self.state, name, None) @@ -16,48 +16,83 @@ class Avatar(object): class BaseAvatar(object): """ Base class for all avatar implementations. """ - def __init__(self, server_hostname, preferred_url_scheme): - self.server_hostname = server_hostname + def __init__(self, preferred_url_scheme, colors, http_client): self.preferred_url_scheme = preferred_url_scheme + self.colors = colors + self.http_client = http_client - def get_url(self, email, size=16, name=None): - """ Returns the full URL for viewing the avatar of the given email address, with - an optional size. + def get_mail_html(self, name, email_or_id, size=16, kind='user'): + """ Returns the full HTML and CSS for viewing the avatar of the given name and email address, + with an optional size. """ - raise NotImplementedError + data = self.get_data(name, email_or_id, kind) + url = self._get_url(data['hash'], size) if kind != 'team' else None + font_size = size - 6 - def compute_hash(self, email, name=None): - """ Computes the avatar hash for the given email address. If the name is given and a default - avatar is being computed, the name can be used in place of the email address. """ - raise NotImplementedError + if url is not None: + # Try to load the gravatar. If we get a non-404 response, then we use it in place of + # the CSS avatar. + response = self.http_client.get(url) + if response.status_code == 200: + return """%s""" % (url, size, size, kind) + + radius = '50%' if kind == 'team' else '0%' + letter = 'Ω' if kind == 'team' and data['name'] == 'owners' else data['name'].upper()[0] + + return """ + + %s + +""" % (size, size, data['color'], font_size, size, radius, letter) + + def get_data_for_user(self, user): + return self.get_data(user.username, user.email, 'robot' if user.robot else 'user') + + def get_data_for_team(self, team): + return self.get_data(team.name, team.name, 'team') + + def get_data_for_org(self, org): + return self.get_data(org.username, org.email, 'org') + + def get_data(self, name, email_or_id, kind='user'): + """ Computes and returns the full data block for the avatar: + { + 'name': name, + 'hash': The gravatar hash, if any. + 'color': The color for the avatar + } + """ + colors = self.colors + hash_value = hashlib.md5(email_or_id.strip().lower()).hexdigest() + + byte_count = int(math.ceil(math.log(len(colors), 16))) + byte_data = hash_value[0:byte_count] + hash_color = colors[int(byte_data, 16) % len(colors)] + + return { + 'name': name, + 'hash': hash_value, + 'color': hash_color, + 'kind': kind + } + + def _get_url(self, hash_value, size): + """ Returns the URL for displaying the overlay avatar. """ + return None class GravatarAvatar(BaseAvatar): """ Avatar system that uses gravatar for generating avatars. """ - def compute_hash(self, email, name=None): - email = email or "" - return hashlib.md5(email.strip().lower()).hexdigest() - - def get_url(self, email, size=16, name=None): - computed = self.compute_hash(email, name=name) - return '%s://www.gravatar.com/avatar/%s?d=identicon&size=%s' % (self.preferred_url_scheme, - computed, size) + def _get_url(self, hash_value, size=16): + return '%s://www.gravatar.com/avatar/%s?d=404&size=%s' % (self.preferred_url_scheme, + hash_value, size) class LocalAvatar(BaseAvatar): """ Avatar system that uses the local system for generating avatars. """ - def compute_hash(self, email, name=None): - email = email or "" - if not name and not email: - return '' - - prefix = name if name else email - return prefix[0] + hashlib.md5(email.strip().lower()).hexdigest() - - def get_url(self, email, size=16, name=None): - computed = self.compute_hash(email, name=name) - return '%s://%s/avatar/%s?size=%s' % (self.preferred_url_scheme, self.server_hostname, - computed, size) - + pass AVATAR_CLASSES = { 'gravatar': GravatarAvatar, diff --git a/buildman/manager/ephemeral.py b/buildman/manager/ephemeral.py index 40876cdf5..7e24094c8 100644 --- a/buildman/manager/ephemeral.py +++ b/buildman/manager/ephemeral.py @@ -157,8 +157,12 @@ class EphemeralBuilderManager(BaseManager): etcd_host = self._manager_config.get('ETCD_HOST', '127.0.0.1') etcd_port = self._manager_config.get('ETCD_PORT', 2379) - etcd_auth = self._manager_config.get('ETCD_CERT_AND_KEY', None) etcd_ca_cert = self._manager_config.get('ETCD_CA_CERT', None) + + etcd_auth = self._manager_config.get('ETCD_CERT_AND_KEY', None) + if etcd_auth is not None: + etcd_auth = tuple(etcd_auth) # Convert YAML list to a tuple + etcd_protocol = 'http' if etcd_auth is None else 'https' logger.debug('Connecting to etcd on %s:%s', etcd_host, etcd_port) diff --git a/buildman/manager/executor.py b/buildman/manager/executor.py index b548420f5..b6a293fc0 100644 --- a/buildman/manager/executor.py +++ b/buildman/manager/executor.py @@ -69,6 +69,7 @@ class BuilderExecutor(object): manager_hostname=manager_hostname, coreos_channel=coreos_channel, worker_tag=self.executor_config['WORKER_TAG'], + logentries_token=self.executor_config.get('LOGENTRIES_TOKEN', None), ) diff --git a/buildman/templates/cloudconfig.yaml b/buildman/templates/cloudconfig.yaml index 51bb2f090..2f274361a 100644 --- a/buildman/templates/cloudconfig.yaml +++ b/buildman/templates/cloudconfig.yaml @@ -12,6 +12,9 @@ write_files: REALM={{ realm }} TOKEN={{ token }} SERVER=wss://{{ manager_hostname }} + {% if logentries_token -%} + LOGENTRIES_TOKEN={{ logentries_token }} + {%- endif %} coreos: update: @@ -19,6 +22,17 @@ coreos: group: {{ coreos_channel }} units: + - name: systemd-journal-gatewayd.socket + command: start + enable: yes + content: | + [Unit] + Description=Journal Gateway Service Socket + [Socket] + ListenStream=/var/run/journald.sock + Service=systemd-journal-gatewayd.service + [Install] + WantedBy=sockets.target {{ dockersystemd('quay-builder', 'quay.io/coreos/registry-build-worker', quay_username, @@ -29,3 +43,10 @@ coreos: flattened=True, restart_policy='no' ) | indent(4) }} + {% if logentries_token -%} + {{ dockersystemd('builder-logs', + 'quay.io/kelseyhightower/journal-2-logentries', + extra_args='--env-file /root/overrides.list -v /run/journald.sock:/run/journald.sock', + after_units=['quay-builder.service'] + ) | indent(4) }} + {%- endif %} diff --git a/buildstatus/building.svg b/buildstatus/building.svg index dc7aeae7b..8e26edf87 100644 --- a/buildstatus/building.svg +++ b/buildstatus/building.svg @@ -1 +1 @@ -Docker ImageDocker Imagebuildingbuilding \ No newline at end of file +containercontainerbuildingbuilding \ No newline at end of file diff --git a/buildstatus/failed.svg b/buildstatus/failed.svg index 069d9f4e4..cc74c2381 100644 --- a/buildstatus/failed.svg +++ b/buildstatus/failed.svg @@ -1 +1 @@ -Docker ImageDocker Imagebuild failedbuild failed \ No newline at end of file +containercontainerfailedfailed \ No newline at end of file diff --git a/buildstatus/none.svg b/buildstatus/none.svg index 3c31d29b1..0e4680acf 100644 --- a/buildstatus/none.svg +++ b/buildstatus/none.svg @@ -1 +1 @@ -Docker ImageDocker Imagenonenone \ No newline at end of file +containercontainernonenone \ No newline at end of file diff --git a/buildstatus/ready.svg b/buildstatus/ready.svg index 111262e3b..50e451a01 100644 --- a/buildstatus/ready.svg +++ b/buildstatus/ready.svg @@ -1 +1 @@ -Docker ImageDocker Imagereadyready \ No newline at end of file +containercontainerreadyready \ No newline at end of file diff --git a/conf/http-base.conf b/conf/http-base.conf index 8b7ff9e0b..3c3d57372 100644 --- a/conf/http-base.conf +++ b/conf/http-base.conf @@ -4,7 +4,7 @@ types_hash_max_size 2048; include /usr/local/nginx/conf/mime.types.default; default_type application/octet-stream; -access_log /var/log/nginx/nginx.access.log; +access_log /dev/stdout; sendfile on; gzip on; diff --git a/conf/init/buildlogsarchiver/log/run b/conf/init/buildlogsarchiver/log/run deleted file mode 100755 index c35fb1fb9..000000000 --- a/conf/init/buildlogsarchiver/log/run +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -exec svlogd /var/log/buildlogsarchiver/ \ No newline at end of file diff --git a/conf/init/buildmanager/log/run b/conf/init/buildmanager/log/run deleted file mode 100755 index 1dd4c3fef..000000000 --- a/conf/init/buildmanager/log/run +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -exec svlogd /var/log/buildmanager/ \ No newline at end of file diff --git a/conf/init/copy_syslog_config.sh b/conf/init/copy_syslog_config.sh new file mode 100755 index 000000000..7acd62b6b --- /dev/null +++ b/conf/init/copy_syslog_config.sh @@ -0,0 +1,6 @@ +#! /bin/sh + +if [ -e /conf/stack/syslog-ng-extra.conf ] +then + cp /conf/stack/syslog-ng-extra.conf /etc/syslog-ng/conf.d/ +fi diff --git a/conf/init/diffsworker/log/run b/conf/init/diffsworker/log/run deleted file mode 100755 index 066f7415a..000000000 --- a/conf/init/diffsworker/log/run +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -exec svlogd /var/log/diffsworker/ \ No newline at end of file diff --git a/conf/init/gunicorn_registry/log/run b/conf/init/gunicorn_registry/log/run deleted file mode 100755 index 1896ef533..000000000 --- a/conf/init/gunicorn_registry/log/run +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -exec svlogd /var/log/gunicorn_registry/ \ No newline at end of file diff --git a/conf/init/gunicorn_verbs/log/run b/conf/init/gunicorn_verbs/log/run deleted file mode 100755 index 2b061e193..000000000 --- a/conf/init/gunicorn_verbs/log/run +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -exec svlogd /var/log/gunicorn_verbs/ \ No newline at end of file diff --git a/conf/init/gunicorn_web/log/run b/conf/init/gunicorn_web/log/run deleted file mode 100755 index de17cdf61..000000000 --- a/conf/init/gunicorn_web/log/run +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -exec svlogd /var/log/gunicorn_web/ \ No newline at end of file diff --git a/conf/init/nginx/log/run b/conf/init/nginx/log/run deleted file mode 100755 index 30476f6e6..000000000 --- a/conf/init/nginx/log/run +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -exec svlogd /var/log/nginx/ \ No newline at end of file diff --git a/conf/init/notificationworker/log/run b/conf/init/notificationworker/log/run deleted file mode 100755 index 46f8431a7..000000000 --- a/conf/init/notificationworker/log/run +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -exec svlogd -t /var/log/notificationworker/ \ No newline at end of file diff --git a/conf/init/preplogsdir.sh b/conf/init/preplogsdir.sh deleted file mode 100755 index 93c3ee5af..000000000 --- a/conf/init/preplogsdir.sh +++ /dev/null @@ -1,10 +0,0 @@ -#! /bin/sh - -echo 'Linking config files to logs directory' -for svc in `ls /etc/service/` -do - if [ ! -d /var/log/$svc ]; then - mkdir -p /var/log/$svc - ln -s /svlogd_config /var/log/$svc/config - fi -done diff --git a/conf/init/service/buildlogsarchiver/log/run b/conf/init/service/buildlogsarchiver/log/run new file mode 100755 index 000000000..3bcd9ba8a --- /dev/null +++ b/conf/init/service/buildlogsarchiver/log/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec logger -i -t buildlogsarchiver \ No newline at end of file diff --git a/conf/init/buildlogsarchiver/run b/conf/init/service/buildlogsarchiver/run similarity index 100% rename from conf/init/buildlogsarchiver/run rename to conf/init/service/buildlogsarchiver/run diff --git a/conf/init/service/buildmanager/log/run b/conf/init/service/buildmanager/log/run new file mode 100755 index 000000000..b35e28af9 --- /dev/null +++ b/conf/init/service/buildmanager/log/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec logger -i -t buildmanager \ No newline at end of file diff --git a/conf/init/buildmanager/run b/conf/init/service/buildmanager/run similarity index 100% rename from conf/init/buildmanager/run rename to conf/init/service/buildmanager/run diff --git a/conf/init/service/diffsworker/log/run b/conf/init/service/diffsworker/log/run new file mode 100755 index 000000000..8e3dca5f3 --- /dev/null +++ b/conf/init/service/diffsworker/log/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec logger -i -t diffsworker \ No newline at end of file diff --git a/conf/init/diffsworker/run b/conf/init/service/diffsworker/run similarity index 100% rename from conf/init/diffsworker/run rename to conf/init/service/diffsworker/run diff --git a/conf/init/service/gunicorn_registry/log/run b/conf/init/service/gunicorn_registry/log/run new file mode 100755 index 000000000..5b5b37af9 --- /dev/null +++ b/conf/init/service/gunicorn_registry/log/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec logger -i -t gunicorn_registry \ No newline at end of file diff --git a/conf/init/gunicorn_registry/run b/conf/init/service/gunicorn_registry/run similarity index 100% rename from conf/init/gunicorn_registry/run rename to conf/init/service/gunicorn_registry/run diff --git a/conf/init/service/gunicorn_verbs/log/run b/conf/init/service/gunicorn_verbs/log/run new file mode 100755 index 000000000..d0bc335d7 --- /dev/null +++ b/conf/init/service/gunicorn_verbs/log/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec logger -i -t gunicorn_verbs \ No newline at end of file diff --git a/conf/init/gunicorn_verbs/run b/conf/init/service/gunicorn_verbs/run similarity index 100% rename from conf/init/gunicorn_verbs/run rename to conf/init/service/gunicorn_verbs/run diff --git a/conf/init/service/gunicorn_web/log/run b/conf/init/service/gunicorn_web/log/run new file mode 100755 index 000000000..c96d365a5 --- /dev/null +++ b/conf/init/service/gunicorn_web/log/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec logger -i -t gunicorn_web \ No newline at end of file diff --git a/conf/init/gunicorn_web/run b/conf/init/service/gunicorn_web/run similarity index 100% rename from conf/init/gunicorn_web/run rename to conf/init/service/gunicorn_web/run diff --git a/conf/init/service/nginx/log/run b/conf/init/service/nginx/log/run new file mode 100755 index 000000000..168af6d3e --- /dev/null +++ b/conf/init/service/nginx/log/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec logger -i -t nginx \ No newline at end of file diff --git a/conf/init/nginx/run b/conf/init/service/nginx/run similarity index 100% rename from conf/init/nginx/run rename to conf/init/service/nginx/run diff --git a/conf/init/service/notificationworker/log/run b/conf/init/service/notificationworker/log/run new file mode 100755 index 000000000..49747f3ce --- /dev/null +++ b/conf/init/service/notificationworker/log/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec logger -i -t notificationworker \ No newline at end of file diff --git a/conf/init/notificationworker/run b/conf/init/service/notificationworker/run similarity index 100% rename from conf/init/notificationworker/run rename to conf/init/service/notificationworker/run diff --git a/conf/init/svlogd_config b/conf/init/svlogd_config deleted file mode 100644 index 2ccee1e0c..000000000 --- a/conf/init/svlogd_config +++ /dev/null @@ -1,3 +0,0 @@ -s100000000 -t86400 -n4 diff --git a/conf/proxy-protocol.conf b/conf/proxy-protocol.conf index 5897f1839..ba00507f5 100644 --- a/conf/proxy-protocol.conf +++ b/conf/proxy-protocol.conf @@ -5,4 +5,4 @@ real_ip_header proxy_protocol; log_format elb_pp '$proxy_protocol_addr - $remote_user [$time_local] ' '"$request" $status $body_bytes_sent ' '"$http_referer" "$http_user_agent"'; -access_log /var/log/nginx/nginx.access.log elb_pp; +access_log /dev/stdout elb_pp; diff --git a/conf/root-base.conf b/conf/root-base.conf index 02c004564..357e6ed03 100644 --- a/conf/root-base.conf +++ b/conf/root-base.conf @@ -1,7 +1,7 @@ # vim: ft=nginx pid /tmp/nginx.pid; -error_log /var/log/nginx/nginx.error.log; +error_log /dev/stdout; worker_processes 2; worker_priority -10; diff --git a/conf/server-base.conf b/conf/server-base.conf index 2f03b11b2..3853fbccf 100644 --- a/conf/server-base.conf +++ b/conf/server-base.conf @@ -1,6 +1,5 @@ # vim: ft=nginx -client_body_temp_path /var/log/nginx/client_body 1 2; server_name _; keepalive_timeout 5; @@ -36,7 +35,7 @@ location /v1/repositories/ { proxy_pass http://registry_app_server; proxy_read_timeout 2000; - proxy_temp_path /var/log/nginx/proxy_temp 1 2; + proxy_temp_path /tmp 1 2; limit_req zone=repositories burst=10; } @@ -47,7 +46,7 @@ location /v1/ { proxy_request_buffering off; proxy_pass http://registry_app_server; - proxy_temp_path /var/log/nginx/proxy_temp 1 2; + proxy_temp_path /tmp 1 2; client_max_body_size 20G; } @@ -58,7 +57,7 @@ location /c1/ { proxy_request_buffering off; proxy_pass http://verbs_app_server; - proxy_temp_path /var/log/nginx/proxy_temp 1 2; + proxy_temp_path /tmp 1 2; limit_req zone=verbs burst=10; } diff --git a/config.py b/config.py index 339ffca34..6fe1c4042 100644 --- a/config.py +++ b/config.py @@ -45,8 +45,6 @@ class DefaultConfig(object): PREFERRED_URL_SCHEME = 'http' SERVER_HOSTNAME = 'localhost:5000' - AVATAR_KIND = 'local' - REGISTRY_TITLE = 'CoreOS Enterprise Registry' REGISTRY_TITLE_SHORT = 'Enterprise Registry' @@ -165,6 +163,10 @@ class DefaultConfig(object): # Feature Flag: Whether users can be renamed FEATURE_USER_RENAME = False + # Feature Flag: Whether non-encrypted passwords (as opposed to encrypted tokens) can be used for + # basic auth. + FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH = False + BUILD_MANAGER = ('enterprise', {}) DISTRIBUTED_STORAGE_CONFIG = { @@ -201,3 +203,11 @@ class DefaultConfig(object): # Signed registry grant token expiration in seconds SIGNED_GRANT_EXPIRATION_SEC = 60 * 60 * 24 # One day to complete a push/pull + + # The various avatar background colors. + AVATAR_KIND = 'local' + AVATAR_COLORS = ['#969696', '#aec7e8', '#ff7f0e', '#ffbb78', '#2ca02c', '#98df8a', '#d62728', + '#ff9896', '#9467bd', '#c5b0d5', '#8c564b', '#c49c94', '#e377c2', '#f7b6d2', + '#7f7f7f', '#c7c7c7', '#bcbd22', '#1f77b4', '#17becf', '#9edae5', '#393b79', + '#5254a3', '#6b6ecf', '#9c9ede', '#9ecae1', '#31a354', '#b5cf6b', '#a1d99b', + '#8c6d31', '#ad494a', '#e7ba52', '#a55194'] diff --git a/data/database.py b/data/database.py index 82a179325..837309a42 100644 --- a/data/database.py +++ b/data/database.py @@ -139,7 +139,7 @@ def uuid_generator(): return str(uuid.uuid4()) -_get_epoch_timestamp = lambda: int(time.time()) +get_epoch_timestamp = lambda: int(time.time()) def close_db_filter(_): @@ -167,6 +167,17 @@ class BaseModel(ReadSlaveModel): database = db read_slaves = (read_slave,) + def __getattribute__(self, name): + """ Adds _id accessors so that foreign key field IDs can be looked up without making + a database roundtrip. + """ + if name.endswith('_id'): + field_name = name[0:len(name) - 3] + if field_name in self._meta.fields: + return self._data.get(field_name) + + return super(BaseModel, self).__getattribute__(name) + class User(BaseModel): uuid = CharField(default=uuid_generator, max_length=36, null=True) @@ -484,7 +495,7 @@ class RepositoryTag(BaseModel): name = CharField() image = ForeignKeyField(Image) repository = ForeignKeyField(Repository) - lifetime_start_ts = IntegerField(default=_get_epoch_timestamp) + lifetime_start_ts = IntegerField(default=get_epoch_timestamp) lifetime_end_ts = IntegerField(null=True, index=True) hidden = BooleanField(default=False) @@ -493,6 +504,9 @@ class RepositoryTag(BaseModel): read_slaves = (read_slave,) indexes = ( (('repository', 'name'), False), + + # This unique index prevents deadlocks when concurrently moving and deleting tags + (('repository', 'name', 'lifetime_end_ts'), True), ) diff --git a/data/migrations/migration.sh b/data/migrations/migration.sh index 17901e130..1140c02b8 100755 --- a/data/migrations/migration.sh +++ b/data/migrations/migration.sh @@ -19,7 +19,7 @@ up_mysql() { down_mysql() { docker kill mysql - docker rm mysql + docker rm -v mysql } up_mariadb() { @@ -36,24 +36,24 @@ up_mariadb() { down_mariadb() { docker kill mariadb - docker rm mariadb + docker rm -v mariadb } up_percona() { # Run a SQL database on port 3306 inside of Docker. - docker run --name percona -p 3306:3306 -d dockerfile/percona + docker run --name percona -p 3306:3306 -d percona # Sleep for 10s echo 'Sleeping for 10...' sleep 10 # Add the daabase to mysql. - docker run --rm --link percona:percona dockerfile/percona sh -c 'echo "create database genschema" | mysql -h $PERCONA_PORT_3306_TCP_ADDR' + docker run --rm --link percona:percona percona sh -c 'echo "create database genschema" | mysql -h $PERCONA_PORT_3306_TCP_ADDR' } down_percona() { docker kill percona - docker rm percona + docker rm -v percona } up_postgres() { @@ -70,7 +70,7 @@ up_postgres() { down_postgres() { docker kill postgres - docker rm postgres + docker rm -v postgres } gen_migrate() { diff --git a/data/migrations/versions/2b4dc0818a5e_add_a_unique_index_to_prevent_deadlocks_.py b/data/migrations/versions/2b4dc0818a5e_add_a_unique_index_to_prevent_deadlocks_.py new file mode 100644 index 000000000..8efe0c123 --- /dev/null +++ b/data/migrations/versions/2b4dc0818a5e_add_a_unique_index_to_prevent_deadlocks_.py @@ -0,0 +1,26 @@ +"""Add a unique index to prevent deadlocks with tags. + +Revision ID: 2b4dc0818a5e +Revises: 2b2529fd23ff +Create Date: 2015-03-20 23:37:10.558179 + +""" + +# revision identifiers, used by Alembic. +revision = '2b4dc0818a5e' +down_revision = '2b2529fd23ff' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.create_index('repositorytag_repository_id_name_lifetime_end_ts', 'repositorytag', ['repository_id', 'name', 'lifetime_end_ts'], unique=True) + ### end Alembic commands ### + + +def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.drop_index('repositorytag_repository_id_name_lifetime_end_ts', table_name='repositorytag') + ### end Alembic commands ### diff --git a/data/model/legacy.py b/data/model/legacy.py index 2d076c5cc..7ec27eed9 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -18,7 +18,7 @@ from data.database import (User, Repository, Image, AccessToken, Role, Repositor DerivedImageStorage, ImageStorageTransformation, random_string_generator, db, BUILD_PHASE, QuayUserField, ImageStorageSignature, QueueItem, ImageStorageSignatureKind, validate_database_url, db_for_update, - AccessTokenKind, Star) + AccessTokenKind, Star, get_epoch_timestamp) from peewee import JOIN_LEFT_OUTER, fn from util.validation import (validate_username, validate_email, validate_password, INVALID_PASSWORD_MESSAGE) @@ -310,11 +310,54 @@ def _list_entity_robots(entity_name): .where(User.robot == True, User.username ** (entity_name + '+%'))) -def list_entity_robot_tuples(entity_name): - return (_list_entity_robots(entity_name) - .select(User.username, FederatedLogin.service_ident) - .tuples()) +class _TupleWrapper(object): + def __init__(self, data, fields): + self._data = data + self._fields = fields + def get(self, field): + return self._data[self._fields.index(field.name + ':' + field.model_class.__name__)] + + +class TupleSelector(object): + """ Helper class for selecting tuples from a peewee query and easily accessing + them as if they were objects. + """ + def __init__(self, query, fields): + self._query = query.select(*fields).tuples() + self._fields = [field.name + ':' + field.model_class.__name__ for field in fields] + + def __iter__(self): + return self._build_iterator() + + def _build_iterator(self): + for tuple_data in self._query: + yield _TupleWrapper(tuple_data, self._fields) + + + +def list_entity_robot_permission_teams(entity_name): + query = (_list_entity_robots(entity_name) + .join(RepositoryPermission, JOIN_LEFT_OUTER, + on=(RepositoryPermission.user == FederatedLogin.user)) + .join(Repository, JOIN_LEFT_OUTER) + .switch(User) + .join(TeamMember, JOIN_LEFT_OUTER) + .join(Team, JOIN_LEFT_OUTER)) + + fields = [User.username, FederatedLogin.service_ident, Repository.name, Team.name] + return TupleSelector(query, fields) + + +def list_robot_permissions(robot_name): + return (RepositoryPermission.select(RepositoryPermission, User, Repository) + .join(Repository) + .join(Visibility) + .switch(RepositoryPermission) + .join(Role) + .switch(RepositoryPermission) + .join(User) + .where(User.username == robot_name, User.robot == True)) def convert_user_to_organization(user, admin_user): # Change the user to an organization. @@ -636,6 +679,73 @@ def get_user_or_org_by_customer_id(customer_id): except User.DoesNotExist: return None +def get_matching_user_namespaces(namespace_prefix, username, limit=10): + query = (Repository + .select() + .join(Namespace, on=(Repository.namespace_user == Namespace.id)) + .switch(Repository) + .join(Visibility) + .switch(Repository) + .join(RepositoryPermission, JOIN_LEFT_OUTER) + .where(Namespace.username ** (namespace_prefix + '%')) + .group_by(Repository.namespace_user, Repository)) + + count = 0 + namespaces = {} + for repo in _filter_to_repos_for_user(query, username): + if not repo.namespace_user.username in namespaces: + namespaces[repo.namespace_user.username] = repo.namespace_user + count = count + 1 + if count >= limit: + break + + return namespaces.values() + +def get_matching_user_teams(team_prefix, user, limit=10): + query = (Team.select() + .join(User) + .switch(Team) + .join(TeamMember) + .where(TeamMember.user == user, Team.name ** (team_prefix + '%')) + .distinct(Team.id) + .limit(limit)) + + return query + + +def get_matching_robots(name_prefix, username, limit=10): + admined_orgs = (get_user_organizations(username) + .switch(Team) + .join(TeamRole) + .where(TeamRole.name == 'admin')) + + prefix_checks = False + + for org in admined_orgs: + prefix_checks = prefix_checks | (User.username ** (org.username + '+' + name_prefix + '%')) + + prefix_checks = prefix_checks | (User.username ** (username + '+' + name_prefix + '%')) + + return User.select().where(prefix_checks).limit(limit) + + +def get_matching_admined_teams(team_prefix, user, limit=10): + admined_orgs = (get_user_organizations(user.username) + .switch(Team) + .join(TeamRole) + .where(TeamRole.name == 'admin')) + + query = (Team.select() + .join(User) + .switch(Team) + .join(TeamMember) + .where(Team.name ** (team_prefix + '%'), Team.organization << (admined_orgs)) + .distinct(Team.id) + .limit(limit)) + + return query + + def get_matching_teams(team_prefix, organization): query = Team.select().where(Team.name ** (team_prefix + '%'), Team.organization == organization) @@ -654,13 +764,13 @@ def get_matching_users(username_prefix, robot_namespace=None, (User.robot == True))) query = (User - .select(User.username, User.robot) - .group_by(User.username, User.robot) + .select(User.username, User.email, User.robot) + .group_by(User.username, User.email, User.robot) .where(direct_user_query)) if organization: query = (query - .select(User.username, User.robot, fn.Sum(Team.id)) + .select(User.username, User.email, User.robot, fn.Sum(Team.id)) .join(TeamMember, JOIN_LEFT_OUTER) .join(Team, JOIN_LEFT_OUTER, on=((Team.id == TeamMember.team) & (Team.organization == organization)))) @@ -669,9 +779,11 @@ def get_matching_users(username_prefix, robot_namespace=None, class MatchingUserResult(object): def __init__(self, *args): self.username = args[0] - self.is_robot = args[1] + self.email = args[1] + self.robot = args[2] + if organization: - self.is_org_member = (args[2] != None) + self.is_org_member = (args[3] != None) else: self.is_org_member = None @@ -787,7 +899,7 @@ def get_visible_repository_count(username=None, include_public=True, def get_visible_repositories(username=None, include_public=True, page=None, - limit=None, sort=False, namespace=None): + limit=None, sort=False, namespace=None, namespace_only=False): query = _visible_repository_query(username=username, include_public=include_public, page=page, limit=limit, namespace=namespace, select_models=[Repository, Namespace, Visibility]) @@ -798,6 +910,9 @@ def get_visible_repositories(username=None, include_public=True, page=None, if limit: query = query.limit(limit) + if namespace and namespace_only: + query = query.where(Namespace.username == namespace) + return query @@ -876,11 +991,73 @@ def _get_public_repo_visibility(): return _public_repo_visibility_cache -def get_matching_repositories(repo_term, username=None): +def get_sorted_matching_repositories(prefix, only_public, checker, limit=10): + """ Returns repositories matching the given prefix string and passing the given checker + function. + """ + + last_week = datetime.now() - timedelta(weeks=1) + results = [] + existing_ids = [] + + def get_search_results(search_clause, with_count): + if len(results) >= limit: + return + + selected = [Repository, Namespace] + if with_count: + selected.append(fn.Count(LogEntry.id).alias('count')) + + query = (Repository.select(*selected) + .join(Namespace, JOIN_LEFT_OUTER, on=(Namespace.id == Repository.namespace_user)) + .switch(Repository) + .where(search_clause) + .group_by(Repository, Namespace)) + + if only_public: + query = query.where(Repository.visibility == _get_public_repo_visibility()) + + if existing_ids: + query = query.where(~(Repository.id << existing_ids)) + + if with_count: + query = (query.join(LogEntry, JOIN_LEFT_OUTER) + .where(LogEntry.datetime >= last_week) + .order_by(fn.Count(LogEntry.id).desc())) + + for result in query: + if len(results) >= limit: + return results + + # Note: We compare IDs here, instead of objects, because calling .visibility on the + # Repository will kick off a new SQL query to retrieve that visibility enum value. We don't + # join the visibility table in SQL, as well, because it is ungodly slow in MySQL :-/ + result.is_public = result.visibility_id == _get_public_repo_visibility().id + result.count = result.count if with_count else 0 + + if not checker(result): + continue + + results.append(result) + existing_ids.append(result.id) + + # For performance reasons, we conduct the repo name and repo namespace searches on their + # own, and with and without counts on their own. This also affords us the ability to give + # higher precedence to repository names matching over namespaces, which is semantically correct. + get_search_results((Repository.name ** (prefix + '%')), with_count=True) + get_search_results((Repository.name ** (prefix + '%')), with_count=False) + + get_search_results((Namespace.username ** (prefix + '%')), with_count=True) + get_search_results((Namespace.username ** (prefix + '%')), with_count=False) + + return results + + +def get_matching_repositories(repo_term, username=None, limit=10, include_public=True): namespace_term = repo_term name_term = repo_term - visible = get_visible_repositories(username) + visible = get_visible_repositories(username, include_public=include_public) search_clauses = (Repository.name ** ('%' + name_term + '%') | Namespace.username ** ('%' + namespace_term + '%')) @@ -894,8 +1071,7 @@ def get_matching_repositories(repo_term, username=None): search_clauses = (Repository.name ** ('%' + name_term + '%') & Namespace.username ** ('%' + namespace_term + '%')) - final = visible.where(search_clauses).limit(10) - return list(final) + return visible.where(search_clauses).limit(limit) def change_password(user, new_password): @@ -905,6 +1081,7 @@ def change_password(user, new_password): pw_hash = hash_password(new_password) user.invalid_login_attempts = 0 user.password_hash = pw_hash + user.uuid = str(uuid4()) user.save() # Remove any password required notifications for the user. @@ -1038,7 +1215,8 @@ def get_all_repo_teams(namespace_name, repository_name): def get_all_repo_users(namespace_name, repository_name): - return (RepositoryPermission.select(User.username, User.robot, Role.name, RepositoryPermission) + return (RepositoryPermission.select(User.username, User.email, User.robot, Role.name, + RepositoryPermission) .join(User) .switch(RepositoryPermission) .join(Role) @@ -1577,9 +1755,21 @@ def get_repository_images(namespace_name, repository_name): return _get_repository_images_base(namespace_name, repository_name, lambda q: q) -def _tag_alive(query): +def _tag_alive(query, now_ts=None): + if now_ts is None: + now_ts = get_epoch_timestamp() return query.where((RepositoryTag.lifetime_end_ts >> None) | - (RepositoryTag.lifetime_end_ts > int(time.time()))) + (RepositoryTag.lifetime_end_ts > now_ts)) + + +def list_repository_tag_history(repository, limit=100): + query = (RepositoryTag + .select(RepositoryTag, Image) + .join(Image) + .where(RepositoryTag.repository == repository) + .order_by(RepositoryTag.lifetime_start_ts.desc()) + .limit(limit)) + return query def list_repository_tags(namespace_name, repository_name, include_hidden=False, @@ -1610,14 +1800,19 @@ def list_repository_tags(namespace_name, repository_name, include_hidden=False, def _garbage_collect_tags(namespace_name, repository_name): # We do this without using a join to prevent holding read locks on the repository table repo = _get_repository(namespace_name, repository_name) - now = int(time.time()) + expired_time = get_epoch_timestamp() - repo.namespace_user.removed_tag_expiration_s - (RepositoryTag - .delete() - .where(RepositoryTag.repository == repo, - ~(RepositoryTag.lifetime_end_ts >> None), - (RepositoryTag.lifetime_end_ts + repo.namespace_user.removed_tag_expiration_s) <= now) - .execute()) + tags_to_delete = list(RepositoryTag + .select(RepositoryTag.id) + .where(RepositoryTag.repository == repo, + ~(RepositoryTag.lifetime_end_ts >> None), + (RepositoryTag.lifetime_end_ts <= expired_time)) + .order_by(RepositoryTag.id)) + if len(tags_to_delete) > 0: + (RepositoryTag + .delete() + .where(RepositoryTag.id << tags_to_delete) + .execute()) def garbage_collect_repository(namespace_name, repository_name): @@ -1713,46 +1908,39 @@ def _garbage_collect_storage(storage_id_whitelist): logger.debug('Garbage collecting storages from candidates: %s', storage_id_whitelist) with config.app_config['DB_TRANSACTION_FACTORY'](db): # Track all of the data that should be removed from blob storage - placements_to_remove = orphaned_storage_query(ImageStoragePlacement - .select(ImageStoragePlacement, - ImageStorage, - ImageStorageLocation) - .join(ImageStorageLocation) - .switch(ImageStoragePlacement) - .join(ImageStorage), - storage_id_whitelist, - (ImageStorage, ImageStoragePlacement, - ImageStorageLocation)) + placements_to_remove = list(orphaned_storage_query(ImageStoragePlacement + .select(ImageStoragePlacement, + ImageStorage, + ImageStorageLocation) + .join(ImageStorageLocation) + .switch(ImageStoragePlacement) + .join(ImageStorage), + storage_id_whitelist, + (ImageStorage, ImageStoragePlacement, + ImageStorageLocation))) - paths_to_remove = placements_query_to_paths_set(placements_to_remove.clone()) + paths_to_remove = placements_query_to_paths_set(placements_to_remove) # Remove the placements for orphaned storages - placements_subquery = (placements_to_remove - .clone() - .select(ImageStoragePlacement.id) - .alias('ps')) - inner = (ImageStoragePlacement - .select(placements_subquery.c.id) - .from_(placements_subquery)) - placements_removed = (ImageStoragePlacement - .delete() - .where(ImageStoragePlacement.id << inner) - .execute()) - logger.debug('Removed %s image storage placements', placements_removed) + if len(placements_to_remove) > 0: + placement_ids_to_remove = [placement.id for placement in placements_to_remove] + placements_removed = (ImageStoragePlacement + .delete() + .where(ImageStoragePlacement.id << placement_ids_to_remove) + .execute()) + logger.debug('Removed %s image storage placements', placements_removed) # Remove all orphaned storages # The comma after ImageStorage.id is VERY important, it makes it a tuple, which is a sequence - orphaned_storages = orphaned_storage_query(ImageStorage.select(ImageStorage.id), - storage_id_whitelist, - (ImageStorage.id,)).alias('osq') - orphaned_storage_inner = (ImageStorage - .select(orphaned_storages.c.id) - .from_(orphaned_storages)) - storages_removed = (ImageStorage - .delete() - .where(ImageStorage.id << orphaned_storage_inner) - .execute()) - logger.debug('Removed %s image storage records', storages_removed) + orphaned_storages = list(orphaned_storage_query(ImageStorage.select(ImageStorage.id), + storage_id_whitelist, + (ImageStorage.id,)).alias('osq')) + if len(orphaned_storages) > 0: + storages_removed = (ImageStorage + .delete() + .where(ImageStorage.id << orphaned_storages) + .execute()) + logger.debug('Removed %s image storage records', storages_removed) # We are going to make the conscious decision to not delete image storage blobs inside # transactions. @@ -1803,40 +1991,34 @@ def get_parent_images(namespace_name, repository_name, image_obj): def create_or_update_tag(namespace_name, repository_name, tag_name, tag_docker_image_id): + try: + repo = _get_repository(namespace_name, repository_name) + except Repository.DoesNotExist: + raise DataModelException('Invalid repository %s/%s' % (namespace_name, repository_name)) + + now_ts = get_epoch_timestamp() with config.app_config['DB_TRANSACTION_FACTORY'](db): try: - repo = _get_repository(namespace_name, repository_name) - except Repository.DoesNotExist: - raise DataModelException('Invalid repository %s/%s' % (namespace_name, repository_name)) + tag = db_for_update(_tag_alive(RepositoryTag + .select() + .where(RepositoryTag.repository == repo, + RepositoryTag.name == tag_name), now_ts)).get() + tag.lifetime_end_ts = now_ts + tag.save() + except RepositoryTag.DoesNotExist: + pass try: image = Image.get(Image.docker_image_id == tag_docker_image_id, Image.repository == repo) except Image.DoesNotExist: raise DataModelException('Invalid image with id: %s' % tag_docker_image_id) - now_ts = int(time.time()) - - created = RepositoryTag.create(repository=repo, image=image, name=tag_name, - lifetime_start_ts=now_ts) - - try: - # When we move a tag, we really end the timeline of the old one and create a new one - query = _tag_alive(RepositoryTag - .select() - .where(RepositoryTag.repository == repo, RepositoryTag.name == tag_name, - RepositoryTag.id != created.id)) - tag = query.get() - tag.lifetime_end_ts = now_ts - tag.save() - except RepositoryTag.DoesNotExist: - # No tag that needs to be ended - pass - - return created - + return RepositoryTag.create(repository=repo, image=image, name=tag_name, + lifetime_start_ts=now_ts) def delete_tag(namespace_name, repository_name, tag_name): + now_ts = get_epoch_timestamp() with config.app_config['DB_TRANSACTION_FACTORY'](db): try: query = _tag_alive(RepositoryTag @@ -1845,21 +2027,21 @@ def delete_tag(namespace_name, repository_name, tag_name): .join(Namespace, on=(Repository.namespace_user == Namespace.id)) .where(Repository.name == repository_name, Namespace.username == namespace_name, - RepositoryTag.name == tag_name)) + RepositoryTag.name == tag_name), now_ts) found = db_for_update(query).get() except RepositoryTag.DoesNotExist: msg = ('Invalid repository tag \'%s\' on repository \'%s/%s\'' % (tag_name, namespace_name, repository_name)) raise DataModelException(msg) - found.lifetime_end_ts = int(time.time()) + found.lifetime_end_ts = now_ts found.save() def create_temporary_hidden_tag(repo, image, expiration_s): """ Create a tag with a defined timeline, that will not appear in the UI or CLI. Returns the name of the temporary tag. """ - now_ts = int(time.time()) + now_ts = get_epoch_timestamp() expire_ts = now_ts + expiration_s tag_name = str(uuid4()) RepositoryTag.create(repository=repo, image=image, name=tag_name, lifetime_start_ts=now_ts, diff --git a/data/userevent.py b/data/userevent.py index 5523da2e5..aea34226a 100644 --- a/data/userevent.py +++ b/data/userevent.py @@ -1,6 +1,9 @@ import redis import json import threading +import logging + +logger = logging.getLogger(__name__) class UserEventBuilder(object): """ @@ -68,8 +71,9 @@ class UserEvent(object): def conduct(): try: self.publish_event_data_sync(event_id, data_obj) - except Exception as e: - print e + logger.debug('Published user event %s: %s', event_id, data_obj) + except Exception: + logger.exception('Could not publish user event') thread = threading.Thread(target=conduct) thread.start() diff --git a/data/users.py b/data/users.py index 9e01e4d45..3d763c9b6 100644 --- a/data/users.py +++ b/data/users.py @@ -1,6 +1,11 @@ import ldap import logging +import json +import itertools +import uuid +import struct +from util.aes import AESCipher from util.validation import generate_valid_usernames from data import model @@ -106,6 +111,7 @@ class LDAPUsers(object): return found_user is not None + class UserAuthentication(object): def __init__(self, app=None): self.app = app @@ -138,5 +144,81 @@ class UserAuthentication(object): app.extensions['authentication'] = users return users + def _get_secret_key(self): + """ Returns the secret key to use for encrypting and decrypting. """ + from app import app + app_secret_key = app.config['SECRET_KEY'] + secret_key = None + + # First try parsing the key as an int. + try: + big_int = int(app_secret_key) + secret_key = str(bytearray.fromhex('{:02x}'.format(big_int))) + except ValueError: + pass + + # Next try parsing it as an UUID. + if secret_key is None: + try: + secret_key = uuid.UUID(app_secret_key).bytes + except ValueError: + pass + + if secret_key is None: + secret_key = str(bytearray(map(ord, app_secret_key))) + + # Otherwise, use the bytes directly. + return ''.join(itertools.islice(itertools.cycle(secret_key), 32)) + + def encrypt_user_password(self, password): + """ Returns an encrypted version of the user's password. """ + data = { + 'password': password + } + + message = json.dumps(data) + cipher = AESCipher(self._get_secret_key()) + return cipher.encrypt(message) + + def _decrypt_user_password(self, encrypted): + """ Attempts to decrypt the given password and returns it. """ + cipher = AESCipher(self._get_secret_key()) + + try: + message = cipher.decrypt(encrypted) + except ValueError: + return None + except TypeError: + return None + + try: + data = json.loads(message) + except ValueError: + return None + + return data.get('password', encrypted) + + def verify_user(self, username_or_email, password, basic_auth=False): + # First try to decode the password as a signed token. + if basic_auth: + import features + + decrypted = self._decrypt_user_password(password) + if decrypted is None: + # This is a normal password. + if features.REQUIRE_ENCRYPTED_BASIC_AUTH: + msg = ('Client login with unecrypted passwords is disabled. Please generate an ' + + 'encrypted password in the user admin panel for use here.') + return (None, msg) + else: + password = decrypted + + result = self.state.verify_user(username_or_email, password) + if result: + return (result, '') + else: + return (result, 'Invalid password.') + + def __getattr__(self, name): return getattr(self.state, name, None) diff --git a/emails/teaminvite.html b/emails/teaminvite.html index 3d8ff9c14..128bbe00f 100644 --- a/emails/teaminvite.html +++ b/emails/teaminvite.html @@ -4,7 +4,7 @@

Invitation to join team: {{ teamname }}

-{{ inviter | user_reference }} has invited you to join the team {{ teamname }} under organization {{ organization | user_reference }}. +{{ inviter | user_reference }} has invited you to join the team {{ teamname | team_reference }} under organization {{ organization | user_reference }}.

diff --git a/endpoints/api/image.py b/endpoints/api/image.py index 71171d572..939a87d98 100644 --- a/endpoints/api/image.py +++ b/endpoints/api/image.py @@ -9,7 +9,7 @@ from data import model from util.cache import cache_control_flask_restful -def image_view(image, image_map): +def image_view(image, image_map, include_locations=True, include_ancestors=True): extended_props = image if image.storage and image.storage.id: extended_props = image.storage @@ -20,24 +20,35 @@ def image_view(image, image_map): if not aid or not aid in image_map: return '' - return image_map[aid] + return image_map[aid].docker_image_id - # Calculate the ancestors string, with the DBID's replaced with the docker IDs. - ancestors = [docker_id(a) for a in image.ancestors.split('/')] - ancestors_string = '/'.join(ancestors) - - return { + image_data = { 'id': image.docker_image_id, 'created': format_date(extended_props.created), 'comment': extended_props.comment, 'command': json.loads(command) if command else None, 'size': extended_props.image_size, - 'locations': list(image.storage.locations), 'uploading': image.storage.uploading, - 'ancestors': ancestors_string, - 'sort_index': len(image.ancestors) + 'sort_index': len(image.ancestors), } + if include_locations: + image_data['locations'] = list(image.storage.locations) + + if include_ancestors: + # Calculate the ancestors string, with the DBID's replaced with the docker IDs. + ancestors = [docker_id(a) for a in image.ancestors.split('/')] + image_data['ancestors'] = '/'.join(ancestors) + + return image_data + + +def historical_image_view(image, image_map): + ancestors = [image_map[a] for a in image.ancestors.split('/')[1:-1]] + normal_view = image_view(image, image_map) + normal_view['history'] = [image_view(parent, image_map, False, False) for parent in ancestors] + return normal_view + @resource('/v1/repository//image/') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @@ -62,7 +73,7 @@ class RepositoryImageList(RepositoryParamResource): filtered_images = [] for image in all_images: if str(image.id) in found_image_ids: - image_map[str(image.id)] = image.docker_image_id + image_map[str(image.id)] = image filtered_images.append(image) def add_tags(image_json): @@ -90,9 +101,9 @@ class RepositoryImage(RepositoryParamResource): # Lookup all the ancestor images for the image. image_map = {} for current_image in model.get_parent_images(namespace, repository, image): - image_map[str(current_image.id)] = image.docker_image_id + image_map[str(current_image.id)] = current_image - return image_view(image, image_map) + return historical_image_view(image, image_map) @resource('/v1/repository//image//changes') diff --git a/endpoints/api/organization.py b/endpoints/api/organization.py index 4302bd62f..3cb98fb84 100644 --- a/endpoints/api/organization.py +++ b/endpoints/api/organization.py @@ -24,16 +24,22 @@ logger = logging.getLogger(__name__) def org_view(o, teams): - admin_org = AdministerOrganizationPermission(o.username) - is_admin = admin_org.can() + is_admin = AdministerOrganizationPermission(o.username).can() + is_member = OrganizationMemberPermission(o.username).can() + view = { 'name': o.username, 'email': o.email if is_admin else '', - 'avatar': avatar.compute_hash(o.email, name=o.username), - 'teams': {t.name : team_view(o.username, t) for t in teams}, - 'is_admin': is_admin + 'avatar': avatar.get_data_for_user(o), + 'is_admin': is_admin, + 'is_member': is_member } + if teams is not None: + teams = sorted(teams, key=lambda team:team.id) + view['teams'] = {t.name : team_view(o.username, t) for t in teams} + view['ordered_teams'] = [team.name for team in teams] + if is_admin: view['invoice_email'] = o.invoice_email @@ -129,17 +135,17 @@ class Organization(ApiResource): @nickname('getOrganization') def get(self, orgname): """ Get the details for the specified organization """ - permission = OrganizationMemberPermission(orgname) - if permission.can(): - try: - org = model.get_organization(orgname) - except model.InvalidOrganizationException: - raise NotFound() + try: + org = model.get_organization(orgname) + except model.InvalidOrganizationException: + raise NotFound() + teams = None + if OrganizationMemberPermission(orgname).can(): teams = model.get_teams_within_org(org) - return org_view(org, teams) - raise Unauthorized() + return org_view(org, teams) + @require_scope(scopes.ORG_ADMIN) @nickname('changeOrganizationDetails') @@ -218,7 +224,7 @@ class OrgPrivateRepositories(ApiResource): @path_param('orgname', 'The name of the organization') class OrgnaizationMemberList(ApiResource): """ Resource for listing the members of an organization. """ - + @require_scope(scopes.ORG_ADMIN) @nickname('getOrganizationMembers') def get(self, orgname): @@ -297,16 +303,14 @@ class ApplicationInformation(ApiResource): if not application: raise NotFound() - org_hash = avatar.compute_hash(application.organization.email, - name=application.organization.username) - app_hash = (avatar.compute_hash(application.avatar_email, name=application.name) if - application.avatar_email else org_hash) + app_email = application.avatar_email or application.organization.email + app_data = avatar.get_data(application.name, app_email, 'app') return { 'name': application.name, 'description': application.description, 'uri': application.application_uri, - 'avatar': app_hash, + 'avatar': app_data, 'organization': org_view(application.organization, []) } diff --git a/endpoints/api/permission.py b/endpoints/api/permission.py index c8a473d9c..4c0b62074 100644 --- a/endpoints/api/permission.py +++ b/endpoints/api/permission.py @@ -2,6 +2,7 @@ import logging from flask import request +from app import avatar from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource, log_action, request_error, validate_json_request, path_param) from data import model @@ -16,7 +17,10 @@ def role_view(repo_perm_obj): } def wrap_role_view_user(role_json, user): + role_json['name'] = user.username role_json['is_robot'] = user.robot + if not user.robot: + role_json['avatar'] = avatar.get_data_for_user(user) return role_json @@ -25,6 +29,12 @@ def wrap_role_view_org(role_json, user, org_members): return role_json +def wrap_role_view_team(role_json, team): + role_json['name'] = team.name + role_json['avatar'] = avatar.get_data_for_team(team) + return role_json + + @resource('/v1/repository//permissions/team/') @path_param('repository', 'The full path of the repository. e.g. namespace/name') class RepositoryTeamPermissionList(RepositoryParamResource): @@ -35,8 +45,11 @@ class RepositoryTeamPermissionList(RepositoryParamResource): """ List all team permission. """ repo_perms = model.get_all_repo_teams(namespace, repository) + def wrapped_role_view(repo_perm): + return wrap_role_view_team(role_view(repo_perm), repo_perm.team) + return { - 'permissions': {repo_perm.team.name: role_view(repo_perm) + 'permissions': {repo_perm.team.name: wrapped_role_view(repo_perm) for repo_perm in repo_perms} } @@ -232,7 +245,7 @@ class RepositoryTeamPermission(RepositoryParamResource): 'role': new_permission['role']}, repo=model.get_repository(namespace, repository)) - return role_view(perm), 200 + return wrap_role_view_team(role_view(perm), perm.team), 200 @require_repo_admin @nickname('deleteTeamPermissions') diff --git a/endpoints/api/prototype.py b/endpoints/api/prototype.py index 343913c3a..de0c97483 100644 --- a/endpoints/api/prototype.py +++ b/endpoints/api/prototype.py @@ -7,6 +7,7 @@ from auth.permissions import AdministerOrganizationPermission from auth.auth_context import get_authenticated_user from auth import scopes from data import model +from app import avatar def prototype_view(proto, org_members): @@ -16,6 +17,7 @@ def prototype_view(proto, org_members): 'is_robot': user.robot, 'kind': 'user', 'is_org_member': user.robot or user.username in org_members, + 'avatar': avatar.get_data_for_user(user) } if proto.delegate_user: @@ -24,6 +26,7 @@ def prototype_view(proto, org_members): delegate_view = { 'name': proto.delegate_team.name, 'kind': 'team', + 'avatar': avatar.get_data_for_team(proto.delegate_team) } return { diff --git a/endpoints/api/repository.py b/endpoints/api/repository.py index 9d610e686..aa7453554 100644 --- a/endpoints/api/repository.py +++ b/endpoints/api/repository.py @@ -109,6 +109,8 @@ class RepositoryList(ApiResource): @query_param('sort', 'Whether to sort the results.', type=truthy_bool, default=False) @query_param('count', 'Whether to include a count of the total number of results available.', type=truthy_bool, default=False) + @query_param('namespace_only', 'Whether to limit only to the given namespace.', + type=truthy_bool, default=False) def get(self, args): """Fetch the list of repositories under a variety of situations.""" username = None @@ -129,7 +131,8 @@ class RepositoryList(ApiResource): repo_query = model.get_visible_repositories(username, limit=args['limit'], page=args['page'], include_public=args['public'], sort=args['sort'], - namespace=args['namespace']) + namespace=args['namespace'], + namespace_only=args['namespace_only']) def repo_view(repo_obj): repo = { 'namespace': repo_obj.namespace_user.username, diff --git a/endpoints/api/robot.py b/endpoints/api/robot.py index b7614a356..ecb26e573 100644 --- a/endpoints/api/robot.py +++ b/endpoints/api/robot.py @@ -5,16 +5,63 @@ from auth.permissions import AdministerOrganizationPermission, OrganizationMembe from auth.auth_context import get_authenticated_user from auth import scopes from data import model +from data.database import User, Team, Repository, FederatedLogin from util.names import format_robot_username - +from flask import abort +from app import avatar def robot_view(name, token): return { 'name': name, - 'token': token, + 'token': token } +def permission_view(permission): + return { + 'repository': { + 'name': permission.repository.name, + 'is_public': permission.repository.visibility.name == 'public' + }, + 'role': permission.role.name + } + + +def robots_list(prefix): + tuples = model.list_entity_robot_permission_teams(prefix) + + robots = {} + robot_teams = set() + + for robot_tuple in tuples: + robot_name = robot_tuple.get(User.username) + if not robot_name in robots: + robots[robot_name] = { + 'name': robot_name, + 'token': robot_tuple.get(FederatedLogin.service_ident), + 'teams': [], + 'repositories': [] + } + + team_name = robot_tuple.get(Team.name) + repository_name = robot_tuple.get(Repository.name) + + if team_name is not None: + check_key = robot_name + ':' + team_name + if not check_key in robot_teams: + robot_teams.add(check_key) + + robots[robot_name]['teams'].append({ + 'name': team_name, + 'avatar': avatar.get_data(team_name, team_name, 'team') + }) + + if repository_name is not None: + if not repository_name in robots[robot_name]['repositories']: + robots[robot_name]['repositories'].append(repository_name) + + return {'robots': robots.values()} + @resource('/v1/user/robots') @internal_only class UserRobotList(ApiResource): @@ -24,10 +71,7 @@ class UserRobotList(ApiResource): def get(self): """ List the available robots for the user. """ user = get_authenticated_user() - robots = model.list_entity_robot_tuples(user.username) - return { - 'robots': [robot_view(name, password) for name, password in robots] - } + return robots_list(user.username) @resource('/v1/user/robots/') @@ -73,10 +117,7 @@ class OrgRobotList(ApiResource): """ List the organization's robots. """ permission = OrganizationMemberPermission(orgname) if permission.can(): - robots = model.list_entity_robot_tuples(orgname) - return { - 'robots': [robot_view(name, password) for name, password in robots] - } + return robots_list(orgname) raise Unauthorized() @@ -125,6 +166,47 @@ class OrgRobot(ApiResource): raise Unauthorized() +@resource('/v1/user/robots//permissions') +@path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix') +@internal_only +class UserRobotPermissions(ApiResource): + """ Resource for listing the permissions a user's robot has in the system. """ + @require_user_admin + @nickname('getUserRobotPermissions') + def get(self, robot_shortname): + """ Returns the list of repository permissions for the user's robot. """ + parent = get_authenticated_user() + robot, password = model.get_robot(robot_shortname, parent) + permissions = model.list_robot_permissions(robot.username) + + return { + 'permissions': [permission_view(permission) for permission in permissions] + } + + +@resource('/v1/organization//robots//permissions') +@path_param('orgname', 'The name of the organization') +@path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix') +@related_user_resource(UserRobotPermissions) +class OrgRobotPermissions(ApiResource): + """ Resource for listing the permissions an org's robot has in the system. """ + @require_user_admin + @nickname('getOrgRobotPermissions') + def get(self, orgname, robot_shortname): + """ Returns the list of repository permissions for the org's robot. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + parent = model.get_organization(orgname) + robot, password = model.get_robot(robot_shortname, parent) + permissions = model.list_robot_permissions(robot.username) + + return { + 'permissions': [permission_view(permission) for permission in permissions] + } + + abort(403) + + @resource('/v1/user/robots//regenerate') @path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix') @internal_only diff --git a/endpoints/api/search.py b/endpoints/api/search.py index 0e3561745..20a34b495 100644 --- a/endpoints/api/search.py +++ b/endpoints/api/search.py @@ -3,11 +3,15 @@ from endpoints.api import (ApiResource, parse_args, query_param, truthy_bool, ni from data import model from auth.permissions import (OrganizationMemberPermission, ViewTeamPermission, ReadRepositoryPermission, UserAdminPermission, - AdministerOrganizationPermission) + AdministerOrganizationPermission, ReadRepositoryPermission) from auth.auth_context import get_authenticated_user from auth import scopes -from app import avatar +from app import avatar, get_app_url +from operator import itemgetter +from stringscore import liquidmetal +from util.names import parse_robot_username +import math @resource('/v1/entities/') class EntitySearch(ApiResource): @@ -45,7 +49,7 @@ class EntitySearch(ApiResource): 'name': namespace_name, 'kind': 'org', 'is_org_member': True, - 'avatar': avatar.compute_hash(organization.email, name=organization.username), + 'avatar': avatar.get_data_for_org(organization), }] except model.InvalidOrganizationException: @@ -63,7 +67,8 @@ class EntitySearch(ApiResource): result = { 'name': team.name, 'kind': 'team', - 'is_org_member': True + 'is_org_member': True, + 'avatar': avatar.get_data_for_team(team) } return result @@ -71,11 +76,12 @@ class EntitySearch(ApiResource): user_json = { 'name': user.username, 'kind': 'user', - 'is_robot': user.is_robot, + 'is_robot': user.robot, + 'avatar': avatar.get_data_for_user(user) } if organization is not None: - user_json['is_org_member'] = user.is_robot or user.is_org_member + user_json['is_org_member'] = user.robot or user.is_org_member return user_json @@ -128,3 +134,154 @@ class FindRepositories(ApiResource): if (repo.visibility.name == 'public' or ReadRepositoryPermission(repo.namespace_user.username, repo.name).can())] } + + + +def search_entity_view(username, entity): + kind = 'user' + avatar_data = avatar.get_data_for_user(entity) + href = '/user/' + entity.username + + if entity.organization: + kind = 'organization' + avatar_data = avatar.get_data_for_org(entity) + href = '/organization/' + entity.username + elif entity.robot: + parts = parse_robot_username(entity.username) + if parts[0] == username: + href = '/user/' + username + '?tab=robots&showRobot=' + entity.username + else: + href = '/organization/' + parts[0] + '?tab=robots&showRobot=' + entity.username + + kind = 'robot' + avatar_data = None + + return { + 'kind': kind, + 'avatar': avatar_data, + 'name': entity.username, + 'score': 1, + 'href': href + } + + +def conduct_team_search(username, query, encountered_teams, results): + """ Finds the matching teams where the user is a member. """ + matching_teams = model.get_matching_user_teams(query, get_authenticated_user(), limit=5) + for team in matching_teams: + if team.id in encountered_teams: + continue + + encountered_teams.add(team.id) + + results.append({ + 'kind': 'team', + 'name': team.name, + 'organization': search_entity_view(username, team.organization), + 'avatar': avatar.get_data_for_team(team), + 'score': 2, + 'href': '/organization/' + team.organization.username + '/teams/' + team.name + }) + + +def conduct_admined_team_search(username, query, encountered_teams, results): + """ Finds matching teams in orgs admined by the user. """ + matching_teams = model.get_matching_admined_teams(query, get_authenticated_user(), limit=5) + for team in matching_teams: + if team.id in encountered_teams: + continue + + encountered_teams.add(team.id) + + results.append({ + 'kind': 'team', + 'name': team.name, + 'organization': search_entity_view(username, team.organization), + 'avatar': avatar.get_data_for_team(team), + 'score': 2, + 'href': '/organization/' + team.organization.username + '/teams/' + team.name + }) + + +def conduct_repo_search(username, query, results): + """ Finds matching repositories. """ + def can_read(repository): + if repository.is_public: + return True + + return ReadRepositoryPermission(repository.namespace_user.username, repository.name).can() + + only_public = username is None + matching_repos = model.get_sorted_matching_repositories(query, only_public, can_read, limit=5) + + for repo in matching_repos: + repo_score = math.log(repo.count or 1, 10) or 1 + + # If the repository is under the user's namespace, give it 20% more weight. + namespace = repo.namespace_user.username + if OrganizationMemberPermission(namespace).can() or namespace == username: + repo_score = repo_score * 1.2 + + results.append({ + 'kind': 'repository', + 'namespace': search_entity_view(username, repo.namespace_user), + 'name': repo.name, + 'description': repo.description, + 'is_public': repo.is_public, + 'score': repo_score, + 'href': '/repository/' + repo.namespace_user.username + '/' + repo.name + }) + + +def conduct_namespace_search(username, query, results): + """ Finds matching users and organizations. """ + matching_entities = model.get_matching_user_namespaces(query, username, limit=5) + for entity in matching_entities: + results.append(search_entity_view(username, entity)) + + +def conduct_robot_search(username, query, results): + """ Finds matching robot accounts. """ + matching_robots = model.get_matching_robots(query, username, limit=5) + for robot in matching_robots: + results.append(search_entity_view(username, robot)) + + +@resource('/v1/find/all') +class ConductSearch(ApiResource): + """ Resource for finding users, repositories, teams, etc. """ + @parse_args + @query_param('query', 'The search query.', type=str, default='') + @require_scope(scopes.READ_REPO) + @nickname('conductSearch') + def get(self, args): + """ Get a list of entities and resources that match the specified query. """ + query = args['query'] + if not query: + return {'results': []} + + username = None + results = [] + + if get_authenticated_user(): + username = get_authenticated_user().username + + # Search for teams. + encountered_teams = set() + conduct_team_search(username, query, encountered_teams, results) + conduct_admined_team_search(username, query, encountered_teams, results) + + # Search for robot accounts. + conduct_robot_search(username, query, results) + + # Search for repos. + conduct_repo_search(username, query, results) + + # Search for users and orgs. + conduct_namespace_search(username, query, results) + + # Modify the results' scores via how close the query term is to each result's name. + for result in results: + result['score'] = result['score'] * liquidmetal.score(result['name'], query) + + return {'results': sorted(results, key=itemgetter('score'), reverse=True)} diff --git a/endpoints/api/suconfig.py b/endpoints/api/suconfig.py index 10741d9f3..b1152231c 100644 --- a/endpoints/api/suconfig.py +++ b/endpoints/api/suconfig.py @@ -15,6 +15,7 @@ from auth.permissions import SuperUserPermission from auth.auth_context import get_authenticated_user from data.database import User from util.config.configutil import add_enterprise_config_defaults +from util.config.provider import CannotWriteConfigException from util.config.validator import validate_service_for_config, SSL_FILENAMES from data.runmigration import run_alembic_migration diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py index 2c7daf633..01dbc5cb2 100644 --- a/endpoints/api/superuser.py +++ b/endpoints/api/superuser.py @@ -108,7 +108,7 @@ def user_view(user): 'username': user.username, 'email': user.email, 'verified': user.verified, - 'avatar': avatar.compute_hash(user.email, name=user.username), + 'avatar': avatar.get_data_for_user(user), 'super_user': superusers.is_superuser(user.username) } diff --git a/endpoints/api/tag.py b/endpoints/api/tag.py index 21972fc19..f698be851 100644 --- a/endpoints/api/tag.py +++ b/endpoints/api/tag.py @@ -1,12 +1,45 @@ -from flask import request +from flask import request, abort from endpoints.api import (resource, nickname, require_repo_read, require_repo_write, RepositoryParamResource, log_action, NotFound, validate_json_request, - path_param) + path_param, format_date) from endpoints.api.image import image_view from data import model from auth.auth_context import get_authenticated_user +from datetime import datetime + + + +@resource('/v1/repository//tag/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +class ListRepositoryTags(RepositoryParamResource): + """ Resource for listing repository tags. """ + + @require_repo_write + @nickname('listRepoTags') + def get(self, namespace, repository): + repo = model.get_repository(namespace, repository) + if not repo: + abort(404) + + def tag_view(tag): + tag_info = { + 'name': tag.name, + 'docker_image_id': tag.image.docker_image_id, + } + + if tag.lifetime_start_ts > 0: + tag_info['start_ts'] = tag.lifetime_start_ts + + if tag.lifetime_end_ts > 0: + tag_info['end_ts'] = tag.lifetime_end_ts + + return tag_info + + tags = model.list_repository_tag_history(repo, limit=100) + return {'tags': [tag_view(tag) for tag in tags]} + @resource('/v1/repository//tag/') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @@ -92,7 +125,7 @@ class RepositoryTagImages(RepositoryParamResource): parent_images = model.get_parent_images(namespace, repository, tag_image) image_map = {} for image in parent_images: - image_map[str(image.id)] = image.docker_image_id + image_map[str(image.id)] = image parents = list(parent_images) parents.reverse() diff --git a/endpoints/api/team.py b/endpoints/api/team.py index 91f225fa1..ce42f5e94 100644 --- a/endpoints/api/team.py +++ b/endpoints/api/team.py @@ -52,11 +52,11 @@ def team_view(orgname, team): view_permission = ViewTeamPermission(orgname, team.name) role = model.get_team_org_role(team).name return { - 'id': team.id, 'name': team.name, 'description': team.description, 'can_view': view_permission.can(), - 'role': role + 'role': role, + 'avatar': avatar.get_data_for_team(team) } def member_view(member, invited=False): @@ -64,7 +64,7 @@ def member_view(member, invited=False): 'name': member.username, 'kind': 'user', 'is_robot': member.robot, - 'avatar': avatar.compute_hash(member.email, name=member.username) if not member.robot else None, + 'avatar': avatar.get_data_for_user(member), 'invited': invited, } @@ -76,7 +76,7 @@ def invite_view(invite): return { 'email': invite.email, 'kind': 'invite', - 'avatar': avatar.compute_hash(invite.email), + 'avatar': avatar.get_data(invite.email, invite.email, 'user'), 'invited': True } diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 6c3cafd63..93e77f47c 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -1,7 +1,8 @@ import logging import json -from flask import request +from random import SystemRandom +from flask import request, abort from flask.ext.login import logout_user from flask.ext.principal import identity_changed, AnonymousIdentity from peewee import IntegrityError @@ -35,7 +36,7 @@ def user_view(user): admin_org = AdministerOrganizationPermission(o.username) return { 'name': o.username, - 'avatar': avatar.compute_hash(o.email, name=o.username), + 'avatar': avatar.get_data_for_org(o), 'is_org_admin': admin_org.can(), 'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can(), 'preferred_namespace': not (o.stripe_id is None) @@ -58,15 +59,16 @@ def user_view(user): logins = model.list_federated_logins(user) user_response = { - 'verified': user.verified, 'anonymous': False, 'username': user.username, - 'avatar': avatar.compute_hash(user.email, name=user.username), + 'avatar': avatar.get_data_for_user(user) } user_admin = UserAdminPermission(user.username) if user_admin.can(): user_response.update({ + 'is_me': True, + 'verified': user.verified, 'email': user.email, 'organizations': [org_view(o) for o in organizations], 'logins': [login_view(login) for login in logins], @@ -76,7 +78,7 @@ def user_view(user): 'tag_expiration': user.removed_tag_expiration_s, }) - if features.SUPER_USERS: + if features.SUPER_USERS and SuperUserPermission().can(): user_response.update({ 'super_user': user and user == get_authenticated_user() and SuperUserPermission().can() }) @@ -175,8 +177,8 @@ class User(ApiResource): 'description': 'The user\'s email address', }, 'avatar': { - 'type': 'string', - 'description': 'Avatar hash representing the user\'s icon' + 'type': 'object', + 'description': 'Avatar data representing the user\'s icon' }, 'organizations': { 'type': 'array', @@ -224,8 +226,13 @@ class User(ApiResource): if 'password' in user_data: logger.debug('Changing password for user: %s', user.username) log_action('account_change_password', user.username) + + # Change the user's password. model.change_password(user, user_data['password']) + # Login again to reset their session cookie. + common_login(user) + if features.MAILING: send_password_changed(user.username, user.email) @@ -335,13 +342,51 @@ class PrivateRepositories(ApiResource): } +@resource('/v1/user/clientkey') +@internal_only +class ClientKey(ApiResource): + """ Operations for returning an encrypted key which can be used in place of a password + for the Docker client. """ + schemas = { + 'GenerateClientKey': { + 'id': 'GenerateClientKey', + 'type': 'object', + 'required': [ + 'password', + ], + 'properties': { + 'password': { + 'type': 'string', + 'description': 'The user\'s password', + }, + } + } + } + + @require_user_admin + @nickname('generateUserClientKey') + @validate_json_request('GenerateClientKey') + def post(self): + """ Return's the user's private client key. """ + username = get_authenticated_user().username + password = request.get_json()['password'] + + (result, error_message) = authentication.verify_user(username, password) + if not result: + raise request_error(message=error_message) + + return { + 'key': authentication.encrypt_user_password(password) + } + + def conduct_signin(username_or_email, password): needs_email_verification = False invalid_credentials = False verified = None try: - verified = authentication.verify_user(username_or_email, password) + (verified, error_message) = authentication.verify_user(username_or_email, password) except model.TooManyUsersException as ex: raise license_error(exception=ex) @@ -407,7 +452,7 @@ class ConvertToOrganization(ApiResource): # Ensure that the sign in credentials work. admin_password = convert_data['adminPassword'] - admin_user = authentication.verify_user(admin_username, admin_password) + (admin_user, error_message) = authentication.verify_user(admin_username, admin_password) if not admin_user: raise request_error(reason='invaliduser', message='The admin user credentials are not valid') @@ -621,17 +666,16 @@ class UserNotification(ApiResource): def authorization_view(access_token): oauth_app = access_token.application + app_email = oauth_app.avatar_email or oauth_app.organization.email return { 'application': { 'name': oauth_app.name, 'description': oauth_app.description, 'url': oauth_app.application_uri, - 'avatar': avatar.compute_hash(oauth_app.avatar_email or oauth_app.organization.email, - name=oauth_app.name), + 'avatar': avatar.get_data(oauth_app.name, app_email, 'app'), 'organization': { 'name': oauth_app.organization.username, - 'avatar': avatar.compute_hash(oauth_app.organization.email, - name=oauth_app.organization.username) + 'avatar': avatar.get_data_for_org(oauth_app.organization) } }, 'scopes': scopes.get_scope_information(access_token.scope), @@ -745,6 +789,7 @@ class StarredRepositoryList(ApiResource): 'repository': repository, }, 201 + @resource('/v1/user/starred/') @path_param('repository', 'The full path of the repository. e.g. namespace/name') class StarredRepository(RepositoryParamResource): @@ -759,3 +804,17 @@ class StarredRepository(RepositoryParamResource): if repo: model.unstar_repository(user, repo) return 'Deleted', 204 + + +@resource('/v1/users/') +class Users(ApiResource): + """ Operations related to retrieving information about other users. """ + @nickname('getUserInformation') + def get(self, username): + """ Get user information for the specified user. """ + user = model.get_user(username) + if user is None or user.organization or user.robot: + abort(404) + + return user_view(user) + diff --git a/endpoints/callbacks.py b/endpoints/callbacks.py index cbe96f6e7..4b8215435 100644 --- a/endpoints/callbacks.py +++ b/endpoints/callbacks.py @@ -157,7 +157,10 @@ def github_oauth_callback(): if error: return render_ologin_error('GitHub', error) + # Exchange the OAuth code. token = exchange_code_for_token(request.args.get('code'), github_login) + + # Retrieve the user's information. user_data = get_user(github_login, token) if not user_data or not 'login' in user_data: return render_ologin_error('GitHub') @@ -172,16 +175,35 @@ def github_oauth_callback(): token_param = { 'access_token': token, } + + # Retrieve the user's orgnizations (if organization filtering is turned on) + if github_login.allowed_organizations() is not None: + get_orgs = client.get(github_login.orgs_endpoint(), params=token_param, + headers={'Accept': 'application/vnd.github.moondragon+json'}) + + organizations = set([org.get('login').lower() for org in get_orgs.json()]) + if not (organizations & set(github_login.allowed_organizations())): + err = """You are not a member of an allowed GitHub organization. + Please contact your system administrator if you believe this is in error.""" + return render_ologin_error('GitHub', err) + + # Find the e-mail address for the user: we will accept any email, but we prefer the primary get_email = client.get(github_login.email_endpoint(), params=token_param, headers=v3_media_type) - # We will accept any email, but we prefer the primary found_email = None for user_email in get_email.json(): + if not github_login.is_enterprise() and not user_email['verified']: + continue + found_email = user_email['email'] if user_email['primary']: break + if found_email is None: + err = 'There is no verified e-mail address attached to the GitHub account.' + return render_ologin_error('GitHub', err) + metadata = { 'service_username': username } diff --git a/endpoints/decorated.py b/endpoints/decorated.py index 182f41a45..b51e1ee2e 100644 --- a/endpoints/decorated.py +++ b/endpoints/decorated.py @@ -4,6 +4,7 @@ import json from flask import make_response from app import app from util.useremails import CannotSendEmailException +from util.config.provider import CannotWriteConfigException from data import model logger = logging.getLogger(__name__) @@ -17,3 +18,11 @@ def handle_dme(ex): def handle_emailexception(ex): message = 'Could not send email. Please contact an administrator and report this problem.' return make_response(json.dumps({'message': message}), 400) + +@app.errorhandler(CannotWriteConfigException) +def handle_configexception(ex): + message = ('Configuration could not be written to the mounted volume. \n' + + 'Please make sure the mounted volume is not read-only and restart ' + + 'the setup process. \n\nIssue: %s' % ex) + + return make_response(json.dumps({'message': message}), 400) \ No newline at end of file diff --git a/endpoints/index.py b/endpoints/index.py index 2df427601..79e7c9041 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -109,19 +109,17 @@ def create_user(): issue='robot-login-failure') if authentication.user_exists(username): - verified = authentication.verify_user(username, password) + (verified, error_message) = authentication.verify_user(username, password, basic_auth=True) if verified: # Mark that the user was logged in. event = userevents.get_event(username) event.publish_event_data('docker-cli', {'action': 'login'}) - return success else: # Mark that the login failed. event = userevents.get_event(username) event.publish_event_data('docker-cli', {'action': 'loginfailure'}) - - abort(400, 'Invalid password.', issue='login-failure') + abort(400, error_message, issue='login-failure') elif not features.USER_CREATION: abort(400, 'User creation is disabled. Please speak to your administrator.') @@ -231,6 +229,16 @@ def create_repository(namespace, repository): repo = model.create_repository(namespace, repository, get_authenticated_user()) + if get_authenticated_user(): + user_event_data = { + 'action': 'push_start', + 'repository': repository, + 'namespace': namespace + } + + event = userevents.get_event(get_authenticated_user().username) + event.publish_event_data('docker-cli', user_event_data) + return make_response('Created', 201) @@ -248,20 +256,6 @@ def update_images(namespace, repository): # Make sure the repo actually exists. abort(404, message='Unknown repository', issue='unknown-repo') - if get_authenticated_user(): - logger.debug('Publishing push event') - username = get_authenticated_user().username - - # Mark that the user has pushed the repo. - user_data = { - 'action': 'pushed_repo', - 'repository': repository, - 'namespace': namespace - } - - event = userevents.get_event(username) - event.publish_event_data('docker-cli', user_data) - logger.debug('GCing repository') model.garbage_collect_repository(namespace, repository) @@ -272,6 +266,7 @@ def update_images(namespace, repository): event_data = { 'updated_tags': updated_tags, } + track_and_log('push_repo', repo) spawn_notification(repo, 'repo_push', event_data) return make_response('Updated', 204) diff --git a/endpoints/web.py b/endpoints/web.py index bbb282be3..214c8c1f3 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -3,7 +3,6 @@ import logging from flask import (abort, redirect, request, url_for, make_response, Response, Blueprint, send_from_directory, jsonify, send_file) -from avatar_generator import Avatar from flask.ext.login import current_user from urlparse import urlparse from health.healthcheck import get_healthchecker @@ -39,7 +38,6 @@ STATUS_TAGS = app.config['STATUS_TAGS'] @web.route('/', methods=['GET'], defaults={'path': ''}) -@web.route('/organization/', methods=['GET']) @no_cache def index(path, **kwargs): return render_page_template('index.html', **kwargs) @@ -50,6 +48,18 @@ def internal_error_display(): return render_page_template('500.html') +@web.route('/organization/', methods=['GET']) +@no_cache +def org_view(path): + return index('') + + +@web.route('/user/', methods=['GET']) +@no_cache +def user_view(path): + return index('') + + @web.route('/snapshot', methods=['GET']) @web.route('/snapshot/', methods=['GET']) @web.route('/snapshot/', methods=['GET']) @@ -210,20 +220,6 @@ def endtoend_health(): return response -@app.route("/avatar/") -@set_cache_headers -def render_avatar(avatar_hash, headers): - try: - size = int(request.args.get('size', 16)) - except ValueError: - size = 16 - - generated = Avatar.generate(size, avatar_hash, "PNG") - resp = make_response(generated, 200, {'Content-Type': 'image/png'}) - resp.headers.extend(headers) - return resp - - @web.route('/tos', methods=['GET']) @no_cache def tos(): @@ -248,21 +244,6 @@ def robots(): return send_from_directory('static', 'robots.txt') -@web.route('/') -@no_cache -@process_oauth -@parse_repository_name_and_tag -def redirect_to_repository(namespace, reponame, tag): - permission = ReadRepositoryPermission(namespace, reponame) - is_public = model.repository_is_public(namespace, reponame) - - if permission.can() or is_public: - repository_name = '/'.join([namespace, reponame]) - return redirect(url_for('web.repository', path=repository_name, tag=tag)) - - abort(404) - - @web.route('/receipt', methods=['GET']) @route_show_if(features.BILLING) @require_session_login @@ -449,15 +430,16 @@ def request_authorization_code(): # Load the application information. oauth_app = provider.get_application_for_client_id(client_id) + app_email = oauth_app.email or organization.email + oauth_app_view = { 'name': oauth_app.name, 'description': oauth_app.description, 'url': oauth_app.application_uri, - 'avatar': avatar.compute_hash(oauth_app.avatar_email, name=oauth_app.name), + 'avatar': avatar.get_data(oauth_app.name, app_email, 'app'), 'organization': { 'name': oauth_app.organization.username, - 'avatar': avatar.compute_hash(oauth_app.organization.email, - name=oauth_app.organization.username) + 'avatar': avatar.get_data_for_org(oauth_app.organization) } } @@ -533,3 +515,31 @@ def attach_custom_build_trigger(namespace, repository_name): return redirect(full_url) abort(403) + +@web.route('/') +@no_cache +@process_oauth +@parse_repository_name_and_tag +def redirect_to_repository(namespace, reponame, tag): + permission = ReadRepositoryPermission(namespace, reponame) + is_public = model.repository_is_public(namespace, reponame) + + if permission.can() or is_public: + repository_name = '/'.join([namespace, reponame]) + return redirect(url_for('web.repository', path=repository_name, tag=tag)) + + abort(404) + + +@web.route('/') +@no_cache +@process_oauth +def redirect_to_namespace(namespace): + user_or_org = model.get_user_or_org(namespace) + if not user_or_org: + abort(404) + + if user_or_org.organization: + return redirect(url_for('web.org_view', path=namespace)) + else: + return redirect(url_for('web.user_view', path=namespace)) diff --git a/external_libraries.py b/external_libraries.py index 3ab6bfd4a..7004a2cca 100644 --- a/external_libraries.py +++ b/external_libraries.py @@ -21,12 +21,17 @@ EXTERNAL_CSS = [ 'netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.css', 'netdna.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css', 'fonts.googleapis.com/css?family=Source+Sans+Pro:400,700', + 's3.amazonaws.com/cdn.core-os.net/icons/core-icons.css' ] EXTERNAL_FONTS = [ 'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.woff?v=4.2.0', 'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.ttf?v=4.2.0', 'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.svg?v=4.2.0', + + 'cdn.core-os.net/icons/core-icons.woff', + 'cdn.core-os.net/icons/core-icons.ttf', + 'cdn.core-os.net/icons/core-icons.svg', ] diff --git a/initdb.py b/initdb.py index a253f0ab6..026c6b5f3 100644 --- a/initdb.py +++ b/initdb.py @@ -57,7 +57,7 @@ def __gen_image_uuid(repo, image_num): global_image_num = [0] -def __create_subtree(repo, structure, creator_username, parent): +def __create_subtree(repo, structure, creator_username, parent, tag_map): num_nodes, subtrees, last_node_tags = structure # create the nodes @@ -102,12 +102,18 @@ def __create_subtree(repo, structure, creator_username, parent): tag = model.create_or_update_tag(repo.namespace_user.username, repo.name, tag_name, new_image.docker_image_id) + tag_map[tag_name] = tag + + for tag_name in last_node_tags: if tag_name[0] == '#': - tag.lifetime_end_ts = int(time.time()) - 1 + tag = tag_map[tag_name] + tag.name = tag_name[1:] + tag.lifetime_end_ts = tag_map[tag_name[1:]].lifetime_start_ts + tag.lifetime_start_ts = tag.lifetime_end_ts - 10 tag.save() for subtree in subtrees: - __create_subtree(repo, subtree, creator_username, new_image) + __create_subtree(repo, subtree, creator_username, new_image, tag_map) def __generate_repository(user, name, description, is_public, permissions, @@ -127,9 +133,9 @@ def __generate_repository(user, name, description, is_public, permissions, if isinstance(structure, list): for s in structure: - __create_subtree(repo, s, user.username, None) + __create_subtree(repo, s, user.username, None, {}) else: - __create_subtree(repo, structure, user.username, None) + __create_subtree(repo, structure, user.username, None, {}) return repo diff --git a/registry.py b/registry.py index 2669ec1c1..4205fc635 100644 --- a/registry.py +++ b/registry.py @@ -3,6 +3,9 @@ import logging.config from app import app as application +# Note: We need to import this module to make sure the decorators are registered. +import endpoints.decorated + from endpoints.index import index from endpoints.tags import tags from endpoints.registry import registry diff --git a/requirements-nover.txt b/requirements-nover.txt index 5eb570581..055d38875 100644 --- a/requirements-nover.txt +++ b/requirements-nover.txt @@ -48,3 +48,4 @@ pygpgme cachetools mock psutil +stringscore \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5b5f061a2..0767cc1af 100644 --- a/requirements.txt +++ b/requirements.txt @@ -54,6 +54,7 @@ redis==2.10.3 reportlab==2.7 requests==2.5.1 six==1.9.0 +stringscore==0.1.0 stripe==1.20.1 trollius==1.0.4 tzlocal==1.1.2 diff --git a/static/css/core-ui.css b/static/css/core-ui.css index 25a547a81..214e79114 100644 --- a/static/css/core-ui.css +++ b/static/css/core-ui.css @@ -278,6 +278,15 @@ display: block; } +.config-list-field-element input { + vertical-align: middle; +} + +.config-list-field-element .item-delete { + display: inline-block; + margin-left: 20px; +} + .config-list-field-element input { width: 350px; } @@ -764,10 +773,17 @@ padding: 10px; } +.co-table.no-lines td { + border-bottom: 0px; + padding: 6px; +} + .co-table thead td { + color: #999; + font-size: 90%; text-transform: uppercase; - font-size: 16px; - color: #666; + font-weight: 300; + padding-top: 0px !important; } .co-table thead td a { @@ -804,11 +820,45 @@ width: 30px; } +.co-table td.caret-col { + width: 10px; + padding-left: 6px; + padding-right: 0px; + color: #aaa; +} + +.co-table td.caret-col i.fa { + cursor: pointer; +} + +.co-table .add-row-spacer td { + padding: 5px; +} + .co-table .add-row td { + padding-top: 10px; border-top: 2px solid #eee; border-bottom: none; } +.co-table tr.co-table-header-row td { + font-size: 12px; + text-transform: uppercase; + color: #ccc; + border-bottom: none; + padding-left: 10px; + padding-top: 10px; + padding-bottom: 4px; +} + +.co-table tr.co-table-header-row td i.fa { + margin-right: 4px; +} + +.co-table tr.indented-row td:first-child { + padding-left: 28px; +} + .cor-checkable-menu { display: inline-block; } @@ -910,3 +960,78 @@ margin-bottom: 10px; } +.co-alert { + padding: 16px; + padding-left: 46px; + position: relative; + margin-bottom: 20px; + position: relative; + border: 1px solid #eee; +} + +.co-alert.co-alert-success { + background: #F0FFF4; +} + +.co-alert.co-alert-success:before { + font-family: FontAwesome; + content: "\f058"; + position: absolute; + top: 11px; + left: 12px; + font-size: 22px; + color: #83D29C; +} + +.co-alert.co-alert-info { + background: #F0FAFF; +} + +.co-alert.co-alert-info:before { + font-family: FontAwesome; + content: "\f05a"; + position: absolute; + top: 11px; + left: 12px; + font-size: 22px; + color: #83B7D2; +} + +.co-alert.co-alert-warning { + background: #FFFBF0; +} + +.co-alert.co-alert-warning:before { + font-family: FontAwesome; + content: "\f071"; + position: absolute; + top: 11px; + left: 12px; + font-size: 22px; + color: #E4C212; +} + +.co-alert.co-alert-danger { + background: #FFF0F0; +} + +.co-alert.co-alert-danger:before { + font-family: core-icons; + content: "\f107"; + position: absolute; + top: 11px; + left: 12px; + font-size: 22px; + color: red; +} + +.co-alert.co-alert-danger:after { + font-family: FontAwesome; + content: "\f12a"; + position: absolute; + top: 16px; + left: 20px; + font-size: 16px; + color: white; + z-index: 2; +} \ No newline at end of file diff --git a/static/css/directives/repo-view/repo-panel-info.css b/static/css/directives/repo-view/repo-panel-info.css index 2291fd852..a0d35b312 100644 --- a/static/css/directives/repo-view/repo-panel-info.css +++ b/static/css/directives/repo-view/repo-panel-info.css @@ -1,3 +1,14 @@ +.repo-panel-info-element .right-controls { + margin-bottom: 20px; + float: right; +} + +.repo-panel-info-element .right-controls .copy-box { + width: 400px; + display: inline-block; + margin-left: 10px; +} + .repo-panel-info-element .stat-col { border-right: 2px solid #eee; } diff --git a/static/css/directives/repo-view/repo-panel-tags.css b/static/css/directives/repo-view/repo-panel-tags.css index aebf689f8..1a81a5604 100644 --- a/static/css/directives/repo-view/repo-panel-tags.css +++ b/static/css/directives/repo-view/repo-panel-tags.css @@ -63,4 +63,136 @@ .repo-panel-tags-element .options-col { padding-left: 20px; +} + +.repo-panel-tags-element .options-col .fa-download { + color: #999; + cursor: pointer; +} + +.repo-panel-tags-element .history-list { + margin: 10px; + border-left: 2px solid #eee; +} + +.repo-panel-tags-element .history-entry { + position:relative; + margin-top: 20px; + padding-left: 26px; + + transition: all 350ms ease-in-out; +} + +.repo-panel-tags-element .history-entry .history-text { + transition: transform 350ms ease-in-out, opacity 350ms ease-in-out; + overflow: hidden; + height: 40px; +} + +.repo-panel-tags-element .history-entry.filtered-mismatch { + margin-top: 10px; +} + +.repo-panel-tags-element .history-entry.filtered-mismatch .history-text { + height: 18px; + opacity: 0; +} + +.repo-panel-tags-element .history-entry.filtered-mismatch .history-icon { + opacity: 0.5; + transform: scale(0.5, 0.5); +} + +.repo-panel-tags-element .history-entry .history-date-break { + font-size: 16px; +} + +.repo-panel-tags-element .history-entry .history-date-break:before { + content: ""; + position: absolute; + border-radius: 50%; + width: 12px; + height: 12px; + background: #ccc; + top: 4px; + left: -7px; +} + +.repo-panel-tags-element .history-entry .history-icon { + border-radius: 50%; + width: 32px; + height: 32px; + line-height: 33px; + text-align: center; + font-size: 20px; + color: white; + background: #ccc; + + position: absolute; + left: -17px; + top: -4px; + display: inline-block; + + transition: all 350ms ease-in-out; +} + +.repo-panel-tags-element .history-entry.move .history-icon:before { + content: "\f061"; + font-family: FontAwesome; +} + +.repo-panel-tags-element .history-entry.create .history-icon:before { + content: "\f02b"; + font-family: FontAwesome; +} + +.repo-panel-tags-element .history-entry.delete .history-icon:before { + content: "\f014"; + font-family: FontAwesome; +} + +.repo-panel-tags-element .history-entry.move .history-icon { + background-color: #1f77b4; +} + +.repo-panel-tags-element .history-entry.create .history-icon { + background-color: #98df8a; +} + +.repo-panel-tags-element .history-entry.delete .history-icon { + background-color: #ff9896; +} + +.repo-panel-tags-element .history-entry .history-icon .fa-tag { + margin-right: 0px; +} + +.repo-panel-tags-element .history-entry .tag-span { + display: inline-block; + border-radius: 4px; + padding: 2px; + background: #eee; + padding-right: 6px; + color: black; + cursor: pointer; +} + +.repo-panel-tags-element .history-entry .tag-span.checked { + background: #F6FCFF; +} + +.repo-panel-tags-element .history-entry .tag-span:before { + content: "\f02b"; + font-family: FontAwesome; + margin-left: 4px; + margin-right: 4px; +} + +.repo-panel-tags-element .history-entry .history-description { + color: #777; +} + +.repo-panel-tags-element .history-entry .history-datetime { + font-size: 12px; + color: #ccc; } \ No newline at end of file diff --git a/static/css/directives/ui/application-manager.css b/static/css/directives/ui/application-manager.css new file mode 100644 index 000000000..8184f9183 --- /dev/null +++ b/static/css/directives/ui/application-manager.css @@ -0,0 +1,7 @@ +.application-manager-element .co-table { + margin-top: 20px; +} + +.application-manager-element i.fa { + margin-right: 4px; +} diff --git a/static/css/directives/ui/authorized-apps-manager.css b/static/css/directives/ui/authorized-apps-manager.css new file mode 100644 index 000000000..f3a74c8f7 --- /dev/null +++ b/static/css/directives/ui/authorized-apps-manager.css @@ -0,0 +1,3 @@ +.authorized-apps-manager .avatar { + margin-right: 4px; +} \ No newline at end of file diff --git a/static/css/directives/ui/avatar.css b/static/css/directives/ui/avatar.css new file mode 100644 index 000000000..3bcdd9f6b --- /dev/null +++ b/static/css/directives/ui/avatar.css @@ -0,0 +1,31 @@ +.avatar-element { + display: inline-block; + vertical-align: middle; + color: white !important; + text-align: center; + position: relative; + background: white; + overflow: hidden; +} + +.avatar-element.team { + border-radius: 50%; +} + +.avatar-element img { + position: absolute; + top: 0px; + left: 0px; + background: white; +} + +.avatar-element .letter { + cursor: default !important; + font-style: normal !important; + font-weight: normal !important; + font-variant: normal !important; +} + +a .avatar-element .letter { + cursor: pointer !important; +} \ No newline at end of file diff --git a/static/css/directives/ui/billing-invoices.css b/static/css/directives/ui/billing-invoices.css new file mode 100644 index 000000000..10011237c --- /dev/null +++ b/static/css/directives/ui/billing-invoices.css @@ -0,0 +1,25 @@ + +.billing-invoices-element .invoice-title { + padding: 6px; + cursor: pointer; +} + +.billing-invoices-element .invoice-status .success { + color: green; +} + +.billing-invoices-element .invoice-status .pending { + color: steelblue; +} + +.billing-invoices-element .invoice-status .danger { + color: red; +} + +.billing-invoices-element .invoice-amount:before { + content: '$'; +} + +.billing-invoices-element .fa-download { + color: #aaa; +} \ No newline at end of file diff --git a/static/css/directives/ui/build-mini-status.css b/static/css/directives/ui/build-mini-status.css index 0913e9b53..4f39f171d 100644 --- a/static/css/directives/ui/build-mini-status.css +++ b/static/css/directives/ui/build-mini-status.css @@ -10,6 +10,11 @@ text-decoration: none !important; } +.build-mini-status a { + text-decoration: none !important; + color: black; +} + .build-mini-status .timing { display: inline-block; margin-left: 30px; @@ -29,5 +34,15 @@ bottom: 4px; line-height: 33px; overflow: hidden; +} + +.build-mini-status .build-description .tbd-content { + position: absolute; + top: 0px; + bottom: 0px; + left: 0px; + right: 0px; + overflow: hidden; text-overflow: ellipsis; -} \ No newline at end of file + white-space: nowrap; +} diff --git a/static/css/directives/ui/convert-user-to-org.css b/static/css/directives/ui/convert-user-to-org.css new file mode 100644 index 000000000..c0cf59336 --- /dev/null +++ b/static/css/directives/ui/convert-user-to-org.css @@ -0,0 +1,35 @@ +.convert-user-to-org .convert-form h3 { + margin-bottom: 20px; +} + +.convert-user-to-org #convertForm { + max-width: 700px; +} + +.convert-user-to-org #convertForm .form-group { + margin-bottom: 20px; +} + +.convert-user-to-org #convertForm input { + margin-bottom: 10px; + margin-left: 20px; +} + +.convert-user-to-org #convertForm .existing-data { + font-size: 16px; + font-weight: bold; +} + +.convert-user-to-org #convertForm .description { + margin-top: 10px; + display: block; + color: #888; + font-size: 12px; + margin-left: 20px; +} + +.convert-user-to-org #convertForm .existing-data { + display: block; + padding-left: 20px; + margin-top: 10px; +} diff --git a/static/css/directives/ui/entity-reference.css b/static/css/directives/ui/entity-reference.css new file mode 100644 index 000000000..5885f0d8c --- /dev/null +++ b/static/css/directives/ui/entity-reference.css @@ -0,0 +1,7 @@ +.entity-reference .new-entity-reference .entity-name { + margin-left: 6px; +} + +.entity-reference .new-entity-reference .fa-wrench { + width: 16px; +} diff --git a/static/css/directives/ui/entity-search.css b/static/css/directives/ui/entity-search.css new file mode 100644 index 000000000..eaab63120 --- /dev/null +++ b/static/css/directives/ui/entity-search.css @@ -0,0 +1,52 @@ +.entity-search-element { + position: relative; + display: block; +} + +.entity-search-element .entity-reference { + position: absolute !important; + top: 7px; + left: 8px; + right: 36px; + z-index: 0; + pointer-events: none; +} + +.entity-search-element .entity-reference .entity-reference-element { + pointer-events: none; +} + +.entity-search-element .entity-reference-element i.fa-exclamation-triangle { + pointer-events: all; +} + +.entity-search-element .entity-reference .entity-name { + display: none; +} + +.entity-search-element input { + vertical-align: middle; + width: 100%; +} + +.entity-search-element.persistent input { + padding-left: 28px; + padding-right: 28px; +} + +.entity-search-element .twitter-typeahead { + vertical-align: middle; + display: block !important; + margin-right: 36px; +} + +.entity-search-element .dropdown { + vertical-align: middle; + position: absolute; + top: 0px; + right: 0px; +} + +.entity-search-element .menuitem .avatar { + margin-right: 4px; +} \ No newline at end of file diff --git a/static/css/directives/ui/external-login-button.css b/static/css/directives/ui/external-login-button.css new file mode 100644 index 000000000..390eda57d --- /dev/null +++ b/static/css/directives/ui/external-login-button.css @@ -0,0 +1,3 @@ +.external-login-button i.fa { + margin-right: 4px; +} \ No newline at end of file diff --git a/static/css/directives/ui/external-logins-manager.css b/static/css/directives/ui/external-logins-manager.css new file mode 100644 index 000000000..2c5ca7302 --- /dev/null +++ b/static/css/directives/ui/external-logins-manager.css @@ -0,0 +1,11 @@ +.external-logins-manager .empty { + color: #ccc; +} + +.external-logins-manager .external-auth-provider td:first-child { + font-size: 18px; +} + +.external-logins-manager .external-auth-provider td:first-child i.fa { + margin-right: 6px; +} \ No newline at end of file diff --git a/static/css/directives/ui/fetch-tag-dialog.css b/static/css/directives/ui/fetch-tag-dialog.css new file mode 100644 index 000000000..8155650a5 --- /dev/null +++ b/static/css/directives/ui/fetch-tag-dialog.css @@ -0,0 +1,19 @@ +.fetch-tag-dialog .modal-table { + width: 100%; +} + +.fetch-tag-dialog .modal-table .first-col { + width: 140px; +} + +.fetch-tag-dialog .co-dialog .modal-body { + padding: 20px; +} + +.fetch-tag-dialog .entity-search { + margin: 10px; +} + +.fetch-tag-dialog pre.command { + margin-top: 10px; +} \ No newline at end of file diff --git a/static/css/directives/ui/header-bar.css b/static/css/directives/ui/header-bar.css new file mode 100644 index 000000000..5629c1c69 --- /dev/null +++ b/static/css/directives/ui/header-bar.css @@ -0,0 +1,223 @@ +nav.navbar { + border: 0px; + border-radius: 0px; +} + +nav.navbar-default .navbar-nav>li>a { + letter-spacing: 0.5px; + color: #428bca; + font-size: 16px; +} + +nav.navbar-default .navbar-nav>li>a.active { + color: #f04c5c; +} + +.navbar-default .navbar-nav>li>a:hover, .navbar-default .navbar-nav>li>a:focus { + background: #eee; +} + +.navbar-default .navbar-nav>.open>a, .navbar-default .navbar-nav>.open>a:hover, .navbar-default .navbar-nav>.open>a:focus { + cursor: pointer; + background: rgba(255, 255, 255, 0.4) !important; +} + +.header-bar-element .header-bar-content.search-visible { + box-shadow: 0px 1px 4px #ccc; +} + +.header-bar-element .header-bar-content { + z-index: 5; + position: absolute; + top: 0px; + left: 0px; + right: 0px; + background: white; +} + +.header-bar-element .search-box { + position: absolute; + left: 0px; + right: 0px; + top: -50px; + z-index: 4; + height: 83px; + transition: top 0.3s cubic-bezier(.23,.88,.72,.98); + background: white; + box-shadow: 0px 1px 16px #444; + padding: 10px; +} + +.header-bar-element .search-box.search-visible { + top: 50px; +} + +.header-bar-element .search-box.results-visible { + box-shadow: 0px 1px 4px #ccc; +} + +.header-bar-element .search-box .search-label { + display: inline-block; + text-transform: uppercase; + font-size: 12px; + font-weight: bold; + color: #ccc; + margin-right: 10px; + position: absolute; + top: 34px; + left: 14px; +} + +.header-bar-element .search-box .search-box-wrapper { + position: absolute; + top: 0px; + left: 100px; + right: 10px; + padding: 10px; +} + +.header-bar-element .search-box .search-box-wrapper input { + font-size: 28px; + width: 100%; + padding: 10px; + border: 0px; +} + +.header-bar-element .search-results { + position: absolute; + left: 0px; + right: 0px; + top: -130px; + z-index: 3; + transition: top 0.4s cubic-bezier(.23,.88,.72,.98), height 0.25s ease-in-out; + + background: white; + box-shadow: 0px 1px 16px #444; + padding-top: 20px; +} + +.header-bar-element .search-results.loading, .header-bar-element .search-results.results { + top: 130px; +} + +.header-bar-element .search-results.loading { + height: 50px; +} + +.header-bar-element .search-results.no-results { + height: 150px; +} + +.header-bar-element .search-results ul { + padding: 0px; + margin: 0px; +} + +.header-bar-element .search-results li { + list-style: none; + padding: 6px; + margin-bottom: 4px; + padding-left: 20px; + position: relative; +} + +.header-bar-element .search-results li .kind { + text-transform: uppercase; + font-size: 12px; + display: inline-block; + margin-right: 10px; + color: #aaa; + width: 80px; + text-align: right; +} + +.header-bar-element .search-results .avatar { + margin-left: 6px; + margin-right: 2px; +} + +.header-bar-element .search-results li.current { + background: rgb(223, 242, 255); + cursor: pointer; +} + +.header-bar-element .search-results li i.fa { + margin-left: 6px; + margin-right: 4px; +} + +.header-bar-element .search-results li .description { + overflow: hidden; + text-overflow: ellipsis; + max-height: 24px; + padding-left: 10px; + display: inline-block; + color: #aaa; + vertical-align: middle; +} + +.header-bar-element .search-results li .score:before { + content: "Score: "; +} + +.header-bar-element .search-results li .score { + float: right; + color: #ccc; +} + +.header-bar-element .search-results li .result-name { + vertical-align: middle; +} + +.header-bar-element .search-results li .clarification { + font-size: 12px; + margin-left: 6px; + display: inline-block; +} + +.header-bar-element .avatar { + margin-right: 6px; +} + +.user-tools { + position: relative; + display: inline-block; +} + +.user-tools .user-tool { + font-size: 24px; + margin-top: 14px; + color: #428bca; + margin-right: 20px; +} + +.user-tools.with-menu { + margin-right: 6px; +} + +.user-tools .caret { + position: absolute; + top: 3px; + left: 23px +} + +.user-tools .notifications-bubble { + position: absolute; + top: 2px; + left: 13px; +} + +.user-tools i.user-tool:hover { + cursor: pointer; + color: #333; +} + +.user-tools .new-menu { + background: transparent !important; +} + +.header-bar-element .context-dropdown i.fa { + width: 16px; + text-align: center; + display: inline-block; +} diff --git a/static/css/directives/ui/image-link.css b/static/css/directives/ui/image-link.css new file mode 100644 index 000000000..03e39f3d4 --- /dev/null +++ b/static/css/directives/ui/image-link.css @@ -0,0 +1,4 @@ +.image-link a { + font-family: Consolas, "Lucida Console", Monaco, monospace; + font-size: 12px; +} \ No newline at end of file diff --git a/static/css/directives/ui/image-view-layer.css b/static/css/directives/ui/image-view-layer.css new file mode 100644 index 000000000..85fc70d99 --- /dev/null +++ b/static/css/directives/ui/image-view-layer.css @@ -0,0 +1,79 @@ +.image-view-layer-element { + position: relative; + padding: 10px; + padding-left: 170px; +} + +.image-view-layer-element .image-id { + font-family: monospace; + position: absolute; + top: 10px; + left: 10px; + width: 110px; + text-align: right; +} + +.image-view-layer-element .image-id a { + color: #aaa; +} + +.image-view-layer-element.first .image-id { + font-weight: bold; + font-size: 110%; +} + +.image-view-layer-element.first .image-id a { + color: black; +} + +.image-view-layer-element .image-comment { + margin-bottom: 10px; +} + +.image-view-layer-element .nondocker-command { + font-family: monospace; + padding: 2px; +} + +.image-view-layer-element .nondocker-command:before { + content: "\f120"; + font-family: "FontAwesome"; + font-size: 16px; + margin-right: 6px; + color: #999; +} + +.image-view-layer-element .image-layer-line { + position: absolute; + top: 0px; + bottom: 0px; + left: 140px; + + border-left: 2px solid #428bca; + width: 0px; +} + +.image-view-layer-element.first .image-layer-line { + top: 20px; +} + +.image-view-layer-element.last .image-layer-line { + height: 16px; +} + +.image-view-layer-element .image-layer-dot { + position: absolute; + top: 14px; + left: 135px; + border: 2px solid #428bca; + border-radius: 50%; + width: 12px; + height: 12px; + background: white; + + z-index: 2; +} + +.image-view-layer-element.first .image-layer-dot { + background: #428bca; +} diff --git a/static/css/directives/ui/prototype-manager.css b/static/css/directives/ui/prototype-manager.css new file mode 100644 index 000000000..41ccb9824 --- /dev/null +++ b/static/css/directives/ui/prototype-manager.css @@ -0,0 +1,8 @@ +.prototype-manager-element i.fa { + margin-right: 4px; +} + +.prototype-manager-element td { + padding: 10px !important; + vertical-align: middle !important; +} \ No newline at end of file diff --git a/static/css/directives/ui/repository-permissions-table.css b/static/css/directives/ui/repository-permissions-table.css index 8e69007a2..437e6d57e 100644 --- a/static/css/directives/ui/repository-permissions-table.css +++ b/static/css/directives/ui/repository-permissions-table.css @@ -1,3 +1,15 @@ .repository-permissions-table #add-entity-permission { padding-left: 0px; +} + +.repository-permissions-table .user-permission-entity { + position: relative; +} + +.repository-permissions-table .outside-org { + position: absolute; + top: 15px; + left: -2px; + font-size: 16px; + color: #E8BB03; } \ No newline at end of file diff --git a/static/css/directives/ui/robots-manager.css b/static/css/directives/ui/robots-manager.css new file mode 100644 index 000000000..a04fbcdc2 --- /dev/null +++ b/static/css/directives/ui/robots-manager.css @@ -0,0 +1,86 @@ +.robots-manager-element .robot a { + font-size: 16px; + cursor: pointer; +} + +.robots-manager-element .robot .prefix { + color: #aaa; +} + +.robots-manager-element .robot i { + margin-right: 10px; +} + +.robots-manager-element .popup-input-button i.fa { + margin-right: 4px; +} + +.robots-manager-element .empty { + color: #ccc; +} + +.robots-manager-element tr.open td { + border-bottom: 1px solid transparent; +} + +.robots-manager-element .permissions-table-wrapper { + margin-left: 0px; + border-left: 2px solid #ccc; + padding-left: 20px; +} + +.robots-manager-element .permissions-table tbody tr:last-child td { + border-bottom: 0px; +} + +.robots-manager-element .permissions-display-row { + position: relative; + padding-bottom: 20px; +} + +.robots-manager-element .permissions-display-row td:first-child { + min-width: 300px; +} + +.robots-manager-element .repo-circle { + color: #999; + display: inline-block; + position: relative; + background: #eee; + padding: 4px; + border-radius: 50%; + display: inline-block; + width: 46px; + height: 46px; + margin-right: 6px; +} + +.robots-manager-element .repo-circle .fa-hdd-o { + font-size: 1.7em; +} + +.robots-manager-element .repo-circle.no-background .fa-hdd-o { + font-size: 1.7em; +} + +.robots-manager-element .repo-circle .fa-lock { + width: 16px; + height: 16px; + line-height: 16px; + font-size: 12px !important; +} + +.robots-manager-element .repo-circle.no-background .fa-lock { + bottom: 5px; + right: 2px; +} + +.robots-manager-element .member-perm-summary { + margin-right: 14px; +} + +.robots-manager-element .co-filter-box { + float: right; + min-width: 175px; + margin-bottom: 10px; +} \ No newline at end of file diff --git a/static/css/directives/ui/teams-manager.css b/static/css/directives/ui/teams-manager.css new file mode 100644 index 000000000..35cd2cfad --- /dev/null +++ b/static/css/directives/ui/teams-manager.css @@ -0,0 +1,59 @@ +.teams-manager .popup-input-button { + float: right; +} + +.teams-manager .manager-header { + border-bottom: 1px solid #eee; + margin-bottom: 10px; +} + +.teams-manager .cor-options-menu { + display: inline-block; + margin-left: 10px; +} + +.teams-manager .header-col .info-icon { + font-size: 16px; +} + +.teams-manager .header-col .header-text { + text-transform: uppercase; + font-size: 14px; + color: #aaa !important; + display: inline-block; + padding-top: 4px; +} + +.teams-manager .control-col { + padding-top: 6px; +} + +.teams-manager .team-listing { + margin-bottom: 10px; +} + +.teams-manager .team-listing .avatar { + margin-right: 6px; +} + +.teams-manager .team-member-list .fa { + color: #ccc; +} + +.teams-manager .team-member-list { + position: relative; + min-height: 20px; + padding: 4px; + padding-left: 40px; +} + +.teams-manager .team-member-list .team-member a { + text-decoration: none !important; +} + +.teams-manager .team-member-list .team-member-more { + vertical-align: middle; + padding-left: 6px; + color: #aaa; + font-size: 14px; +} diff --git a/static/css/pages/image-view.css b/static/css/pages/image-view.css new file mode 100644 index 000000000..f91ceacc9 --- /dev/null +++ b/static/css/pages/image-view.css @@ -0,0 +1,25 @@ +.image-view .image-view-header { + padding: 10px; + background: #e8f1f6; + margin: -10px; + margin-bottom: 20px; +} + +.image-view .image-view-header .section-icon { + margin-right: 6px; +} + +.image-view .image-view-header .section { + padding: 4px; + display: inline-block; + margin-right: 20px; +} + +.image-view .co-tab-content { + padding: 20px; + padding-top: 10px; +} + +.image-view .co-tab-content h3 { + margin-bottom: 20px; +} diff --git a/static/css/pages/new-organization.css b/static/css/pages/new-organization.css new file mode 100644 index 000000000..6e6a1ef7b --- /dev/null +++ b/static/css/pages/new-organization.css @@ -0,0 +1,4 @@ +.new-organization .co-main-content-panel { + padding: 30px; + position: relative; +} \ No newline at end of file diff --git a/static/css/pages/new-repo.css b/static/css/pages/new-repo.css new file mode 100644 index 000000000..425e26774 --- /dev/null +++ b/static/css/pages/new-repo.css @@ -0,0 +1,90 @@ +.new-repo .co-main-content-panel { + padding: 30px; +} + +.new-repo .namespace-selector-header .slash { + color: #444; + padding-left: 6px; + padding-right: 6px; +} + +.new-repo .required-plan { + margin: 10px; + margin-top: 20px; + margin-left: 50px; +} + +.new-repo .required-plan .alert { + color: #444 !important; +} + +.new-repo .new-header .popover { + font-size: 14px; +} + +.new-repo .new-header .repo-circle { + margin-right: 14px; +} + +.new-repo .new-header .name-container { + display: inline-block; + width: 300px; + vertical-align: middle; +} + +.new-repo .description { + margin-left: 10px; + margin-top: 10px; +} + +.new-repo .section { + padding-bottom: 20px; + border-bottom: 1px solid #eee; + margin-bottom: 16px; +} + +.new-repo .section-title { + float: right; + color: #aaa; +} + +.new-repo .repo-option { + margin: 6px; + margin-top: 16px; +} + +.new-repo .repo-option label { + font-weight: normal; +} + +.new-repo .repo-option i { + font-size: 18px; + padding-left: 10px; + padding-right: 10px; + width: 42px; + display: inline-block; + text-align: center; +} + +.new-repo .option-description { + display: inline-block; + vertical-align: top; +} + +.new-repo .option-description label { + display: block; +} + +.new-repo .cbox { + margin: 10px; +} + +.new-repo .initialize-repo { + margin: 10px; + margin-top: 16px; + margin-left: 20px; +} + +.new-repo .initialize-repo .file-drop { + margin: 10px; +} diff --git a/static/css/pages/org-view.css b/static/css/pages/org-view.css new file mode 100644 index 000000000..c8d798acd --- /dev/null +++ b/static/css/pages/org-view.css @@ -0,0 +1,26 @@ +.org-view .organization-name { + vertical-align: middle; + margin-left: 6px; +} + +.org-view h3 { + margin-bottom: 20px; + margin-top: 0px; +} + +.org-view .section-description-header { + padding-left: 40px; + position: relative; + margin-bottom: 20px; + min-height: 50px; +} + +.org-view .section-description-header:before { + font-family: FontAwesome; + content: "\f05a"; + position: absolute; + top: -4px; + left: 6px; + font-size: 27px; + color: #888; +} \ No newline at end of file diff --git a/static/css/pages/repo-list.css b/static/css/pages/repo-list.css index c92110fc1..ac0481c86 100644 --- a/static/css/pages/repo-list.css +++ b/static/css/pages/repo-list.css @@ -24,3 +24,15 @@ .repo-list .namespaces-list li .avatar { margin-right: 10px; } + +.repo-list .new-org { + margin-top: 20px !important; + padding-top: 14px; + border-top: 1px solid #eee; +} + +.repo-list .new-org i.fa { + width: 30px; + margin-right: 10px; + text-align: center; +} \ No newline at end of file diff --git a/static/css/pages/repo-view.css b/static/css/pages/repo-view.css index 9051be756..dd0c3d298 100644 --- a/static/css/pages/repo-view.css +++ b/static/css/pages/repo-view.css @@ -44,3 +44,7 @@ .repository-view .heading-controls .btn .fa { margin-right: 6px; } + +.repository-view .tag-span { + white-space: nowrap; +} \ No newline at end of file diff --git a/static/css/pages/team-view.css b/static/css/pages/team-view.css new file mode 100644 index 000000000..0e9a8d7a0 --- /dev/null +++ b/static/css/pages/team-view.css @@ -0,0 +1,55 @@ +.team-view .co-main-content-panel { + padding: 20px; +} + +.team-view .team-name { + vertical-align: middle; + margin-left: 6px; +} + +.team-view .team-view-header { + border-bottom: 1px solid #eee; + margin-bottom: 10px; + padding-bottom: 10px; +} + +.team-view .team-view-header button i.fa { + margin-right: 4px; +} + +.team-view .team-view-header > h3 { + margin-top: 10px; +} + +.team-view .team-view-header .popover { + max-width: none !important; +} + +.team-view .team-view-header .popover.bottom-right .arrow:after { + border-bottom-color: #f7f7f7; + top: 2px; +} + +.team-view .team-view-header .popover-content { + font-size: 14px; + padding-top: 6px; + min-width: 500px; +} + +.team-view .team-view-header .popover-content input { + background: white; +} + +.team-view .team-view-add-element .help-text { + font-size: 13px; + color: #ccc; + margin-top: 10px; +} + +.team-view .co-table-header-row td { + padding-top: 20px !important; +} + +.team-view .co-table-header-row:first-child td { + padding-top: 10px !important; +} \ No newline at end of file diff --git a/static/css/pages/tutorial.css b/static/css/pages/tutorial.css new file mode 100644 index 000000000..8f68a4061 --- /dev/null +++ b/static/css/pages/tutorial.css @@ -0,0 +1,3 @@ +.tutorial-view .co-main-content-panel { + padding: 30px; +} \ No newline at end of file diff --git a/static/css/pages/user-view.css b/static/css/pages/user-view.css new file mode 100644 index 000000000..32892d6ba --- /dev/null +++ b/static/css/pages/user-view.css @@ -0,0 +1,55 @@ +.user-view .user-name { + vertical-align: middle; + margin-left: 6px; +} + +.user-view .user-repo-list { + padding: 20px; +} + +.user-view h3 { + margin-bottom: 20px; + margin-top: 0px; +} + +.user-view .section-description-header { + padding-left: 40px; + position: relative; + margin-bottom: 20px; + min-height: 50px; +} + +.user-view .section-description-header:before { + font-family: FontAwesome; + content: "\f05a"; + position: absolute; + top: -4px; + left: 6px; + font-size: 27px; + color: #888; +} + +.user-view .user-settings-form .row { + padding: 10px; + margin: 0px; +} + +.user-view .co-panel { + position: relative; + min-height: 50px; +} + +.user-view .co-panel .panel-body { + position: relative; +} + +.user-view .co-panel .row { + margin: 10px; +} + +.user-view .co-panel .row .panel { + margin-bottom: 20px; + border-bottom: 0px; + box-shadow: none; +} + diff --git a/static/css/quay.css b/static/css/quay.css index bcc0f6584..477b08929 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -29,7 +29,7 @@ .announcement { position: absolute; - z-index: 9; + z-index: 4; top: 0px; left: 0px; right: 0px; @@ -150,30 +150,6 @@ max-width: none !important; } -nav.navbar { - border: 0px; - border-radius: 0px; -} - -nav.navbar-default .navbar-nav>li>a { - letter-spacing: 0.5px; - color: #428bca; - font-size: 16px; -} - -nav.navbar-default .navbar-nav>li>a.active { - color: #f04c5c; -} - -.navbar-default .navbar-nav>li>a:hover, .navbar-default .navbar-nav>li>a:focus { - background: #eee; -} - -.navbar-default .navbar-nav>.open>a, .navbar-default .navbar-nav>.open>a:hover, .navbar-default .navbar-nav>.open>a:focus { - cursor: pointer; - background: rgba(255, 255, 255, 0.4) !important; -} - .notification-view-element { cursor: pointer; margin-bottom: 10px; @@ -277,6 +253,7 @@ nav.navbar-default .navbar-nav>li>a.active { .dockerfile-command .command-title { font-family: Consolas, "Lucida Console", Monaco, monospace; padding-left: 90px; + display: inline-block; } .dockerfile-command .label { @@ -371,55 +348,6 @@ nav.navbar-default .navbar-nav>li>a.active { top: 70px; } -.entity-search-element { - position: relative; - display: block; -} - -.entity-search-element .entity-reference { - position: absolute !important; - top: 7px; - left: 8px; - right: 36px; - z-index: 0; - pointer-events: none; -} - -.entity-search-element .entity-reference .entity-reference-element { - pointer-events: none; -} - -.entity-search-element .entity-reference-element i.fa-exclamation-triangle { - pointer-events: all; -} - -.entity-search-element .entity-reference .entity-name { - display: none; -} - -.entity-search-element input { - vertical-align: middle; - width: 100%; -} - -.entity-search-element.persistent input { - padding-left: 28px; - padding-right: 28px; -} - -.entity-search-element .twitter-typeahead { - vertical-align: middle; - display: block !important; - margin-right: 36px; -} - -.entity-search-element .dropdown { - vertical-align: middle; - position: absolute; - top: 0px; - right: 0px; -} - .dropdown-menu i.fa { margin-right: 6px; position: relative; @@ -573,27 +501,6 @@ i.toggle-icon:hover { visibility: hidden; } -.robots-manager-element { - max-width: 800px; -} - -.robots-manager-element .alert { - margin-bottom: 20px; -} - -.robots-manager-element .robot a { - font-size: 16px; - cursor: pointer; -} - -.robots-manager-element .robot .prefix { - color: #aaa; -} - -.robots-manager-element .robot i { - margin-right: 10px; -} - .logs-view-element .header { padding-bottom: 10px; border-bottom: 1px solid #eee; @@ -835,17 +742,6 @@ i.toggle-icon:hover { 100% { transform: scale(1); } } -.user-tools .user-tool { - font-size: 24px; - margin-top: 14px; - color: #428bca; -} - -.user-tools i.user-tool:hover { - cursor: pointer; - color: #333; -} - .status-box a { padding: 6px; color: black; @@ -1039,89 +935,6 @@ i.toggle-icon:hover { text-decoration: none !important; } -.new-repo .required-plan { - margin: 10px; - margin-top: 20px; - margin-left: 50px; -} - -.new-repo .required-plan .alert { - color: #444 !important; -} - -.new-repo .new-header .popover { - font-size: 14px; -} - -.new-repo .new-header .repo-circle { - margin-right: 14px; -} - -.new-repo .new-header .name-container { - display: inline-block; - width: 300px; - vertical-align: middle; -} - -.new-repo .description { - margin-left: 10px; - margin-top: 10px; -} - -.new-repo .section { - padding-bottom: 20px; - border-bottom: 1px solid #eee; - margin-bottom: 16px; -} - -.new-repo .section-title { - float: right; - color: #aaa; -} - -.new-repo .repo-option { - margin: 6px; - margin-top: 16px; -} - -.new-repo .repo-option label { - font-weight: normal; -} - -.new-repo .repo-option i { - font-size: 18px; - padding-left: 10px; - padding-right: 10px; - width: 42px; - display: inline-block; - text-align: center; -} - -.new-repo .option-description { - display: inline-block; - vertical-align: top; -} - -.new-repo .option-description label { - display: block; -} - -.new-repo .cbox { - margin: 10px; -} - -.new-repo .initialize-repo { - margin: 10px; - margin-top: 16px; - margin-left: 20px; - padding: 10px; - border: 1px dashed #ccc; -} - -.new-repo .initialize-repo .file-drop { - margin: 10px; -} - .user-guide h3 { margin-bottom: 20px; } @@ -2329,6 +2142,7 @@ p.editable:hover i { .copy-box-element input { border: 0px; padding-right: 32px; + cursor: pointer !important; } .copy-box-element .copy-container .copy-icon { @@ -3335,64 +3149,6 @@ p.editable:hover i { max-width: 100%; } -.billing-invoices-element .invoice-title { - padding: 6px; - cursor: pointer; -} - -.billing-invoices-element .invoice-status .success { - color: green; -} - -.billing-invoices-element .invoice-status .pending { - color: steelblue; -} - -.billing-invoices-element .invoice-status .danger { - color: red; -} - -.billing-invoices-element .invoice-amount:before { - content: '$'; -} - -.billing-invoices-element .invoice-details { - margin-left: 10px; - margin-bottom: 10px; - - padding: 4px; - padding-left: 6px; - border-left: 2px solid #eee !important; -} - -.billing-invoices-element .invoice-details td { - border: 0px solid transparent !important; -} - -.billing-invoices-element .invoice-details dl { - margin: 0px; -} - -.billing-invoices-element .invoice-details dd { - margin-left: 10px; - padding: 6px; - margin-bottom: 10px; -} - -.billing-invoices-element .invoice-title:hover { - color: steelblue; -} - -.prototype-manager-element thead th { - padding: 4px; - color: #666; -} - -.prototype-manager-element td { - padding: 10px !important; - vertical-align: middle !important; -} - .org-list h2 { margin-bottom: 20px; } @@ -4787,6 +4543,20 @@ i.slack-icon { height: 16px; } +i.docker-icon { + background-image: url(/static/img/docker.png); + background-size: 16px; + width: 16px; + height: 16px; +} + +i.rocket-icon { + background-image: url(/static/img/rocket.png); + background-size: 16px; + width: 16px; + height: 16px; +} + .external-notification-view-element { margin: 10px; padding: 6px; @@ -4989,3 +4759,13 @@ i.slack-icon { text-align: center; } +.manager-header { + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; +} + +.manager-header h3 { + margin-bottom: 10px; +} + diff --git a/static/directives/anchor.html b/static/directives/anchor.html new file mode 100644 index 000000000..563fbe581 --- /dev/null +++ b/static/directives/anchor.html @@ -0,0 +1,4 @@ + + + + diff --git a/static/directives/angular-tour-ui.html b/static/directives/angular-tour-ui.html index d2fc3ed9d..a3a59abc7 100644 --- a/static/directives/angular-tour-ui.html +++ b/static/directives/angular-tour-ui.html @@ -1,11 +1,6 @@
-
-

{{ tour.title }}

- -
-
Your browser does not support features required for this tour. Please upgrade and try again.
diff --git a/static/directives/application-info.html b/static/directives/application-info.html index 241fec279..960bee7ed 100644 --- a/static/directives/application-info.html +++ b/static/directives/application-info.html @@ -1,6 +1,6 @@
- +

{{ application.name }}

{{ application.organization.name }} diff --git a/static/directives/application-manager.html b/static/directives/application-manager.html index ba7fb90ae..57c8a111d 100644 --- a/static/directives/application-manager.html +++ b/static/directives/application-manager.html @@ -1,17 +1,32 @@
-
- -
-
- - Create New Application - +
+
+
+
+ + Create New Application + +
+

OAuth Applications

- +
+ The OAuth Applications panel allows organizations to define custom OAuth applications that can be used by internal or external customers to access data on behalf of the customers. More information about the API can be found by contacting support. +
+ +
+
No OAuth applications defined.
+
+ Click the "Create New Application" button above to create a new OAuth application under + this organization. +
+
+ +
- - + + diff --git a/static/directives/authorized-apps-manager.html b/static/directives/authorized-apps-manager.html new file mode 100644 index 000000000..cc050346b --- /dev/null +++ b/static/directives/authorized-apps-manager.html @@ -0,0 +1,56 @@ +
+
+

Authorized Applications

+
+ +
+ The authorized applications panel lists applications you have authorized to view information and perform actions on your behalf. You can revoke any of your authorizations here by clicking the gear icon and clicking "Revoke Authorization". +
+ +
+ +
+
You have not authorized any external applications.
+
+ +
Application NameApplication URIApplication NameApplication URI
+ + + + + + + + + + + +
Application NameAuthorized Permissions
+ + + {{ authInfo.application.name }} + + + {{ authInfo.application.name }} + + {{ authInfo.application.organization.name }} + + + {{ scopeInfo.scope }} + + + + + Revoke Authorization + + +
+
+
\ No newline at end of file diff --git a/static/directives/avatar.html b/static/directives/avatar.html index 46c56afe5..b55f0405a 100644 --- a/static/directives/avatar.html +++ b/static/directives/avatar.html @@ -1 +1,12 @@ - \ No newline at end of file + + + + {{ data.name.charAt(0).toUpperCase() }} + Ω + + \ No newline at end of file diff --git a/static/directives/billing-invoices.html b/static/directives/billing-invoices.html index e022fef03..802abc358 100644 --- a/static/directives/billing-invoices.html +++ b/static/directives/billing-invoices.html @@ -1,25 +1,26 @@
-
-
-
+
-
- No invoices have been created -
+
+
No billing invoices found.
+
+ This account has not been billed by . +
+
-
- +
+
- - - - + + + + - - + + - - - - -
Billing Date/TimeAmount DueStatusBilling Date/TimeAmount DueStatus
{{ invoice.date * 1000 | date:'medium' }}{{ invoice.amount_due / 100 }}{{ invoice.date * 1000 | date:'medium' }}{{ invoice.amount_due / 100 }} Paid - Thank you! @@ -28,24 +29,12 @@ Payment pending +
-
-
Billing Period
-
- {{ invoice.period_start * 1000 | date:'mediumDate' }} - - {{ invoice.period_end * 1000 | date:'mediumDate' }} -
-
-
diff --git a/static/directives/build-info-bar.html b/static/directives/build-info-bar.html index 45b17d47c..a25a056c6 100644 --- a/static/directives/build-info-bar.html +++ b/static/directives/build-info-bar.html @@ -15,4 +15,4 @@
Manually Started Build
-
\ No newline at end of file +
diff --git a/static/directives/build-log-error.html b/static/directives/build-log-error.html index c012a623f..af7659a08 100644 --- a/static/directives/build-log-error.html +++ b/static/directives/build-log-error.html @@ -4,7 +4,7 @@ caused by attempting to pull private repository {{ getLocalPullInfo().repo }} - with inaccessible crdentials + with inaccessible credentials without credentials diff --git a/static/directives/build-logs-view.html b/static/directives/build-logs-view.html index 696fd4b93..074ccd5b0 100644 --- a/static/directives/build-logs-view.html +++ b/static/directives/build-logs-view.html @@ -36,4 +36,4 @@

-
\ No newline at end of file + diff --git a/static/directives/build-mini-status.html b/static/directives/build-mini-status.html index e16086195..bba073329 100644 --- a/static/directives/build-mini-status.html +++ b/static/directives/build-mini-status.html @@ -1,10 +1,13 @@ - -
- - - - + + +
+ + + + -
-
-
\ No newline at end of file +
+
+ + diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html index 219dea4a3..04ac2abf2 100644 --- a/static/directives/config/config-setup-tool.html +++ b/static/directives/config/config-setup-tool.html @@ -34,7 +34,7 @@ - User Creation: + User Creation:
@@ -46,6 +46,23 @@
+ + Encrypted Client Password: + +
+ + +
+
+ If enabled, users will not be able to login from the Docker command + line with a non-encrypted password and must generate an encrypted + password to use. +
+
+ This feature is highly recommended for setups with LDAP authentication, as Docker currently stores passwords in plaintext on user's machines. +
+ + @@ -293,6 +310,16 @@

+
+ It is highly recommended to require encrypted client passwords. LDAP passwords used in the Docker client will be stored in plaintext! + Enable this requirement now. +
+ +
+ Note: The "Require Encrypted Client Passwords" feature is currently enabled which will + prevent LDAP passwords from being saved as plaintext by the Docker client. +
+ @@ -305,7 +332,6 @@
Authentication:
- @@ -406,6 +432,28 @@ + + + +
LDAP URI:
Organization Filtering: +
+ + +
+ +
+ If enabled, only members of specified GitHub + Enterprise organizations will be allowed to login via GitHub + Enterprise. +
+ + + +
diff --git a/static/directives/convert-user-to-org.html b/static/directives/convert-user-to-org.html new file mode 100644 index 000000000..4dc143c20 --- /dev/null +++ b/static/directives/convert-user-to-org.html @@ -0,0 +1,100 @@ +
+ +
+
+
+ Cannot convert this account into an organization, as it is a member of {{user.organizations.length}} other + organization{{user.organizations.length > 1 ? 's' : ''}}. Please leave + {{user.organizations.length > 1 ? 'those organizations' : 'that organization'}} first. +
+
+ +
+
+ Note: Converting a user account into an organization cannot be undone +
+ + +
+
+ + +
+
+
+ +
+ + {{ user.username }}
+ This will continue to be the namespace for your repositories +
+ +
+ + + + + The username and password for the account that will become an administrator of the organization. + Note that this account must be a separate registered account from the account that you are + trying to convert, and must already exist. + +
+ + +
+ +
+
+ +
+ +
+
+
+ + + + + + + + +
\ No newline at end of file diff --git a/static/directives/cor-checkable-item.html b/static/directives/cor-checkable-item.html index f3e65e39b..4dde44d92 100644 --- a/static/directives/cor-checkable-item.html +++ b/static/directives/cor-checkable-item.html @@ -1,3 +1,3 @@ - \ No newline at end of file + diff --git a/static/directives/cor-checkable-menu-item.html b/static/directives/cor-checkable-menu-item.html index 452e37ea7..3fc5f7c25 100644 --- a/static/directives/cor-checkable-menu-item.html +++ b/static/directives/cor-checkable-menu-item.html @@ -1 +1 @@ -
  • \ No newline at end of file +
  • diff --git a/static/directives/cor-checkable-menu.html b/static/directives/cor-checkable-menu.html index 2c6fce8f4..74c626ff1 100644 --- a/static/directives/cor-checkable-menu.html +++ b/static/directives/cor-checkable-menu.html @@ -9,4 +9,4 @@ - \ No newline at end of file + diff --git a/static/directives/cor-confirm-dialog.html b/static/directives/cor-confirm-dialog.html index 330729390..c6aa9d2fd 100644 --- a/static/directives/cor-confirm-dialog.html +++ b/static/directives/cor-confirm-dialog.html @@ -22,4 +22,4 @@ - \ No newline at end of file + diff --git a/static/directives/cor-floating-bottom-bar.html b/static/directives/cor-floating-bottom-bar.html index 2e5337fd2..11615e6a8 100644 --- a/static/directives/cor-floating-bottom-bar.html +++ b/static/directives/cor-floating-bottom-bar.html @@ -1,3 +1,3 @@
    -
    \ No newline at end of file + diff --git a/static/directives/cor-loader-inline.html b/static/directives/cor-loader-inline.html index 39ffb5b99..3a2c42c1d 100644 --- a/static/directives/cor-loader-inline.html +++ b/static/directives/cor-loader-inline.html @@ -2,4 +2,4 @@
    - \ No newline at end of file + diff --git a/static/directives/cor-loader.html b/static/directives/cor-loader.html index 112680a22..f0aab7afc 100644 --- a/static/directives/cor-loader.html +++ b/static/directives/cor-loader.html @@ -2,4 +2,4 @@
    - \ No newline at end of file + diff --git a/static/directives/cor-log-box.html b/static/directives/cor-log-box.html index c5442d0f7..6d3157db3 100644 --- a/static/directives/cor-log-box.html +++ b/static/directives/cor-log-box.html @@ -8,4 +8,4 @@
    New Logs
    - \ No newline at end of file + diff --git a/static/directives/cor-option.html b/static/directives/cor-option.html index 0eb57170b..727e3dda3 100644 --- a/static/directives/cor-option.html +++ b/static/directives/cor-option.html @@ -1,3 +1,3 @@
  • -
  • \ No newline at end of file + diff --git a/static/directives/cor-options-menu.html b/static/directives/cor-options-menu.html index 8b6cf1e26..7e5f43cc3 100644 --- a/static/directives/cor-options-menu.html +++ b/static/directives/cor-options-menu.html @@ -3,4 +3,4 @@ - \ No newline at end of file + diff --git a/static/directives/cor-step-bar.html b/static/directives/cor-step-bar.html index 274a2c924..117f8185d 100644 --- a/static/directives/cor-step-bar.html +++ b/static/directives/cor-step-bar.html @@ -1,3 +1,3 @@
    -
    \ No newline at end of file + diff --git a/static/directives/cor-step.html b/static/directives/cor-step.html index 5339db30e..acc9baee4 100644 --- a/static/directives/cor-step.html +++ b/static/directives/cor-step.html @@ -3,4 +3,4 @@ {{ text }} - \ No newline at end of file + diff --git a/static/directives/cor-tab-content.html b/static/directives/cor-tab-content.html index 997ae5af1..747ccb2c8 100644 --- a/static/directives/cor-tab-content.html +++ b/static/directives/cor-tab-content.html @@ -1 +1 @@ -
    \ No newline at end of file +
    diff --git a/static/directives/cor-tab-panel.html b/static/directives/cor-tab-panel.html index f92d683ab..d041c9466 100644 --- a/static/directives/cor-tab-panel.html +++ b/static/directives/cor-tab-panel.html @@ -1,3 +1,3 @@
    -
    \ No newline at end of file + diff --git a/static/directives/cor-tab.html b/static/directives/cor-tab.html index f22d3bdac..07d4e0e92 100644 --- a/static/directives/cor-tab.html +++ b/static/directives/cor-tab.html @@ -1,11 +1,13 @@
  • - - + ng-click="tabInit()"> + + + -
  • \ No newline at end of file + diff --git a/static/directives/cor-tabs.html b/static/directives/cor-tabs.html index 1a965932e..5ab85ecb1 100644 --- a/static/directives/cor-tabs.html +++ b/static/directives/cor-tabs.html @@ -1 +1 @@ -
      \ No newline at end of file +
        diff --git a/static/directives/cor-title-action.html b/static/directives/cor-title-action.html index f06f9b78d..807fe1bab 100644 --- a/static/directives/cor-title-action.html +++ b/static/directives/cor-title-action.html @@ -1,3 +1,3 @@
        -
        \ No newline at end of file + diff --git a/static/directives/cor-title-content.html b/static/directives/cor-title-content.html index 6acbe47b3..5b2077d08 100644 --- a/static/directives/cor-title-content.html +++ b/static/directives/cor-title-content.html @@ -1,3 +1,3 @@

        -
        \ No newline at end of file + diff --git a/static/directives/cor-title-link.html b/static/directives/cor-title-link.html index f400b8741..428671f86 100644 --- a/static/directives/cor-title-link.html +++ b/static/directives/cor-title-link.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/static/directives/entity-reference.html b/static/directives/entity-reference.html index 0252515dc..2229bcb0c 100644 --- a/static/directives/entity-reference.html +++ b/static/directives/entity-reference.html @@ -1,38 +1,3 @@ - - - - {{entity.name}} - {{entity.name}} - - - - - - {{entity.name}} - {{entity.name}} - - - - - - - - - - - - {{ getPrefix(entity.name) }}+{{ getShortenedName(entity.name) }} - - - {{ getPrefix(entity.name) }}+{{ getShortenedName(entity.name) }} - - - - {{getShortenedName(entity.name)}} - - - - + diff --git a/static/directives/entity-search.html b/static/directives/entity-search.html index 80114df88..ae181735d 100644 --- a/static/directives/entity-search.html +++ b/static/directives/entity-search.html @@ -6,12 +6,26 @@ diff --git a/static/directives/external-login-button.html b/static/directives/external-login-button.html index 10701f941..edf81a36a 100644 --- a/static/directives/external-login-button.html +++ b/static/directives/external-login-button.html @@ -1,6 +1,6 @@ diff --git a/static/directives/header-bar.html b/static/directives/header-bar.html index bac17c19c..43ef0847a 100644 --- a/static/directives/header-bar.html +++ b/static/directives/header-bar.html @@ -1,78 +1,3 @@ - - - - - + + + diff --git a/static/directives/image-changes-view.html b/static/directives/image-changes-view.html index 65a5e99ed..78b9581ce 100644 --- a/static/directives/image-changes-view.html +++ b/static/directives/image-changes-view.html @@ -30,4 +30,4 @@ - \ No newline at end of file + diff --git a/static/directives/image-info-sidebar.html b/static/directives/image-info-sidebar.html index 14bd00a46..06eb56b37 100644 --- a/static/directives/image-info-sidebar.html +++ b/static/directives/image-info-sidebar.html @@ -102,4 +102,4 @@ has-changes="hasImageChanges"> - \ No newline at end of file + diff --git a/static/directives/image-link.html b/static/directives/image-link.html new file mode 100644 index 000000000..a41bbd33d --- /dev/null +++ b/static/directives/image-link.html @@ -0,0 +1,2 @@ +{{ imageId.substr(0, 12) }} \ No newline at end of file diff --git a/static/directives/image-view-layer.html b/static/directives/image-view-layer.html new file mode 100644 index 000000000..f85e34964 --- /dev/null +++ b/static/directives/image-view-layer.html @@ -0,0 +1,15 @@ +
        + +
        {{ image.comment }}
        +
        +
        {{ image.command.join(' ') }}
        +
        +
        +
        +
        +
        diff --git a/static/directives/manual-trigger-build-dialog.html b/static/directives/manual-trigger-build-dialog.html index a5eb0dce5..f6d0aeeb3 100644 --- a/static/directives/manual-trigger-build-dialog.html +++ b/static/directives/manual-trigger-build-dialog.html @@ -4,7 +4,7 @@ \ No newline at end of file + diff --git a/static/directives/realtime-line-chart.html b/static/directives/realtime-line-chart.html index 74e8f748c..36c0602c5 100644 --- a/static/directives/realtime-line-chart.html +++ b/static/directives/realtime-line-chart.html @@ -3,4 +3,4 @@
        - \ No newline at end of file + diff --git a/static/directives/repo-list-grid.html b/static/directives/repo-list-grid.html index edad56285..aca6fa2b6 100644 --- a/static/directives/repo-list-grid.html +++ b/static/directives/repo-list-grid.html @@ -2,15 +2,18 @@
        -
        - - Starred -
        -
        - - {{ namespace.name }} - {{ namespace.name }} +
        +
        + + Starred +
        +
        diff --git a/static/directives/repo-star.html b/static/directives/repo-star.html index 1152b8338..198cdcd83 100644 --- a/static/directives/repo-star.html +++ b/static/directives/repo-star.html @@ -2,4 +2,4 @@ - \ No newline at end of file + diff --git a/static/directives/repo-view/repo-panel-builds.html b/static/directives/repo-view/repo-panel-builds.html index 081690a4b..24f4493f5 100644 --- a/static/directives/repo-view/repo-panel-builds.html +++ b/static/directives/repo-view/repo-panel-builds.html @@ -37,16 +37,19 @@ - - - - @@ -63,7 +66,7 @@ @@ -140,7 +143,8 @@ View Credentials - + Run Trigger Now diff --git a/static/directives/repo-view/repo-panel-info.html b/static/directives/repo-view/repo-panel-info.html index 79ee118cb..3cef46919 100644 --- a/static/directives/repo-view/repo-panel-info.html +++ b/static/directives/repo-view/repo-panel-info.html @@ -48,7 +48,8 @@
        -
        +
        @@ -61,7 +62,14 @@
        + + + +

        Description

        +
        User and Robot Permissions
        -
        +
        @@ -12,7 +12,7 @@
        Access Token Permissions
        -
        +
        @@ -49,11 +49,15 @@
        -
        Deleting a repository cannot be undone. Here be dragons!
        - +
        + + + Deleting a repository cannot be undone. Here be dragons! +
        diff --git a/static/directives/repo-view/repo-panel-tags.html b/static/directives/repo-view/repo-panel-tags.html index dbb752e23..575d91dcc 100644 --- a/static/directives/repo-view/repo-panel-tags.html +++ b/static/directives/repo-view/repo-panel-tags.html @@ -1,6 +1,72 @@
        +
        +
        + + +
        +
        +

        Repository Tags

        -
        + + +
        +
        +
        +
        + +
        + + + +
        + +
        +
        +
        This repository is empty.
        +
        Push a tag or initiate a build to populate this repository.
        +
        + +
        +
        + {{ entry.date | amDateFormat:'dddd, MMMM Do YYYY' }} +
        +
        +
        +
        +
        + {{ entry.tag_name }} + + + was created pointing to image + + + was deleted + + + was moved to image + + from image + + + +
        +
        {{ entry.time | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}
        +
        +
        +
        +
        +
        + + +
        @@ -23,7 +89,12 @@ Visualize -
        - - + - + + @@ -98,4 +183,7 @@
        \ No newline at end of file + action-handler="tagActionHandler"> + +
        +
        \ No newline at end of file diff --git a/static/directives/repository-events-table.html b/static/directives/repository-events-table.html index 26eb6e951..6dd328d61 100644 --- a/static/directives/repository-events-table.html +++ b/static/directives/repository-events-table.html @@ -14,7 +14,7 @@ error-message="'Could not load repository events'">
        -
        No notification have been setup for this repository.
        +
        No notifications have been setup for this repository.
        Click the "Create Notification" button above to add a new notification for a repository event.
        @@ -72,4 +72,4 @@ repository="repository" counter="showNewNotificationCounter" notification-created="handleNotificationCreated(notification)">
        - \ No newline at end of file + diff --git a/static/directives/repository-permissions-table.html b/static/directives/repository-permissions-table.html index 22f3e01b3..79e483f7e 100644 --- a/static/directives/repository-permissions-table.html +++ b/static/directives/repository-permissions-table.html @@ -2,7 +2,7 @@
        -
        + Build ID + Triggered By + Date Started + Tags {{ build.started | amCalendar }} - {{ tag }} + {{ tag }}
        Tag + Last Modified + Size + colspan="{{ imageTracks.length + 1 }}" + style="min-width: 120px;"> Image
        {{ tag.name }} {{ tag.name }} Unknown {{ tag.size | bytes }} - - {{ tag.image_id.substr(0, 12) }} - + + + + + + View Tag History + Delete Tag + + Add New Tag +
        +
        @@ -11,11 +11,27 @@ + + + + - + + + + - - + + + + + + + + + + + + + + + +
        Account Name
        +
        +
        No permissions found.
        +
        + To add a permission, enter the information below and click "Add Permission". +
        +
        +
        Team Permissions
        + entity="buildEntityForPermission(permission, 'team')" + avatar-size="24"> @@ -32,26 +48,66 @@
        +
        User Permissions
        + entity="buildEntityForPermission(permission, 'user')" + avatar-size="24"> +
        - +
        - + Delete Permission
        Robot Account Permissions
        + + + +
        + +
        +
        + + + Delete Permission + + +
        -
        \ No newline at end of file + diff --git a/static/directives/robots-manager.html b/static/directives/robots-manager.html index b4bc89cab..03627b49c 100644 --- a/static/directives/robots-manager.html +++ b/static/directives/robots-manager.html @@ -1,32 +1,130 @@
        -
        -
        Robot accounts allow for delegating access in multiple repositories to role-based accounts that you manage
        +
        -
        - - Create Robot Account - +
        +
        + + Create Robot Account + +
        +

        Robot Accounts

        - +
        + Robot Accounts are named tokens that can be granted permissions on multiple repositories + under this organizationuser namespace. They are typically used in environments where credentials will + be shared, such as deployment systems. +
        + + + + + +
        +
        No robot accounts defined.
        +
        + Click the "Create Robot Account" button above to create a robot account. +
        +
        + +
        +
        No robot accounts found matching filter.
        +
        + Please change your filter to display robot accounts. +
        +
        + +
        - - + + + + + - - - - + + + + + + + + + + + +
        Robot Account NameRobot Account NameTeamsRepository Permissions
        - - - {{ getPrefix(robotInfo.name) }}+{{ getShortenedName(robotInfo.name) }} - - - -
        + + + + + + + {{ getPrefix(robotInfo.name) }}+{{ getShortenedName(robotInfo.name) }} + + + + (Not a member of any team) + + + + + + + + + + + (No permissions on any repositories) + + + + Permissions on + {{ robotInfo.repositories.length }} + repository + repositories + + + + + + View Credentials + + + Delete Robot {{ robotInfo.name }} + + +
        + +
        + + + + + + + + + + +
        RepositoryPermission
        + + {{ getPrefix(robotInfo.name) }}/{{ permission.repository.name }} + +
        + +
        +
        +
        +
        diff --git a/static/directives/role-group.html b/static/directives/role-group.html index f3b53ad43..8cf0f6ba2 100644 --- a/static/directives/role-group.html +++ b/static/directives/role-group.html @@ -1,5 +1,6 @@
        + ng-class="(currentRole == role.id) ? ('active btn-' + role.kind) : 'btn-default'" + ng-disabled="readOnly">{{ role.title }}
        diff --git a/static/directives/source-commit-link.html b/static/directives/source-commit-link.html index d8803b319..5bd3b187a 100644 --- a/static/directives/source-commit-link.html +++ b/static/directives/source-commit-link.html @@ -1,4 +1,4 @@ {{ commitSha.substring(0, 8) }} - \ No newline at end of file + diff --git a/static/directives/source-ref-link.html b/static/directives/source-ref-link.html index dcf142001..986216aa8 100644 --- a/static/directives/source-ref-link.html +++ b/static/directives/source-ref-link.html @@ -12,4 +12,4 @@ {{ getTitle(ref) }} - \ No newline at end of file + diff --git a/static/directives/tag-operations-dialog.html b/static/directives/tag-operations-dialog.html index d47d4651c..2c5ebd616 100644 --- a/static/directives/tag-operations-dialog.html +++ b/static/directives/tag-operations-dialog.html @@ -34,7 +34,7 @@ image-cutoff="toTagImage" style="margin: 10px; margin-top: 20px; margin-bottom: -10px;" ng-show="isAnotherImageTag(toTagImage, tagToCreate)"> - This will also delete any unattach images and delete the following images: + This will also delete any unattached images and delete the following images:
        @@ -84,4 +84,4 @@ The following images and any other images not referenced by a tag will be deleted: - \ No newline at end of file + diff --git a/static/directives/teams-manager.html b/static/directives/teams-manager.html new file mode 100644 index 000000000..840cf6dc4 --- /dev/null +++ b/static/directives/teams-manager.html @@ -0,0 +1,65 @@ +
        +
        + + Create New Team + +

        Teams

        +
        + + + +
        +
        +
        +
        + + + {{ team.name }} + + + {{ team.name }} + +
        + +
        + +
        +
        + + + + + + + + + + {{ members[team.name].members.length - 20 }} more team members. + (Empty Team) +
        +
        + +
        + + + + + Delete Team {{ team.name }} + + +
        +
        +
        +
        diff --git a/static/directives/tour-content.html b/static/directives/tour-content.html index 891d2ee6b..3aafce9a4 100644 --- a/static/directives/tour-content.html +++ b/static/directives/tour-content.html @@ -24,7 +24,7 @@
        -
        Useful views of respositories
        +
        Useful views of repositories
        Each repository is presented with the maximum amount of useful information, including its image history, markdown-based description, and tags.
        @@ -307,7 +307,7 @@

        Deployment Made Easy

        -

        Trigger container builds when your code is checked into Github and passes tests. Automatically pushed into your repository for immediate access by your servers.

        +

        Trigger container builds when your code is checked into GitHub and passes tests. Automatically pushed into your repository for immediate access by your servers.

        diff --git a/static/directives/triggered-build-description.html b/static/directives/triggered-build-description.html index d191d57e6..b4e6eec3a 100644 --- a/static/directives/triggered-build-description.html +++ b/static/directives/triggered-build-description.html @@ -1,10 +1,10 @@
        - + (Manually Triggered Build) - + {{ build.job_config.manual_user }} @@ -12,7 +12,7 @@ - +
        @@ -42,7 +42,7 @@ - + Triggered by commit - + Triggered by commit to - + {{ build.trigger.config.build_source }} diff --git a/static/img/docker.png b/static/img/docker.png new file mode 100644 index 000000000..ee01a5ee8 Binary files /dev/null and b/static/img/docker.png differ diff --git a/static/img/rocket.png b/static/img/rocket.png new file mode 100644 index 000000000..e42c08141 Binary files /dev/null and b/static/img/rocket.png differ diff --git a/static/js/app.js b/static/js/app.js index bc721d769..1d4486876 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -35,9 +35,9 @@ quayPages.constant('pages', { } }); -quayDependencies = ['ngRoute', 'chieffancypants.loadingBar', 'angular-tour', 'restangular', 'angularMoment', - 'mgcrea.ngStrap', 'ngCookies', 'ngSanitize', 'angular-md5', 'pasvaz.bindonce', 'ansiToHtml', - 'ngAnimate', 'core-ui', 'core-config-setup', 'quayPages']; +quayDependencies = ['ngRoute', 'chieffancypants.loadingBar', 'cfp.hotkeys', 'angular-tour', 'restangular', 'angularMoment', + 'mgcrea.ngStrap', 'ngCookies', 'ngSanitize', 'angular-md5', 'pasvaz.bindonce', 'ansiToHtml', 'debounce', + 'core-ui', 'core-config-setup', 'quayPages']; if (window.__config && window.__config.MIXPANEL_KEY) { quayDependencies.push('angulartics'); @@ -126,7 +126,10 @@ quayApp.config(['$routeProvider', '$locationProvider', 'pages', function($routeP // Organization View Application .route('/organization/:orgname/application/:clientid', 'manage-application') - // User Admin + // View User + .route('/user/:username', 'user-view') + + // DEPRECATED: User Admin .route('/user/', 'user-admin') // Sign In @@ -333,11 +336,14 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi }); var activeTab = $location.search()['tab']; + var checkTabs = function() { + var tabs = $('a[data-toggle="tab"]'); + if (tabs.length == 0) { + $timeout(checkTabs, 50); + return; + } - // Setup deep linking of tabs. This will change the search field of the URL whenever a tab - // is changed in the UI. - $timeout(function() { - $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { + tabs.on('shown.bs.tab', function (e) { var tabName = e.target.getAttribute('data-target').substr(1); $rootScope.$apply(function() { var isDefaultTab = $('a[data-toggle="tab"]')[0] == e.target; @@ -357,7 +363,11 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi if (activeTab) { changeTab(activeTab); } - }, 400); // 400ms to make sure angular has rendered. + }; + + // Setup deep linking of tabs. This will change the search field of the URL whenever a tab + // is changed in the UI. + $timeout(checkTabs, 50); }); var initallyChecked = false; diff --git a/static/js/directives/focusable-popover-content.js b/static/js/directives/focusable-popover-content.js index 98e8a63e6..24324d19c 100644 --- a/static/js/directives/focusable-popover-content.js +++ b/static/js/directives/focusable-popover-content.js @@ -12,7 +12,7 @@ angular.module('quay').directive('focusablePopoverContent', ['$timeout', '$popov if (!scope) { return; } scope.$apply(function() { - if (!scope || !$scope.$hide) { return; } + if (!scope || !scope.$hide) { return; } scope.$hide(); }); }; diff --git a/static/js/directives/ng-image-watch.js b/static/js/directives/ng-image-watch.js new file mode 100644 index 000000000..7b6cde7b7 --- /dev/null +++ b/static/js/directives/ng-image-watch.js @@ -0,0 +1,20 @@ +/** + * Adds a ng-image-watch attribute, which is a callback invoked when the image is loaded or fails. + */ +angular.module('quay').directive('ngImageWatch', function ($parse) { + return { + restrict: 'A', + compile: function($element, attr) { + var fn = $parse(attr['ngImageWatch']); + return function(scope, element) { + element.bind('error', function() { + fn(scope, {result: false}); + }); + + element.bind('load', function() { + fn(scope, {result: true}); + }); + } + } + }; +}); \ No newline at end of file diff --git a/static/js/directives/quay-layout.js b/static/js/directives/quay-layout.js index 58db5b450..96053510a 100644 --- a/static/js/directives/quay-layout.js +++ b/static/js/directives/quay-layout.js @@ -120,6 +120,8 @@ angular.module('quay').directive('quayClasses', function(Features, Config) { /** * Adds a quay-include attribtue that adds a template solely if the expression evaluates to true. * Automatically adds the Features and Config services to the scope. + * + Usage: quay-include="{'Features.BILLING': 'partials/landing-normal.html', '!Features.BILLING': 'partials/landing-login.html'}" */ angular.module('quay').directive('quayInclude', function($compile, $templateCache, $http, Features, Config) { return { @@ -127,7 +129,7 @@ angular.module('quay').directive('quayInclude', function($compile, $templateCach restrict: 'A', link: function($scope, $element, $attr, ctrl) { var getTemplate = function(templateName) { - var templateUrl = '/static/partials/' + templateName; + var templateUrl = '/static/' + templateName; return $http.get(templateUrl, {cache: $templateCache}); }; diff --git a/static/js/directives/repo-view/repo-panel-builds.js b/static/js/directives/repo-view/repo-panel-builds.js index db5578d48..f87cb3e72 100644 --- a/static/js/directives/repo-view/repo-panel-builds.js +++ b/static/js/directives/repo-view/repo-panel-builds.js @@ -12,11 +12,13 @@ angular.module('quay').directive('repoPanelBuilds', function () { 'repository': '=repository', 'builds': '=builds' }, - controller: function($scope, $element, $filter, $routeParams, ApiService, TriggerService) { + controller: function($scope, $element, $filter, $routeParams, ApiService, TriggerService, UserService) { var orderBy = $filter('orderBy'); $scope.TriggerService = TriggerService; + UserService.updateUserIn($scope); + $scope.options = { 'filter': 'recent', 'reverse': false, @@ -66,18 +68,22 @@ angular.module('quay').directive('repoPanelBuilds', function () { if ($scope.buildsResource && filter == $scope.currentFilter) { return; } var since = null; + var limit = 10; if ($scope.options.filter == '48hour') { since = Math.floor(moment().subtract(2, 'days').valueOf() / 1000); + limit = 100; } else if ($scope.options.filter == '30day') { since = Math.floor(moment().subtract(30, 'days').valueOf() / 1000); + limit = 100; } else { since = null; + limit = 10; } var params = { 'repository': $scope.repository.namespace + '/' + $scope.repository.name, - 'limit': 100, + 'limit': limit, 'since': since }; @@ -175,6 +181,12 @@ angular.module('quay').directive('repoPanelBuilds', function () { }; $scope.askRunTrigger = function(trigger) { + if ($scope.user.username != trigger.connected_user) { + bootbox.alert('For security reasons, only user "' + trigger.connected_user + + '" can manually invoke this trigger'); + return; + } + $scope.currentStartTrigger = trigger; $scope.showTriggerStartDialogCounter++; }; diff --git a/static/js/directives/repo-view/repo-panel-info.js b/static/js/directives/repo-view/repo-panel-info.js index 5946bac73..2902699ec 100644 --- a/static/js/directives/repo-view/repo-panel-info.js +++ b/static/js/directives/repo-view/repo-panel-info.js @@ -12,7 +12,16 @@ angular.module('quay').directive('repoPanelInfo', function () { 'repository': '=repository', 'builds': '=builds' }, - controller: function($scope, $element, ApiService) { + controller: function($scope, $element, ApiService, Config) { + $scope.$watch('repository', function(repository) { + if (!$scope.repository) { return; } + + var namespace = $scope.repository.namespace; + var name = $scope.repository.name; + + $scope.pullCommand = 'docker pull ' + Config.getDomain() + '/' + namespace + '/' + name; + }); + $scope.updateDescription = function(content) { $scope.repository.description = content; $scope.repository.put(); diff --git a/static/js/directives/repo-view/repo-panel-tags.js b/static/js/directives/repo-view/repo-panel-tags.js index c96098b8d..283ce2dde 100644 --- a/static/js/directives/repo-view/repo-panel-tags.js +++ b/static/js/directives/repo-view/repo-panel-tags.js @@ -25,6 +25,7 @@ angular.module('quay').directive('repoPanelTags', function () { $scope.iterationState = {}; $scope.tagActionHandler = null; + $scope.showingHistory = false; var setTagState = function() { if (!$scope.repository || !$scope.selectedTags) { return; } @@ -118,8 +119,142 @@ angular.module('quay').directive('repoPanelTags', function () { // Process each of the tags. setTagState(); + + if ($scope.showingHistory) { + loadTimeline(); + } }); + var loadTimeline = function() { + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name + }; + + ApiService.listRepoTags(null, params).then(function(resp) { + var tagData = []; + var currentTags = {}; + + resp.tags.forEach(function(tag) { + var tagName = tag.name; + var imageId = tag.docker_image_id; + var oldImageId = null; + + if (tag.end_ts) { + var action = 'delete'; + + // If the end time matches the existing start time for this tag, then this is a move + // instead of a delete. + var currentTime = tag.end_ts * 1000; + if (currentTags[tagName] && currentTags[tagName].start_ts == tag.end_ts) { + action = 'move'; + + // Remove the create. + var index = tagData.indexOf(currentTags[tagName]); + var createEntry = tagData.splice(index, 1)[0]; + + imageId = createEntry.docker_image_id; + oldImageId = tag.docker_image_id; + } + + // Add the delete/move. + tagData.push({ + 'tag_name': tagName, + 'action': action, + 'start_ts': tag.start_ts, + 'end_ts': tag.end_ts, + 'time': currentTime, + 'docker_image_id': imageId, + 'old_docker_image_id': oldImageId + }) + } + + if (tag.start_ts) { + var currentTime = tag.start_ts * 1000; + var create = { + 'tag_name': tagName, + 'action': 'create', + 'start_ts': tag.start_ts, + 'end_ts': tag.end_ts, + 'time': currentTime, + 'docker_image_id': tag.docker_image_id, + 'old_docker_image_id': '' + }; + + tagData.push(create); + currentTags[tagName] = create; + } + }); + + tagData.sort(function(a, b) { + return b.time - a.time; + }); + + for (var i = tagData.length - 1; i >= 1; --i) { + var current = tagData[i]; + var next = tagData[i - 1]; + + if (new Date(current.time).getDate() != new Date(next.time).getDate()) { + tagData.splice(i - 1, 0, { + 'date_break': true, + 'date': new Date(current.time) + }); + i--; + } + } + + if (tagData.length > 0) { + tagData.splice(0, 0, { + 'date_break': true, + 'date': new Date(tagData[0].time) + }); + } + + $scope.tagHistoryData = tagData; + }); + }; + + $scope.getEntryClasses = function(entry, historyFilter) { + var classes = entry.action + ' '; + if (!historyFilter || !entry.action) { + return classes; + } + + var parts = (historyFilter || '').split(','); + var isMatch = parts.some(function(part) { + if (part && entry.tag_name) { + isMatch = entry.tag_name.indexOf(part) >= 0; + isMatch = isMatch || entry.docker_image_id.indexOf(part) >= 0; + isMatch = isMatch || entry.old_docker_image_id.indexOf(part) >= 0; + return isMatch; + } + }); + + classes += isMatch ? 'filtered-match' : 'filtered-mismatch'; + return classes; + }; + + $scope.showHistory = function(value, opt_tagname) { + if (opt_tagname) { + $scope.options.historyFilter = opt_tagname; + } else { + $scope.options.historyFilter = ''; + } + + if ($scope.showingHistory == value) { + return; + } + + $scope.showingHistory = value; + + if ($scope.showingHistory) { + loadTimeline(); + } + }; + + $scope.toggleHistory = function() { + $scope.showHistory(!$scope.showingHistory); + }; + $scope.trackLineClass = function(index, track_info) { var startIndex = $.inArray(track_info.tags[0], $scope.tags); var endIndex = $.inArray(track_info.tags[track_info.tags.length - 1], $scope.tags); @@ -166,6 +301,10 @@ angular.module('quay').directive('repoPanelTags', function () { $scope.tagActionHandler.askDeleteMultipleTags(tags); }; + $scope.askAddTag = function(tag) { + $scope.tagActionHandler.askAddTag(tag.image_id); + }; + $scope.orderBy = function(predicate) { if (predicate == $scope.options.predicate) { $scope.options.reverse = !$scope.options.reverse; @@ -202,6 +341,22 @@ angular.module('quay').directive('repoPanelTags', function () { return $scope.imageIDFilter(it.image_id, tag); }); }; + + $scope.getTagNames = function(checked) { + var names = checked.map(function(tag) { + return tag.name; + }); + + return names.join(','); + }; + + $scope.isChecked = function(tagName, checked) { + return checked.some(function(tag) { + if (tag.name == tagName) { + return true; + } + }); + }; } }; return directiveDefinitionObject; diff --git a/static/js/directives/ui/anchor.js b/static/js/directives/ui/anchor.js new file mode 100644 index 000000000..ec10e082d --- /dev/null +++ b/static/js/directives/ui/anchor.js @@ -0,0 +1,19 @@ +/** + * An element which displays its contents wrapped in an tag, but only if the href is not null. + */ +angular.module('quay').directive('anchor', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/anchor.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'href': '@href', + 'isOnlyText': '=isOnlyText' + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/authorized-apps-manager.js b/static/js/directives/ui/authorized-apps-manager.js new file mode 100644 index 000000000..9f5bb944d --- /dev/null +++ b/static/js/directives/ui/authorized-apps-manager.js @@ -0,0 +1,41 @@ +/** + * Element for managing the applications authorized by a user. + */ +angular.module('quay').directive('authorizedAppsManager', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/authorized-apps-manager.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'user': '=user', + 'isEnabled': '=isEnabled' + }, + controller: function($scope, $element, ApiService) { + $scope.$watch('isEnabled', function(enabled) { + if (!enabled) { return; } + loadAuthedApps(); + }); + + var loadAuthedApps = function() { + if ($scope.authorizedAppsResource) { return; } + + $scope.authorizedAppsResource = ApiService.listUserAuthorizationsAsResource().get(function(resp) { + $scope.authorizedApps = resp['authorizations']; + }); + }; + + $scope.deleteAccess = function(accessTokenInfo) { + var params = { + 'access_token_uuid': accessTokenInfo['uuid'] + }; + + ApiService.deleteUserAuthorization(null, params).then(function(resp) { + $scope.authorizedApps.splice($scope.authorizedApps.indexOf(accessTokenInfo), 1); + }, ApiService.errorDisplay('Could not revoke authorization')); + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/avatar.js b/static/js/directives/ui/avatar.js index e2e9b339f..80a74f8d5 100644 --- a/static/js/directives/ui/avatar.js +++ b/static/js/directives/ui/avatar.js @@ -1,5 +1,5 @@ /** - * An element which displays an avatar for the given {email,name} or hash. + * An element which displays an avatar for the given avatar data. */ angular.module('quay').directive('avatar', function () { var directiveDefinitionObject = { @@ -9,25 +9,38 @@ angular.module('quay').directive('avatar', function () { transclude: true, restrict: 'C', scope: { - 'hash': '=hash', - 'email': '=email', - 'name': '=name', + 'data': '=data', 'size': '=size' }, - controller: function($scope, $element, AvatarService) { + controller: function($scope, $element, AvatarService, Config, UIService, $timeout) { $scope.AvatarService = AvatarService; + $scope.Config = Config; + $scope.isLoading = true; + $scope.hasGravatar = false; + $scope.loadGravatar = false; - var refreshHash = function() { - if (!$scope.name && !$scope.email) { return; } - $scope._hash = AvatarService.computeHash($scope.email, $scope.name); + $scope.imageCallback = function(r) { + $timeout(function() { + $scope.isLoading = false; + $scope.hasGravatar = r; + }, 1); }; - $scope.$watch('hash', function(hash) { - $scope._hash = hash; + $scope.$watch('size', function(size) { + size = size * 1 || 16; + $scope.fontSize = (size - 4) + 'px'; + $scope.lineHeight = size + 'px'; }); - $scope.$watch('name', refreshHash); - $scope.$watch('email', refreshHash); + $scope.$watch('data', function(data) { + if (!data) { return; } + + $scope.loadGravatar = Config.AVATAR_KIND == 'gravatar' && + (data.kind == 'user' || data.kind == 'org'); + + $scope.isLoading = $scope.loadGravatar; + $scope.hasGravatar = false; + }); } }; return directiveDefinitionObject; diff --git a/static/js/directives/ui/billing-invoice.js b/static/js/directives/ui/billing-invoices.js similarity index 88% rename from static/js/directives/ui/billing-invoice.js rename to static/js/directives/ui/billing-invoices.js index f7ff123f7..ad6696621 100644 --- a/static/js/directives/ui/billing-invoice.js +++ b/static/js/directives/ui/billing-invoices.js @@ -15,11 +15,6 @@ angular.module('quay').directive('billingInvoices', function () { }, controller: function($scope, $element, $sce, ApiService) { $scope.loading = false; - $scope.invoiceExpanded = {}; - - $scope.toggleInvoice = function(id) { - $scope.invoiceExpanded[id] = !$scope.invoiceExpanded[id]; - }; var update = function() { var hasValidUser = !!$scope.user; @@ -35,6 +30,9 @@ angular.module('quay').directive('billingInvoices', function () { ApiService.listInvoices($scope.organization).then(function(resp) { $scope.invoices = resp.invoices; $scope.loading = false; + }, function() { + $scope.invoices = []; + $scope.loading = false; }); }; diff --git a/static/js/directives/ui/build-mini-status.js b/static/js/directives/ui/build-mini-status.js index 70c026e8b..a6698bd74 100644 --- a/static/js/directives/ui/build-mini-status.js +++ b/static/js/directives/ui/build-mini-status.js @@ -9,7 +9,8 @@ angular.module('quay').directive('buildMiniStatus', function () { transclude: false, restrict: 'C', scope: { - 'build': '=build' + 'build': '=build', + 'isAdmin': '=isAdmin' }, controller: function($scope, $element) { $scope.isBuilding = function(build) { diff --git a/static/js/directives/ui/convert-user-to-org.js b/static/js/directives/ui/convert-user-to-org.js new file mode 100644 index 000000000..462cbec0e --- /dev/null +++ b/static/js/directives/ui/convert-user-to-org.js @@ -0,0 +1,62 @@ +/** + * Displays a panel for converting the current user to an organization. + */ +angular.module('quay').directive('convertUserToOrg', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/convert-user-to-org.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'user': '=user' + }, + controller: function($scope, $element, Features, PlanService, Config) { + $scope.convertStep = 0; + + $scope.showConvertForm = function() { + if (Features.BILLING) { + PlanService.getMatchingBusinessPlan(function(plan) { + $scope.org.plan = plan; + }); + + PlanService.getPlans(function(plans) { + $scope.orgPlans = plans; + }); + } + + $scope.convertStep = 1; + }; + + $scope.convertToOrg = function() { + $('#reallyconvertModal').modal({}); + }; + + $scope.reallyConvert = function() { + if (Config.AUTHENTICATION_TYPE != 'Database') { return; } + + $scope.loading = true; + + var data = { + 'adminUser': $scope.org.adminUser, + 'adminPassword': $scope.org.adminPassword, + 'plan': $scope.org.plan ? $scope.org.plan.stripeId : '' + }; + + ApiService.convertUserToOrganization(data).then(function(resp) { + CookieService.putPermanent('quay.namespace', $scope.cuser.username); + UserService.load(); + $location.path('/'); + }, function(resp) { + $scope.loading = false; + if (resp.data.reason == 'invaliduser') { + $('#invalidadminModal').modal({}); + } else { + $('#cannotconvertModal').modal({}); + } + }); + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/entity-reference.js b/static/js/directives/ui/entity-reference.js index 41b280304..403ddcd9a 100644 --- a/static/js/directives/ui/entity-reference.js +++ b/static/js/directives/ui/entity-reference.js @@ -39,6 +39,21 @@ angular.module('quay').directive('entityReference', function () { return '/organization/' + org['name'] + '/admin?tab=robots&showRobot=' + UtilService.textToSafeHtml(name); }; + $scope.getTitle = function(entity) { + if (!entity) { return ''; } + + switch (entity.kind) { + case 'org': + return 'Organization'; + + case 'team': + return 'Team'; + + case 'user': + return entity.is_robot ? 'Robot Account' : 'User'; + } + }; + $scope.getPrefix = function(name) { if (!name) { return ''; } var plus = name.indexOf('+'); diff --git a/static/js/directives/ui/entity-search.js b/static/js/directives/ui/entity-search.js index eb7313509..38a68bb1e 100644 --- a/static/js/directives/ui/entity-search.js +++ b/static/js/directives/ui/entity-search.js @@ -56,6 +56,8 @@ angular.module('quay').directive('entitySearch', function () { $scope.currentEntityInternal = $scope.currentEntity; + $scope.Config = Config; + var isSupported = function(kind, opt_array) { return $.inArray(kind, opt_array || $scope.allowedEntities || ['user', 'team', 'robot']) >= 0; }; @@ -90,48 +92,25 @@ angular.module('quay').directive('entitySearch', function () { }; $scope.createTeam = function() { - if (!$scope.isAdmin) { return; } - - bootbox.prompt('Enter the name of the new team', function(teamname) { - if (!teamname) { return; } - - var regex = new RegExp(TEAM_PATTERN); - if (!regex.test(teamname)) { - bootbox.alert('Invalid team name'); - return; - } - - CreateService.createOrganizationTeam(ApiService, $scope.namespace, teamname, function(created) { - $scope.setEntity(created.name, 'team', false); - $scope.teams[teamname] = created; - }); + CreateService.askCreateTeam($scope.namespace, function(created) { + $scope.setEntity(created.name, 'team', false, created.avatar); + $scope.teams[teamname] = created; }); }; $scope.createRobot = function() { - if (!$scope.isAdmin) { return; } - - bootbox.prompt('Enter the name of the new robot account', function(robotname) { - if (!robotname) { return; } - - var regex = new RegExp(ROBOT_PATTERN); - if (!regex.test(robotname)) { - bootbox.alert('Invalid robot account name'); - return; - } - - CreateService.createRobotAccount(ApiService, $scope.isOrganization, $scope.namespace, robotname, function(created) { - $scope.setEntity(created.name, 'user', true); - $scope.robots.push(created); - }); + CreateService.askCreateRobot($scope.namespace, function(created) { + $scope.setEntity(created.name, 'user', true, created.avatar); + $scope.robots.push(created); }); }; - $scope.setEntity = function(name, kind, is_robot) { + $scope.setEntity = function(name, kind, is_robot, avatar) { var entity = { 'name': name, 'kind': kind, - 'is_robot': is_robot + 'is_robot': is_robot, + 'avatar': avatar }; if ($scope.isOrganization) { diff --git a/static/js/directives/ui/external-login-button.js b/static/js/directives/ui/external-login-button.js index c6733bb38..7d7602f85 100644 --- a/static/js/directives/ui/external-login-button.js +++ b/static/js/directives/ui/external-login-button.js @@ -11,6 +11,7 @@ angular.module('quay').directive('externalLoginButton', function () { scope: { 'signInStarted': '&signInStarted', 'redirectUrl': '=redirectUrl', + 'isLink': '=isLink', 'provider': '@provider', 'action': '@action' }, diff --git a/static/js/directives/ui/external-logins-manager.js b/static/js/directives/ui/external-logins-manager.js new file mode 100644 index 000000000..4dba38482 --- /dev/null +++ b/static/js/directives/ui/external-logins-manager.js @@ -0,0 +1,55 @@ +/** + * Element for managing the applications authorized by a user. + */ +angular.module('quay').directive('externalLoginsManager', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/external-logins-manager.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'user': '=user', + }, + controller: function($scope, $element, ApiService, UserService, Features, Config, KeyService) { + $scope.Features = Features; + $scope.Config = Config; + $scope.KeyService = KeyService; + + UserService.updateUserIn($scope, function(user) { + $scope.cuser = jQuery.extend({}, user); + + if ($scope.cuser.logins) { + for (var i = 0; i < $scope.cuser.logins.length; i++) { + var login = $scope.cuser.logins[i]; + login.metadata = login.metadata || {}; + + if (login.service == 'github') { + $scope.hasGithubLogin = true; + $scope.githubLogin = login.metadata['service_username']; + $scope.githubEndpoint = KeyService['githubEndpoint']; + } + + if (login.service == 'google') { + $scope.hasGoogleLogin = true; + $scope.googleLogin = login.metadata['service_username']; + } + } + } + }); + + $scope.detachExternalLogin = function(kind) { + var params = { + 'servicename': kind + }; + + ApiService.detachExternalLogin(null, params).then(function() { + $scope.hasGithubLogin = false; + $scope.hasGoogleLogin = false; + UserService.load(); + }, ApiService.errorDisplay('Count not detach service')); + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/fetch-tag-dialog.js b/static/js/directives/ui/fetch-tag-dialog.js new file mode 100644 index 000000000..57186f4d9 --- /dev/null +++ b/static/js/directives/ui/fetch-tag-dialog.js @@ -0,0 +1,114 @@ +/** + * An element which adds a of dialog for fetching a tag. + */ +angular.module('quay').directive('fetchTagDialog', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/fetch-tag-dialog.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'repository': '=repository', + 'actionHandler': '=actionHandler' + }, + controller: function($scope, $element, $timeout, ApiService, UserService, Config) { + $scope.clearCounter = 0; + $scope.currentFormat = null; + $scope.currentEntity = null; + $scope.currentRobot = null; + $scope.formats = []; + + UserService.updateUserIn($scope, updateFormats); + + var updateFormats = function() { + $scope.formats = []; + + if ($scope.repository && UserService.isNamespaceAdmin($scope.repository.namespace)) { + $scope.formats.push({ + 'title': 'Squashed Docker Image', + 'icon': 'ci-squashed', + 'command': 'curl -L -f {http}://{pull_user}:{pull_password}@{hostname}/c1/squash/{namespace}/{name}/{tag} | docker load', + 'require_creds': true + }); + } + + $scope.formats.push({ + 'title': 'Basic Docker Pull', + 'icon': 'docker-icon', + 'command': 'docker pull {hostname}/{namespace}/{name}:{tag}' + }); + }; + + $scope.$watch('currentEntity', function(entity) { + if (!entity) { + $scope.currentRobot = null; + return; + } + + if ($scope.currentRobot && $scope.currentRobot.name == entity.name) { + return; + } + + $scope.currentRobot = null; + + var parts = entity.name.split('+'); + var namespace = parts[0]; + var shortname = parts[1]; + + var params = { + 'robot_shortname': shortname + }; + + var orgname = UserService.isOrganization(namespace) ? namespace : ''; + ApiService.getRobot(orgname, null, params).then(function(resp) { + $scope.currentRobot = resp; + }, ApiService.errorDisplay('Cannot download robot token')); + }); + + $scope.getCommand = function(format, robot) { + if (!format || !format.command) { return ''; } + if (format.require_creds && !robot) { return ''; } + + var params = { + 'pull_user': robot ? robot.name : '', + 'pull_password': robot ? robot.token : '', + 'hostname': Config.getDomain(), + 'http': Config.getHttp(), + 'namespace': $scope.repository.namespace, + 'name': $scope.repository.name, + 'tag': $scope.currentTag.name + }; + + var value = format.command; + for (var param in params) { + if (!params.hasOwnProperty(param)) { continue; } + value = value.replace('{' + param + '}', params[param]); + } + + return value; + }; + + $scope.setFormat = function(format) { + $scope.currentFormat = format; + }; + + $scope.actionHandler = { + 'askFetchTag': function(tag) { + $scope.currentTag = tag; + $scope.currentFormat = null; + $scope.currentEntity = null; + $scope.currentRobot = null; + + $scope.clearCounter++; + + updateFormats(); + + $element.find('#copyClipboard').clipboardCopy(); + $element.find('#fetchTagDialog').modal({}); + } + }; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/header-bar.js b/static/js/directives/ui/header-bar.js index e370b9432..e54cc476f 100644 --- a/static/js/directives/ui/header-bar.js +++ b/static/js/directives/ui/header-bar.js @@ -12,12 +12,81 @@ angular.module('quay').directive('headerBar', function () { restrict: 'C', scope: { }, - controller: function($scope, $element, $location, UserService, PlanService, ApiService, NotificationService, Config) { + controller: function($rootScope, $scope, $element, $location, $timeout, hotkeys, UserService, PlanService, ApiService, NotificationService, Config, CreateService) { + $scope.isNewLayout = Config.isNewLayout(); + + if ($scope.isNewLayout) { + // Register hotkeys: + hotkeys.add({ + combo: '/', + description: 'Show search', + callback: function(e) { + e.preventDefault(); + e.stopPropagation(); + $scope.toggleSearch(); + } + }); + + hotkeys.add({ + combo: 'alt+c', + description: 'Create new repository', + callback: function(e) { + e.preventDefault(); + e.stopPropagation(); + $location.url('/new'); + } + }); + } + $scope.notificationService = NotificationService; + $scope.searchVisible = false; + $scope.currentSearchQuery = null; + $scope.searchResultState = null; + $scope.showBuildDialogCounter = 0; // Monitor any user changes and place the current user into the scope. UserService.updateUserIn($scope); + $scope.currentPageContext = {}; + + $rootScope.$watch('currentPage.scope.viewuser', function(u) { + $scope.currentPageContext['viewuser'] = u; + }); + + $rootScope.$watch('currentPage.scope.organization', function(o) { + $scope.currentPageContext['organization'] = o; + }); + + $rootScope.$watch('currentPage.scope.repository', function(r) { + $scope.currentPageContext['repository'] = r; + }); + + var conductSearch = function(query) { + if (!query) { $scope.searchResultState = null; return; } + + $scope.searchResultState = { + 'state': 'loading' + }; + + var params = { + 'query': query + }; + + ApiService.conductSearch(null, params).then(function(resp) { + if (!$scope.searchVisible || query != $scope.currentSearchQuery) { return; } + + $scope.searchResultState = { + 'state': resp.results.length ? 'results' : 'no-results', + 'results': resp.results, + 'current': resp.results.length ? 0 : -1 + }; + }, function(resp) { + $scope.searchResultState = null; + }, /* background */ true); + }; + + $scope.$watch('currentSearchQuery', conductSearch); + $scope.signout = function() { ApiService.logout().then(function() { UserService.load(); @@ -39,6 +108,126 @@ angular.module('quay').directive('headerBar', function () { return Config.ENTERPRISE_LOGO_URL; }; + + $scope.toggleSearch = function() { + $scope.searchVisible = !$scope.searchVisible; + if ($scope.searchVisible) { + $('#search-box-input').focus(); + if ($scope.currentSearchQuery) { + conductSearch($scope.currentSearchQuery); + } + } else { + $('#search-box-input').blur() + $scope.searchResultState = null; + } + }; + + $scope.getSearchBoxClasses = function(searchVisible, searchResultState) { + var classes = searchVisible ? 'search-visible ' : ''; + if (searchResultState) { + classes += 'results-visible'; + } + return classes; + }; + + $scope.handleSearchKeyDown = function(e) { + if (e.keyCode == 27) { + $scope.toggleSearch(); + return; + } + + var state = $scope.searchResultState; + if (!state || !state['results']) { return; } + + if (e.keyCode == 40) { + state['current']++; + e.preventDefault(); + } else if (e.keyCode == 38) { + state['current']--; + e.preventDefault(); + } else if (e.keyCode == 13) { + var current = state['current']; + if (current >= 0 && current < state['results'].length) { + $scope.showResult(state['results'][current]); + } + e.preventDefault(); + } + + if (state['current'] < -1) { + state['current'] = state['results'].length - 1; + } else if (state['current'] >= state['results'].length) { + state['current'] = 0; + } + }; + + $scope.showResult = function(result) { + $scope.toggleSearch(); + $timeout(function() { + $scope.currentSearchQuery = ''; + $location.url(result['href']) + }, 500); + }; + + $scope.setCurrentResult = function(result) { + if (!$scope.searchResultState) { return; } + $scope.searchResultState['current'] = result; + }; + + $scope.getNamespace = function(context) { + if (!context) { return null; } + + if (context.repository && context.repository.namespace) { + return context.repository.namespace; + } + + if (context.organization && context.organization.name) { + return context.organization.name; + } + + if (context.viewuser && context.viewuser.username) { + return context.viewuser.username; + } + + return null; + }; + + $scope.canAdmin = function(namespace) { + if (!namespace) { return false; } + return UserService.isNamespaceAdmin(namespace); + }; + + $scope.isOrganization = function(namespace) { + if (!namespace) { return false; } + return UserService.isOrganization(namespace); + }; + + $scope.startBuild = function(context) { + $scope.showBuildDialogCounter++; + }; + + $scope.handleBuildStarted = function(build, context) { + $location.url('/repository/' + context.repository.namespace + '/' + context.repository.name + '/build/' + build.id); + }; + + $scope.createRobot = function(context) { + var namespace = $scope.getNamespace(context); + CreateService.askCreateRobot(function(created) { + if (isorg) { + $location.url('/organization/' + namespace + '?tab=robots'); + } else { + $location.url('/user/' + namespace + '?tab=robots'); + } + }); + }; + + $scope.createTeam = function(context) { + var namespace = $scope.getNamespace(context); + if (!namespace || !UserService.isNamespaceAdmin(namespace)) { return; } + + CreateService.askCreateTeam(function(created) { + $location.url('/organization/' + namespace + '/teams/' + teamname); + }); + }; } }; return directiveDefinitionObject; diff --git a/static/js/directives/ui/image-link.js b/static/js/directives/ui/image-link.js new file mode 100644 index 000000000..0752965c2 --- /dev/null +++ b/static/js/directives/ui/image-link.js @@ -0,0 +1,19 @@ +/** + * An element which displays a link to a repository image. + */ +angular.module('quay').directive('imageLink', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/image-link.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'repository': '=repository', + 'imageId': '=imageId' + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/image-view-layer.js b/static/js/directives/ui/image-view-layer.js new file mode 100644 index 000000000..e5a781172 --- /dev/null +++ b/static/js/directives/ui/image-view-layer.js @@ -0,0 +1,49 @@ +/** + * An element which displays a single layer representing an image in the image view. + */ +angular.module('quay').directive('imageViewLayer', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/image-view-layer.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'repository': '=repository', + 'image': '=image', + 'images': '=images' + }, + controller: function($scope, $element) { + $scope.getDockerfileCommand = function(command) { + if (!command) { return ''; } + + // ["/bin/sh", "-c", "#(nop) RUN foo"] + var commandPrefix = '#(nop)' + + if (command.length != 3) { return ''; } + if (command[0] != '/bin/sh' || command[1] != '-c') { return ''; } + + var cmd = command[2]; + if (cmd.substring(0, commandPrefix.length) != commandPrefix) { + return 'RUN ' + cmd; + } + + return command[2].substr(commandPrefix.length + 1); + }; + + $scope.getClass = function() { + var index = $.inArray($scope.image, $scope.images); + if (index < 0) { + return 'first'; + } + + if (index == $scope.images.length - 1) { + return 'last'; + } + + return ''; + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/repo-list-grid.js b/static/js/directives/ui/repo-list-grid.js index aa54bfda0..a01aec827 100644 --- a/static/js/directives/ui/repo-list-grid.js +++ b/static/js/directives/ui/repo-list-grid.js @@ -11,9 +11,9 @@ angular.module('quay').directive('repoListGrid', function () { scope: { repositoriesResource: '=repositoriesResource', starred: '=starred', - user: "=user", namespace: '=namespace', - starToggled: '&starToggled' + starToggled: '&starToggled', + hideTitle: '=hideTitle' }, controller: function($scope, $element, UserService) { $scope.isOrganization = function(namespace) { diff --git a/static/js/directives/ui/repository-permissions-table.js b/static/js/directives/ui/repository-permissions-table.js index a0dfaddb2..dd7b3417e 100644 --- a/static/js/directives/ui/repository-permissions-table.js +++ b/static/js/directives/ui/repository-permissions-table.js @@ -2,6 +2,21 @@ * An element which displays a table of permissions on a repository and allows them to be * edited. */ +angular.module('quay').filter('objectFilter', function() { + return function(obj, filterFn) { + if (!obj) { return []; } + + var result = []; + angular.forEach(obj, function(value) { + if (filterFn(value)) { + result.push(value); + } + }); + + return result; + }; +}); + angular.module('quay').directive('repositoryPermissionsTable', function () { var directiveDefinitionObject = { priority: 0, @@ -13,6 +28,7 @@ angular.module('quay').directive('repositoryPermissionsTable', function () { 'repository': '=repository' }, controller: function($scope, $element, ApiService, Restangular, UtilService) { + // TODO(jschorr): move this to a service. $scope.roles = [ { 'id': 'read', 'title': 'Read', 'kind': 'success' }, { 'id': 'write', 'title': 'Write', 'kind': 'success' }, @@ -58,21 +74,50 @@ angular.module('quay').directive('repositoryPermissionsTable', function () { return Restangular.one(url); }; - $scope.buildEntityForPermission = function(name, permission, kind) { - var key = name + ':' + kind; + $scope.buildEntityForPermission = function(permission, kind) { + var key = permission.name + ':' + kind; if ($scope.permissionCache[key]) { return $scope.permissionCache[key]; } return $scope.permissionCache[key] = { 'kind': kind, - 'name': name, + 'name': permission.name, 'is_robot': permission.is_robot, - 'is_org_member': permission.is_org_member + 'is_org_member': permission.is_org_member, + 'avatar': permission.avatar }; }; - $scope.addPermission = function() { + $scope.hasPermissions = function(teams, users) { + if (teams && teams.value) { + if (Object.keys(teams.value).length > 0) { + return true; + } + } + + if (users && users.value) { + if (Object.keys(users.value).length > 0) { + return true; + } + } + + return false; + }; + + $scope.allEntries = function() { + return true; + }; + + $scope.onlyRobot = function(permission) { + return permission.is_robot == true; + }; + + $scope.onlyUser = function(permission) { + return !permission.is_robot; + }; + + $scope.addPermission = function() { $scope.addPermissionInfo['working'] = true; $scope.addNewPermission($scope.addPermissionInfo.entity, $scope.addPermissionInfo.role) }; diff --git a/static/js/directives/ui/robots-manager.js b/static/js/directives/ui/robots-manager.js index 8ebf04337..970681ea7 100644 --- a/static/js/directives/ui/robots-manager.js +++ b/static/js/directives/ui/robots-manager.js @@ -12,12 +12,38 @@ angular.module('quay').directive('robotsManager', function () { 'organization': '=organization', 'user': '=user' }, - controller: function($scope, $element, ApiService, $routeParams, CreateService) { + controller: function($scope, $element, ApiService, $routeParams, CreateService, Config) { $scope.ROBOT_PATTERN = ROBOT_PATTERN; + + // TODO(jschorr): move this to a service. + $scope.roles = [ + { 'id': 'read', 'title': 'Read', 'kind': 'success' }, + { 'id': 'write', 'title': 'Write', 'kind': 'success' }, + { 'id': 'admin', 'title': 'Admin', 'kind': 'primary' } + ]; + $scope.robots = null; $scope.loading = false; $scope.shownRobot = null; $scope.showRobotCounter = 0; + $scope.Config = Config; + + var loadRobotPermissions = function(info) { + var shortName = $scope.getShortenedName(info.name); + info.loading_permissions = true; + ApiService.getRobotPermissions($scope.organization, null, {'robot_shortname': shortName}).then(function(resp) { + info.permissions = resp.permissions; + info.loading_permissions = false; + }, ApiService.errorDisplay('Could not load robot permissions')); + }; + + $scope.showPermissions = function(robotInfo) { + robotInfo.showing_permissions = !robotInfo.showing_permissions; + + if (robotInfo.showing_permissions) { + loadRobotPermissions(robotInfo); + } + }; $scope.regenerateToken = function(username) { if (!username) { return; } @@ -47,6 +73,10 @@ angular.module('quay').directive('robotsManager', function () { return -1; }; + $scope.getShortenedRobotName = function(info) { + return $scope.getShortenedName(info.name); + }; + $scope.getShortenedName = function(name) { var plus = name.indexOf('+'); return name.substr(plus + 1); diff --git a/static/js/directives/ui/role-group.js b/static/js/directives/ui/role-group.js index d8ca75873..66fd0629c 100644 --- a/static/js/directives/ui/role-group.js +++ b/static/js/directives/ui/role-group.js @@ -12,6 +12,7 @@ angular.module('quay').directive('roleGroup', function () { scope: { 'roles': '=roles', 'currentRole': '=currentRole', + 'readOnly': '=readOnly', 'roleChanged': '&roleChanged' }, controller: function($scope, $element) { diff --git a/static/js/directives/ui/teams-manager.js b/static/js/directives/ui/teams-manager.js new file mode 100644 index 000000000..818946e41 --- /dev/null +++ b/static/js/directives/ui/teams-manager.js @@ -0,0 +1,131 @@ +/** + * Element for managing the teams of an organization. + */ +angular.module('quay').directive('teamsManager', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/teams-manager.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'organization': '=organization' + }, + controller: function($scope, $element, ApiService, CreateService) { + $scope.TEAM_PATTERN = TEAM_PATTERN; + $scope.teamRoles = [ + { 'id': 'member', 'title': 'Member', 'kind': 'default' }, + { 'id': 'creator', 'title': 'Creator', 'kind': 'success' }, + { 'id': 'admin', 'title': 'Admin', 'kind': 'primary' } + ]; + + $scope.members = {}; + $scope.orderedTeams = []; + + var loadTeamMembers = function() { + if (!$scope.organization) { return; } + + for (var name in $scope.organization.teams) { + if (!$scope.organization.teams.hasOwnProperty(name)) { continue; } + loadMembersOfTeam(name); + } + }; + + var loadMembersOfTeam = function(name) { + var params = { + 'orgname': $scope.organization.name, + 'teamname': name + }; + + $scope.members[name] = {}; + + ApiService.getOrganizationTeamMembers(null, params).then(function(resp) { + $scope.members[name].members = resp.members; + }, function() { + delete $scope.members[name]; + }); + }; + + var loadOrderedTeams = function() { + if (!$scope.organization || !$scope.organization.ordered_teams) { return; } + + $scope.orderedTeams = []; + $scope.organization.ordered_teams.map(function(name) { + $scope.orderedTeams.push($scope.organization.teams[name]); + }); + }; + + $scope.$watch('organization', loadOrderedTeams); + $scope.$watch('organization', loadTeamMembers); + + $scope.setRole = function(role, teamname) { + var previousRole = $scope.organization.teams[teamname].role; + $scope.organization.teams[teamname].role = role; + + var params = { + 'orgname': $scope.organization.name, + 'teamname': teamname + }; + + var data = $scope.organization.teams[teamname]; + + var errorHandler = ApiService.errorDisplay('Cannot update team', function(resp) { + $scope.organization.teams[teamname].role = previousRole; + }); + + ApiService.updateOrganizationTeam(data, params).then(function(resp) { + }, errorHandler); + }; + + $scope.createTeam = function(teamname) { + if (!teamname) { + return; + } + + if ($scope.organization.teams[teamname]) { + $('#team-' + teamname).removeClass('highlight'); + setTimeout(function() { + $('#team-' + teamname).addClass('highlight'); + }, 10); + return; + } + + var orgname = $scope.organization.name; + CreateService.createOrganizationTeam(ApiService, orgname, teamname, function(created) { + $scope.organization.teams[teamname] = created; + $scope.members[teamname] = {}; + $scope.members[teamname].members = []; + $scope.organization.ordered_teams.push(teamname); + $scope.orderedTeams.push(created); + }); + }; + + $scope.askDeleteTeam = function(teamname) { + bootbox.confirm('Are you sure you want to delete team ' + teamname + '?', function(resp) { + if (resp) { + $scope.deleteTeam(teamname); + } + }); + }; + + $scope.deleteTeam = function(teamname) { + var params = { + 'orgname': $scope.organization.name, + 'teamname': teamname + }; + + ApiService.deleteOrganizationTeam(null, params).then(function() { + var index = $scope.organization.ordered_teams.indexOf(teamname); + if (index >= 0) { + $scope.organization.ordered_teams.splice(index, 1); + } + + loadOrderedTeams(); + delete $scope.organization.teams[teamname]; + }, ApiService.errorDisplay('Cannot delete team')); + }; + } + }; + + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/graphing.js b/static/js/graphing.js index 7ccfcf2df..7ea5203ec 100644 --- a/static/js/graphing.js +++ b/static/js/graphing.js @@ -95,7 +95,7 @@ function ImageHistoryTree(namespace, name, images, formatComment, formatTime, fo * Calculates the dimensions of the tree. */ ImageHistoryTree.prototype.calculateDimensions_ = function(container) { - var cw = Math.max(document.getElementById(container).clientWidth, this.maxWidth_ * DEPTH_WIDTH); + var cw = document.getElementById(container).clientWidth; var ch = this.maxHeight_ * (DEPTH_HEIGHT + 10); var margin = { top: 40, right: 20, bottom: 20, left: 80 }; @@ -1157,8 +1157,11 @@ FileTreeBase.prototype.populateAndDraw_ = function() { } this.root_ = this.nodeMap_['']; - this.root_.x0 = 0; - this.root_.y0 = 0; + if (this.root_) { + this.root_.x0 = 0; + this.root_.y0 = 0; + } + this.toggle_(this.root_); this.update_(this.root_); }; diff --git a/static/js/pages/exp-new-layout.js b/static/js/pages/exp-new-layout.js index f68eb201d..60c32f21f 100644 --- a/static/js/pages/exp-new-layout.js +++ b/static/js/pages/exp-new-layout.js @@ -14,6 +14,7 @@ $scope.setEnabled = function(value) { $scope.isEnabled = value; CookieService.putPermanent('quay.exp-new-layout', value.toString()); + document.location.reload(); }; } }()); \ No newline at end of file diff --git a/static/js/pages/image-view.js b/static/js/pages/image-view.js index a8f04deda..347854d3d 100644 --- a/static/js/pages/image-view.js +++ b/static/js/pages/image-view.js @@ -3,7 +3,14 @@ * Page to view the details of a single image. */ angular.module('quayPages').config(['pages', function(pages) { - pages.create('image-view', 'image-view.html', ImageViewCtrl); + pages.create('image-view', 'image-view.html', ImageViewCtrl, { + 'newLayout': true, + 'title': '{{ image.id }}', + 'description': 'Image {{ image.id }}' + }, ['layout']) + + pages.create('image-view', 'old-image-view.html', OldImageViewCtrl, { + }, ['old-layout']); }]); function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, ImageMetadataService) { @@ -11,6 +18,75 @@ var name = $routeParams.name; var imageid = $routeParams.image; + var loadImage = function() { + var params = { + 'repository': namespace + '/' + name, + 'image_id': imageid + }; + + $scope.imageResource = ApiService.getImageAsResource(params).get(function(image) { + $scope.image = image; + $scope.reversedHistory = image.history.reverse(); + }); + }; + + var loadRepository = function() { + var params = { + 'repository': namespace + '/' + name + }; + + $scope.repositoryResource = ApiService.getRepoAsResource(params).get(function(repo) { + $scope.repository = repo; + }); + }; + + loadImage(); + loadRepository(); + + $scope.downloadChanges = function() { + if ($scope.changesResource) { return; } + + var params = { + 'repository': namespace + '/' + name, + 'image_id': imageid + }; + + $scope.changesResource = ApiService.getImageChangesAsResource(params).get(function(changes) { + var combinedChanges = []; + var addCombinedChanges = function(c, kind) { + for (var i = 0; i < c.length; ++i) { + combinedChanges.push({ + 'kind': kind, + 'file': c[i] + }); + } + }; + + addCombinedChanges(changes.added, 'added'); + addCombinedChanges(changes.removed, 'removed'); + addCombinedChanges(changes.changed, 'changed'); + + $scope.combinedChanges = combinedChanges; + $scope.imageChanges = changes; + $scope.initializeTree(); + }); + }; + + $scope.initializeTree = function() { + if ($scope.tree || !$scope.combinedChanges.length) { return; } + + $scope.tree = new ImageFileChangeTree($scope.image, $scope.combinedChanges); + $timeout(function() { + $scope.tree.draw('changes-tree-container'); + }, 100); + }; + } + + function OldImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, ImageMetadataService) { + var namespace = $routeParams.namespace; + var name = $routeParams.name; + var imageid = $routeParams.image; + $scope.getFormattedCommand = ImageMetadataService.getFormattedCommand; $scope.parseDate = function(dateString) { diff --git a/static/js/pages/landing.js b/static/js/pages/landing.js index eefd7e400..ddb012f69 100644 --- a/static/js/pages/landing.js +++ b/static/js/pages/landing.js @@ -1,6 +1,7 @@ (function() { /** * Landing page. + * DEPRECATED: Remove the code for viewing when logged in. */ angular.module('quayPages').config(['pages', function(pages) { pages.create('landing', 'landing.html', LandingCtrl, { @@ -8,7 +9,7 @@ }); }]); - function LandingCtrl($scope, UserService, ApiService, Features, Config) { + function LandingCtrl($scope, $location, UserService, ApiService, Features, Config) { $scope.namespace = null; $scope.currentScreenshot = 'repo-view'; @@ -16,7 +17,12 @@ loadMyRepos(namespace); }); - UserService.updateUserIn($scope, function() { + UserService.updateUserIn($scope, function(user) { + if (!user.anonymous && Config.isNewLayout()) { + $location.path('/repository'); + return; + } + loadMyRepos($scope.namespace); }); diff --git a/static/js/pages/new-organization.js b/static/js/pages/new-organization.js index 5508da383..85c451115 100644 --- a/static/js/pages/new-organization.js +++ b/static/js/pages/new-organization.js @@ -4,9 +4,15 @@ */ angular.module('quayPages').config(['pages', function(pages) { pages.create('new-organization', 'new-organization.html', NewOrgCtrl, { + 'newLayout': true, 'title': 'New Organization', 'description': 'Create a new organization to manage teams and permissions' - }); + }, ['layout']); + + pages.create('new-organization', 'old-new-organization.html', NewOrgCtrl, { + 'title': 'New Organization', + 'description': 'Create a new organization to manage teams and permissions' + }, ['old-layout']); }]); function NewOrgCtrl($scope, $routeParams, $timeout, $location, UserService, PlanService, ApiService, CookieService, Features) { diff --git a/static/js/pages/new-repo.js b/static/js/pages/new-repo.js index 79cac4762..36ff98016 100644 --- a/static/js/pages/new-repo.js +++ b/static/js/pages/new-repo.js @@ -3,10 +3,16 @@ * Page to create a new repository. */ angular.module('quayPages').config(['pages', function(pages) { - pages.create('new-repo', 'new-repo.html', NewRepoCtrl, { + pages.create('new-repo', 'new-repo.html', NewRepoCtrl, { + 'newLayout': true, 'title': 'New Repository', 'description': 'Create a new Docker repository' - }); + }, ['layout']) + + pages.create('new-repo', 'old-new-repo.html', NewRepoCtrl, { + 'title': 'New Repository', + 'description': 'Create a new Docker repository' + }, ['old-layout']); }]); function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService, PlanService, TriggerService, Features) { diff --git a/static/js/pages/org-admin.js b/static/js/pages/org-admin.js index 194366f8a..b655e825b 100644 --- a/static/js/pages/org-admin.js +++ b/static/js/pages/org-admin.js @@ -1,6 +1,6 @@ (function() { /** - * Organization admin/settings page. + * DEPRECATED: Organization admin/settings page. */ angular.module('quayPages').config(['pages', function(pages) { pages.create('org-admin', 'org-admin.html', OrgAdminCtrl); diff --git a/static/js/pages/org-view.js b/static/js/pages/org-view.js index f1a3fa287..3e825d783 100644 --- a/static/js/pages/org-view.js +++ b/static/js/pages/org-view.js @@ -3,10 +3,95 @@ * Page that displays details about an organization, such as its teams. */ angular.module('quayPages').config(['pages', function(pages) { - pages.create('org-view', 'org-view.html', OrgViewCtrl); + pages.create('org-view', 'org-view.html', OrgViewCtrl, { + 'newLayout': true, + 'title': 'Organization {{ organization.name }}', + 'description': 'Organization {{ organization.name }}' + }, ['layout']) + + pages.create('org-view', 'old-org-view.html', OldOrgViewCtrl, { + }, ['old-layout']); }]); - function OrgViewCtrl($rootScope, $scope, ApiService, $routeParams, CreateService) { + function OrgViewCtrl($scope, $routeParams, $timeout, ApiService, UIService, AvatarService) { + var orgname = $routeParams.orgname; + + $scope.showLogsCounter = 0; + $scope.showApplicationsCounter = 0; + $scope.showInvoicesCounter = 0; + + $scope.orgScope = { + 'changingOrganization': false, + 'organizationEmail': '' + }; + + $scope.$watch('orgScope.organizationEmail', function(e) { + UIService.hidePopover('#changeEmailForm input'); + }); + + var loadRepositories = function() { + var options = { + 'namespace_only': true, + 'namespace': orgname, + }; + + $scope.repositoriesResource = ApiService.listReposAsResource().withOptions(options).get(function(resp) { + return resp.repositories; + }); + }; + + var loadOrganization = function() { + $scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) { + $scope.organization = org; + $scope.orgScope.organizationEmail = org.email; + $scope.isAdmin = org.is_admin; + $scope.isMember = org.is_member; + + // Load the repositories. + $timeout(function() { + loadRepositories(); + }, 10); + }); + }; + + // Load the organization. + loadOrganization(); + + $scope.showInvoices = function() { + $scope.showInvoicesCounter++; + }; + + $scope.showApplications = function() { + $scope.showApplicationsCounter++; + }; + + $scope.showLogs = function() { + $scope.showLogsCounter++; + }; + + $scope.changeEmail = function() { + UIService.hidePopover('#changeEmailForm input'); + + $scope.orgScope.changingOrganization = true; + var params = { + 'orgname': orgname + }; + + var data = { + 'email': $scope.orgScope.organizationEmail + }; + + ApiService.changeOrganizationDetails(data, params).then(function(org) { + $scope.orgScope.changingOrganization = false; + $scope.organization = org; + }, function(result) { + $scope.orgScope.changingOrganization = false; + UIService.showFormError('#changeEmailForm input', result, 'right'); + }); + }; + } + + function OldOrgViewCtrl($rootScope, $scope, ApiService, $routeParams, CreateService) { var orgname = $routeParams.orgname; $scope.TEAM_PATTERN = TEAM_PATTERN; diff --git a/static/js/pages/organizations.js b/static/js/pages/organizations.js index 1158b01bc..ccc5cf898 100644 --- a/static/js/pages/organizations.js +++ b/static/js/pages/organizations.js @@ -1,6 +1,6 @@ (function() { /** - * Page which displays the list of organizations of which the user is a member. + * DEPRECATED: Page which displays the list of organizations of which the user is a member. */ angular.module('quayPages').config(['pages', function(pages) { pages.create('organizations', 'organizations.html', OrgsCtrl, { diff --git a/static/js/pages/repo-view.js b/static/js/pages/repo-view.js index 81fc620f2..7cb8ed365 100644 --- a/static/js/pages/repo-view.js +++ b/static/js/pages/repo-view.js @@ -13,7 +13,7 @@ }, ['old-layout']); }]); - function RepoViewCtrl($scope, $routeParams, $location, ApiService, UserService, AngularPollChannel) { + function RepoViewCtrl($scope, $routeParams, $location, $timeout, ApiService, UserService, AngularPollChannel) { $scope.namespace = $routeParams.namespace; $scope.name = $routeParams.name; @@ -63,12 +63,21 @@ }; $scope.repositoryResource = ApiService.getRepoAsResource(params).get(function(repo) { + $scope.repository = repo; $scope.viewScope.repository = repo; - $scope.setTags($routeParams.tag); - // Track builds. - buildPollChannel = AngularPollChannel.create($scope, loadRepositoryBuilds, 5000 /* 5s */); - buildPollChannel.start(); + // Load the remainder of the data async, so we don't block the initial view from + // showing. + $timeout(function() { + $scope.setTags($routeParams.tag); + + // Load the images. + loadImages(); + + // Track builds. + buildPollChannel = AngularPollChannel.create($scope, loadRepositoryBuilds, 5000 /* 5s */); + buildPollChannel.start(); + }, 10); }); }; @@ -98,9 +107,8 @@ }, errorHandler); }; - // Load the repository and images. + // Load the repository. loadRepository(); - loadImages(); $scope.setTags = function(tagNames) { if (!tagNames) { @@ -638,6 +646,10 @@ $scope.setImage($routeParams.image); } + $timeout(function() { + $scope.tree.notifyResized(); + }, 100); + return resp.images; }); }; diff --git a/static/js/pages/setup.js b/static/js/pages/setup.js index 431811ff4..21a036350 100644 --- a/static/js/pages/setup.js +++ b/static/js/pages/setup.js @@ -140,7 +140,7 @@ $scope.showSuperuserPanel = function() { $('#setupModal').modal('hide'); var prefix = $scope.hasSSL ? 'https' : 'http'; - var hostname = $scope.hostname; + var hostname = $scope.hostname || document.location.hostname; window.location = prefix + '://' + hostname + '/superuser'; }; diff --git a/static/js/pages/team-view.js b/static/js/pages/team-view.js index ecbf59749..e8f184c96 100644 --- a/static/js/pages/team-view.js +++ b/static/js/pages/team-view.js @@ -3,7 +3,14 @@ * Page to view the members of a team and add/remove them. */ angular.module('quayPages').config(['pages', function(pages) { - pages.create('team-view', 'team-view.html', TeamViewCtrl); + pages.create('team-view', 'team-view.html', TeamViewCtrl, { + 'newLayout': true, + 'title': 'Team {{ teamname }}', + 'description': 'Team {{ teamname }}' + }, ['layout']) + + pages.create('team-view', 'old-team-view.html', TeamViewCtrl, { + }, ['old-layout']); }]); function TeamViewCtrl($rootScope, $scope, $timeout, Features, Restangular, ApiService, $routeParams) { diff --git a/static/js/pages/tutorial.js b/static/js/pages/tutorial.js index d5661c73b..b01676446 100644 --- a/static/js/pages/tutorial.js +++ b/static/js/pages/tutorial.js @@ -4,9 +4,13 @@ */ angular.module('quayPages').config(['pages', function(pages) { pages.create('tutorial', 'tutorial.html', TutorialCtrl, { + 'newLayout': true, 'title': 'Tutorial', - 'description': 'Basic tutorial on using Docker with Quay.io' - }); + 'description': 'Basic tutorial on using Quay.io' + }, ['layout']) + + pages.create('tutorial', 'old-tutorial.html', TutorialCtrl, { + }, ['old-layout']); }]); function TutorialCtrl($scope, AngularTour, AngularTourSignals, UserService, Config) { @@ -59,7 +63,7 @@ 'templateUrl': '/static/tutorial/push-image.html', 'signal': AngularTourSignals.serverEvent('/realtime/user/subscribe?events=docker-cli', function(message, tourScope) { - var pushing = message['data']['action'] == 'push_repo'; + var pushing = message['data']['action'] == 'push_start'; if (pushing) { tourScope.repoName = message['data']['repository']; } @@ -73,7 +77,7 @@ 'templateUrl': '/static/tutorial/pushing.html', 'signal': AngularTourSignals.serverEvent('/realtime/user/subscribe?events=docker-cli', function(message, tourScope) { - return message['data']['action'] == 'pushed_repo'; + return message['data']['action'] == 'push_repo'; }), 'waitMessage': "Waiting for repository push to complete" }, diff --git a/static/js/pages/user-admin.js b/static/js/pages/user-admin.js index 6af34d264..cb2fe4bcd 100644 --- a/static/js/pages/user-admin.js +++ b/static/js/pages/user-admin.js @@ -1,6 +1,6 @@ (function() { /** - * User admin/settings page. + * DEPRECATED: User admin/settings page. */ angular.module('quayPages').config(['pages', function(pages) { pages.create('user-admin', 'user-admin.html', UserAdminCtrl, { @@ -160,11 +160,7 @@ $scope.updatingUser = false; $scope.changeEmailSent = true; $scope.sentEmail = $scope.cuser.email; - - // Reset the form. delete $scope.cuser['email']; - - $scope.changeEmailForm.$setPristine(); }, function(result) { $scope.updatingUser = false; UIService.showFormError('#changeEmailForm', result); @@ -196,6 +192,21 @@ }); }; + $scope.generateClientToken = function() { + var generateToken = function(password) { + var data = { + 'password': password + }; + + ApiService.generateUserClientKey(data).then(function(resp) { + $scope.generatedClientToken = resp['key']; + $('#clientTokenModal').modal({}); + }, ApiService.errorDisplay('Could not generate token')); + }; + + UIService.showPasswordDialog('Enter your password to generated an encrypted version:', generateToken); + }; + $scope.detachExternalLogin = function(kind) { var params = { 'servicename': kind diff --git a/static/js/pages/user-view.js b/static/js/pages/user-view.js new file mode 100644 index 000000000..7a1f056aa --- /dev/null +++ b/static/js/pages/user-view.js @@ -0,0 +1,112 @@ +(function() { + /** + * Page that displays details about an user. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('user-view', 'user-view.html', UserViewCtrl, { + 'newLayout': true, + 'title': 'User {{ user.username }}', + 'description': 'User {{ user.username }}' + }, ['layout']) + }]); + + function UserViewCtrl($scope, $routeParams, $timeout, ApiService, UserService, UIService, AvatarService) { + var username = $routeParams.username; + + $scope.showInvoicesCounter = 0; + $scope.showAppsCounter = 0; + $scope.changeEmailInfo = {}; + $scope.changePasswordInfo = {}; + + UserService.updateUserIn($scope); + + var loadRepositories = function() { + var options = { + 'sort': true, + 'namespace_only': true, + 'namespace': username, + }; + + $scope.repositoriesResource = ApiService.listReposAsResource().withOptions(options).get(function(resp) { + return resp.repositories; + }); + }; + + var loadUser = function() { + $scope.userResource = ApiService.getUserInformationAsResource({'username': username}).get(function(user) { + $scope.viewuser = user; + + // Load the repositories. + $timeout(function() { + loadRepositories(); + }, 10); + }); + }; + + // Load the user. + loadUser(); + + $scope.showInvoices = function() { + $scope.showInvoicesCounter++; + }; + + $scope.showApplications = function() { + $scope.showAppsCounter++; + }; + + $scope.changePassword = function() { + UIService.hidePopover('#changePasswordForm'); + $scope.changePasswordInfo.state = 'changing'; + + var data = { + 'password': $scope.changePasswordInfo.password + }; + + ApiService.changeUserDetails(data).then(function(resp) { + $scope.changePasswordInfo.state = 'changed'; + + // Reset the form + delete $scope.changePasswordInfo['password'] + delete $scope.changePasswordInfo['repeatPassword'] + + // Reload the user. + UserService.load(); + }, function(result) { + $scope.changePasswordInfo.state = 'change-error'; + UIService.showFormError('#changePasswordForm', result); + }); + }; + + $scope.generateClientToken = function() { + var generateToken = function(password) { + var data = { + 'password': password + }; + + ApiService.generateUserClientKey(data).then(function(resp) { + $scope.generatedClientToken = resp['key']; + $('#clientTokenModal').modal({}); + }, ApiService.errorDisplay('Could not generate token')); + }; + + UIService.showPasswordDialog('Enter your password to generated an encrypted version:', generateToken); + }; + + $scope.changeEmail = function() { + UIService.hidePopover('#changeEmailForm'); + + var details = { + 'email': $scope.changeEmailInfo.email + }; + + $scope.changeEmailInfo.state = 'sending'; + ApiService.changeUserDetails(details).then(function() { + $scope.changeEmailInfo.state = 'sent'; + delete $scope.changeEmailInfo['email']; + }, function(result) { + $scope.changeEmailInfo.state = 'send-error'; + UIService.showFormError('#changeEmailForm', result); + }); + }; + } +})(); \ No newline at end of file diff --git a/static/js/services/angular-view-array.js b/static/js/services/angular-view-array.js index 698ba2f61..2e0e6594a 100644 --- a/static/js/services/angular-view-array.js +++ b/static/js/services/angular-view-array.js @@ -29,7 +29,7 @@ angular.module('quay').factory('AngularViewArray', ['$interval', function($inter this.hasEntries = true; if (this.isVisible) { - this.setVisible(true); + this.startTimer_(); } }; @@ -64,6 +64,8 @@ angular.module('quay').factory('AngularViewArray', ['$interval', function($inter }; _ViewArray.prototype.startTimer_ = function() { + if (this.timerRef_) { return; } + var that = this; this.timerRef_ = $interval(function() { that.showAdditionalEntries_(); diff --git a/static/js/services/avatar-service.js b/static/js/services/avatar-service.js index 500475000..8aa3d436a 100644 --- a/static/js/services/avatar-service.js +++ b/static/js/services/avatar-service.js @@ -14,7 +14,9 @@ angular.module('quay').factory('AvatarService', ['Config', '$sanitize', 'md5', break; case 'gravatar': - return '//www.gravatar.com/avatar/' + hash + '?d=identicon&size=' + size; + // TODO(jschorr): Remove once the new layout is in place everywhere. + var default_kind = Config.isNewLayout() ? '404' : 'identicon'; + return '//www.gravatar.com/avatar/' + hash + '?d=' + default_kind + '&size=' + size; break; } }; diff --git a/static/js/services/create-service.js b/static/js/services/create-service.js index 25c308e22..b6092728b 100644 --- a/static/js/services/create-service.js +++ b/static/js/services/create-service.js @@ -1,7 +1,7 @@ /** * Service which exposes various methods for creating entities on the backend. */ -angular.module('quay').factory('CreateService', ['ApiService', function(ApiService) { +angular.module('quay').factory('CreateService', ['ApiService', 'UserService', function(ApiService, UserService) { var createService = {}; createService.createRobotAccount = function(ApiService, is_org, orgname, name, callback) { @@ -24,5 +24,38 @@ angular.module('quay').factory('CreateService', ['ApiService', function(ApiServi .then(callback, ApiService.errorDisplay('Cannot create team')); }; + createService.askCreateRobot = function(namespace, callback) { + if (!namespace || !UserService.isNamespaceAdmin(namespace)) { return; } + + var isorg = UserService.isOrganization(namespace); + bootbox.prompt('Enter the name of the new robot account', function(robotname) { + if (!robotname) { return; } + + var regex = new RegExp(ROBOT_PATTERN); + if (!regex.test(robotname)) { + bootbox.alert('Invalid robot account name'); + return; + } + + createService.createRobotAccount(ApiService, isorg, namespace, robotname, callback); + }); + }; + + createService.askCreateTeam = function(namespace, callback) { + if (!namespace || !UserService.isNamespaceAdmin(namespace)) { return; } + + bootbox.prompt('Enter the name of the new team', function(teamname) { + if (!teamname) { return; } + + var regex = new RegExp(TEAM_PATTERN); + if (!regex.test(teamname)) { + bootbox.alert('Invalid team name'); + return; + } + + CreateService.createOrganizationTeam(ApiService, namespace, teamname, callback); + }); + }; + return createService; }]); diff --git a/static/js/services/features-config.js b/static/js/services/features-config.js index e65f2fb9f..7cc66b2dc 100644 --- a/static/js/services/features-config.js +++ b/static/js/services/features-config.js @@ -54,6 +54,10 @@ angular.module('quay').factory('Config', [function() { return config['PREFERRED_URL_SCHEME'] + '://' + auth + config['SERVER_HOSTNAME']; }; + config.getHttp = function() { + return config['PREFERRED_URL_SCHEME']; + }; + config.getUrl = function(opt_path) { var path = opt_path || ''; return config['PREFERRED_URL_SCHEME'] + '://' + config['SERVER_HOSTNAME'] + path; @@ -67,5 +71,10 @@ angular.module('quay').factory('Config', [function() { return value; }; + config.isNewLayout = function() { + // TODO(jschorr): Remove once new layout is in place for everyone. + return document.cookie.toString().indexOf('quay.exp-new-layout=true') >= 0; + }; + return config; }]); \ No newline at end of file diff --git a/static/js/services/key-service.js b/static/js/services/key-service.js index 6b7bb5a3a..e38ba7b26 100644 --- a/static/js/services/key-service.js +++ b/static/js/services/key-service.js @@ -24,6 +24,10 @@ angular.module('quay').factory('KeyService', ['$location', 'Config', function($l keyService['githubTriggerAuthorizeUrl'] = oauth['GITHUB_TRIGGER_CONFIG']['AUTHORIZE_ENDPOINT']; keyService['githubLoginScope'] = 'user:email'; + if (oauth['GITHUB_LOGIN_CONFIG']['ORG_RESTRICT']) { + keyService['githubLoginScope'] += ',read:org'; + } + keyService['googleLoginScope'] = 'openid email'; keyService.isEnterprise = function(service) { diff --git a/static/js/services/ui-service.js b/static/js/services/ui-service.js index 2e857e8fa..2a5250ca6 100644 --- a/static/js/services/ui-service.js +++ b/static/js/services/ui-service.js @@ -66,10 +66,10 @@ angular.module('quay').factory('UIService', [function() { } }; - uiService.showPopover = function(elem, content) { + uiService.showPopover = function(elem, content, opt_placement) { var popover = $(elem).data('bs.popover'); if (!popover) { - $(elem).popover({'content': '-', 'placement': 'left'}); + $(elem).popover({'content': '-', 'placement': opt_placement || 'left'}); } setTimeout(function() { @@ -79,10 +79,10 @@ angular.module('quay').factory('UIService', [function() { }, 500); }; - uiService.showFormError = function(elem, result) { + uiService.showFormError = function(elem, result, opt_placement) { var message = result.data['message'] || result.data['error_description'] || ''; if (message) { - uiService.showPopover(elem, message); + uiService.showPopover(elem, message, opt_placement); } else { uiService.hidePopover(elem); } @@ -92,5 +92,47 @@ angular.module('quay').factory('UIService', [function() { return new CheckStateController(items, opt_checked); }; + uiService.showPasswordDialog = function(message, callback, opt_canceledCallback) { + var success = function() { + var password = $('#passDialogBox').val(); + $('#passDialogBox').val(''); + callback(password); + }; + + var canceled = function() { + $('#passDialogBox').val(''); + opt_canceledCallback && opt_canceledCallback(); + }; + + var box = bootbox.dialog({ + "message": message + + '
        ' + + '' + + '
        ', + "title": 'Please Verify', + "buttons": { + "verify": { + "label": "Verify", + "className": "btn-success", + "callback": success + }, + "close": { + "label": "Cancel", + "className": "btn-default", + "callback": canceled + } + } + }); + + box.bind('shown.bs.modal', function(){ + box.find("input").focus(); + box.find("form").submit(function() { + if (!$('#passDialogBox').val()) { return; } + box.modal('hide'); + success(); + }); + }); + }; + return uiService; }]); diff --git a/static/js/services/user-service.js b/static/js/services/user-service.js index 7f5ee4463..d6bce37cc 100644 --- a/static/js/services/user-service.js +++ b/static/js/services/user-service.js @@ -12,7 +12,8 @@ function(ApiService, CookieService, $rootScope, Config) { username: null, email: null, organizations: [], - logins: [] + logins: [], + beforeload: true } var userService = {} @@ -83,6 +84,10 @@ function(ApiService, CookieService, $rootScope, Config) { }); }; + userService.isOrganization = function(name) { + return !!userService.getOrganization(name); + }; + userService.getOrganization = function(name) { if (!userResponse || !userResponse.organizations) { return null; } for (var i = 0; i < userResponse.organizations.length; ++i) { diff --git a/static/lib/LICENSES b/static/lib/LICENSES index 39ac45d5a..498a26df9 100644 --- a/static/lib/LICENSES +++ b/static/lib/LICENSES @@ -20,6 +20,8 @@ zlib - MIT (https://github.com/imaya/zlib.js) pagedown - Permissive jquery.overscroll - MIT (https://github.com/azoff/overscroll/blob/master/mit.license) URI.js - MIT (https://github.com/medialize/URI.js) +angular-hotkeys - MIT (https://github.com/chieffancypants/angular-hotkeys/blob/master/LICENSE) +angular-debounce - MIT (https://github.com/shahata/angular-debounce/blob/master/LICENSE) Issues: >>>>> jquery.spotlight - GPLv3 (https://github.com/jameshalsall/jQuery-Spotlight) \ No newline at end of file diff --git a/static/lib/angular-debounce.js b/static/lib/angular-debounce.js new file mode 100644 index 000000000..9a0dfe99e --- /dev/null +++ b/static/lib/angular-debounce.js @@ -0,0 +1,66 @@ +'use strict'; + +angular.module('debounce', []) + .service('debounce', ['$timeout', function ($timeout) { + return function (func, wait, immediate) { + var timeout, args, context, result; + function debounce() { + /* jshint validthis:true */ + context = this; + args = arguments; + var later = function () { + timeout = null; + if (!immediate) { + result = func.apply(context, args); + } + }; + var callNow = immediate && !timeout; + if (timeout) { + $timeout.cancel(timeout); + } + timeout = $timeout(later, wait); + if (callNow) { + result = func.apply(context, args); + } + return result; + } + debounce.cancel = function () { + $timeout.cancel(timeout); + timeout = null; + }; + return debounce; + }; + }]) + .directive('debounce', ['debounce', '$parse', function (debounce, $parse) { + return { + require: 'ngModel', + priority: 999, + link: function ($scope, $element, $attrs, ngModelController) { + var debounceDuration = $parse($attrs.debounce)($scope); + var immediate = !!$parse($attrs.immediate)($scope); + var debouncedValue, pass; + var prevRender = ngModelController.$render.bind(ngModelController); + var commitSoon = debounce(function (viewValue) { + pass = true; + ngModelController.$setViewValue(viewValue); + pass = false; + }, parseInt(debounceDuration, 10), immediate); + ngModelController.$render = function () { + prevRender(); + commitSoon.cancel(); + //we must be first parser for this to work properly, + //so we have priority 999 so that we unshift into parsers last + debouncedValue = this.$viewValue; + }; + ngModelController.$parsers.unshift(function (value) { + if (pass) { + debouncedValue = value; + return value; + } else { + commitSoon(ngModelController.$viewValue); + return debouncedValue; + } + }); + } + }; + }]); diff --git a/static/lib/angular-strap.min.js b/static/lib/angular-strap.min.js index c358b9e1c..950a81b4e 100644 --- a/static/lib/angular-strap.min.js +++ b/static/lib/angular-strap.min.js @@ -1,10 +1,10 @@ /** * angular-strap - * @version v2.0.0-rc.4 - 2014-03-07 + * @version v2.2.1 - 2015-03-10 * @link http://mgcrea.github.io/angular-strap * @author Olivier Louvignes (olivier@mg-crea.com) * @license MIT License, http://www.opensource.org/licenses/MIT */ -!function(a,b){"use strict";angular.module("mgcrea.ngStrap",["mgcrea.ngStrap.modal","mgcrea.ngStrap.aside","mgcrea.ngStrap.alert","mgcrea.ngStrap.button","mgcrea.ngStrap.select","mgcrea.ngStrap.datepicker","mgcrea.ngStrap.timepicker","mgcrea.ngStrap.navbar","mgcrea.ngStrap.tooltip","mgcrea.ngStrap.popover","mgcrea.ngStrap.dropdown","mgcrea.ngStrap.typeahead","mgcrea.ngStrap.scrollspy","mgcrea.ngStrap.affix","mgcrea.ngStrap.tab"]),angular.module("mgcrea.ngStrap.affix",["mgcrea.ngStrap.helpers.dimensions"]).provider("$affix",function(){var a=this.defaults={offsetTop:"auto"};this.$get=["$window","dimensions",function(b,c){function d(d,f){function g(a,b,c){var d=h(),e=i();return t>=d?"top":null!==a&&d+a<=b.top?"middle":null!==u&&b.top+c+n>=e-u?"bottom":"middle"}function h(){return l[0]===b?b.pageYOffset:l[0]===b}function i(){return l[0]===b?b.document.body.scrollHeight:l[0].scrollHeight}var j={},k=angular.extend({},a,f),l=k.target,m="affix affix-top affix-bottom",n=0,o=0,p=null,q=null,r=d.parent();if(k.offsetParent)if(k.offsetParent.match(/^\d+$/))for(var s=0;s<1*k.offsetParent-1;s++)r=r.parent();else r=angular.element(k.offsetParent);var t=0;k.offsetTop&&("auto"===k.offsetTop&&(k.offsetTop="+0"),k.offsetTop.match(/^[-+]\d+$/)?(n-=1*k.offsetTop,t=k.offsetParent?c.offset(r[0]).top+1*k.offsetTop:c.offset(d[0]).top-c.css(d[0],"marginTop",!0)+1*k.offsetTop):t=1*k.offsetTop);var u=0;return k.offsetBottom&&(u=k.offsetParent&&k.offsetBottom.match(/^[-+]\d+$/)?i()-(c.offset(r[0]).top+c.height(r[0]))+1*k.offsetBottom+1:1*k.offsetBottom),j.init=function(){o=c.offset(d[0]).top+n,l.on("scroll",this.checkPosition),l.on("click",this.checkPositionWithEventLoop),this.checkPosition(),this.checkPositionWithEventLoop()},j.destroy=function(){l.off("scroll",this.checkPosition),l.off("click",this.checkPositionWithEventLoop)},j.checkPositionWithEventLoop=function(){setTimeout(this.checkPosition,1)},j.checkPosition=function(){var a=h(),b=c.offset(d[0]),f=c.height(d[0]),i=g(q,b,f);p!==i&&(p=i,d.removeClass(m).addClass("affix"+("middle"!==i?"-"+i:"")),"top"===i?(q=null,d.css("position",k.offsetParent?"":"relative"),d.css("top","")):"bottom"===i?(q=k.offsetUnpin?-(1*k.offsetUnpin):b.top-a,d.css("position",k.offsetParent?"":"relative"),d.css("top",k.offsetParent?"":e[0].offsetHeight-u-f-o+"px")):(q=null,d.css("position","fixed"),d.css("top",n+"px")))},j.init(),j}var e=angular.element(b.document.body);return d}]}).directive("bsAffix",["$affix","$window",function(a,b){return{restrict:"EAC",require:"^?bsAffixTarget",link:function(c,d,e,f){var g={scope:c,offsetTop:"auto",target:f?f.$element:angular.element(b)};angular.forEach(["offsetTop","offsetBottom","offsetParent","offsetUnpin"],function(a){angular.isDefined(e[a])&&(g[a]=e[a])});var h=a(d,g);c.$on("$destroy",function(){g=null,h=null})}}}]).directive("bsAffixTarget",function(){return{controller:["$element",function(a){this.$element=a}]}}),angular.module("mgcrea.ngStrap.alert",[]).provider("$alert",function(){var a=this.defaults={animation:"am-fade",prefixClass:"alert",placement:null,template:"alert/alert.tpl.html",container:!1,element:null,backdrop:!1,keyboard:!0,show:!0,duration:!1,type:!1};this.$get=["$modal","$timeout",function(b,c){function d(d){var e={},f=angular.extend({},a,d);e=b(f),f.type&&(e.$scope.type=f.type);var g=e.show;return f.duration&&(e.show=function(){g(),c(function(){e.hide()},1e3*f.duration)}),e}return d}]}).directive("bsAlert",["$window","$location","$sce","$alert",function(a,b,c,d){a.requestAnimationFrame||a.setTimeout;return{restrict:"EAC",scope:!0,link:function(a,b,e){var f={scope:a,element:b,show:!1};angular.forEach(["template","placement","keyboard","html","container","animation","duration"],function(a){angular.isDefined(e[a])&&(f[a]=e[a])}),angular.forEach(["title","content","type"],function(b){e[b]&&e.$observe(b,function(d){a[b]=c.trustAsHtml(d)})}),e.bsAlert&&a.$watch(e.bsAlert,function(b){angular.isObject(b)?angular.extend(a,b):a.content=b},!0);var g=d(f);b.on(e.trigger||"click",g.toggle),a.$on("$destroy",function(){g.destroy(),f=null,g=null})}}}]),angular.module("mgcrea.ngStrap.aside",["mgcrea.ngStrap.modal"]).provider("$aside",function(){var a=this.defaults={animation:"am-fade-and-slide-right",prefixClass:"aside",placement:"right",template:"aside/aside.tpl.html",contentTemplate:!1,container:!1,element:null,backdrop:!0,keyboard:!0,html:!1,show:!0};this.$get=["$modal",function(b){function c(c){var d={},e=angular.extend({},a,c);return d=b(e)}return c}]}).directive("bsAside",["$window","$location","$sce","$aside",function(a,b,c,d){a.requestAnimationFrame||a.setTimeout;return{restrict:"EAC",scope:!0,link:function(a,b,e){var f={scope:a,element:b,show:!1};angular.forEach(["template","contentTemplate","placement","backdrop","keyboard","html","container","animation"],function(a){angular.isDefined(e[a])&&(f[a]=e[a])}),angular.forEach(["title","content"],function(b){e[b]&&e.$observe(b,function(d){a[b]=c.trustAsHtml(d)})}),e.bsAside&&a.$watch(e.bsAside,function(b){angular.isObject(b)?angular.extend(a,b):a.content=b},!0);var g=d(f);b.on(e.trigger||"click",g.toggle),a.$on("$destroy",function(){g.destroy(),f=null,g=null})}}}]),angular.module("mgcrea.ngStrap.button",["ngAnimate"]).provider("$button",function(){var a=this.defaults={activeClass:"active",toggleEvent:"click"};this.$get=function(){return{defaults:a}}}).directive("bsCheckboxGroup",function(){return{restrict:"A",require:"ngModel",compile:function(a,b){a.attr("data-toggle","buttons"),a.removeAttr("ng-model");var c=a[0].querySelectorAll('input[type="checkbox"]');angular.forEach(c,function(a){var c=angular.element(a);c.attr("bs-checkbox",""),c.attr("ng-model",b.ngModel+"."+c.attr("value"))})}}}).directive("bsCheckbox",["$button","$$animateReflow",function(a,b){var c=a.defaults,d=/^(true|false|\d+)$/;return{restrict:"A",require:"ngModel",link:function(a,e,f,g){var h=c,i="INPUT"===e[0].nodeName,j=i?e.parent():e,k=angular.isDefined(f.trueValue)?f.trueValue:!0;d.test(f.trueValue)&&(k=a.$eval(f.trueValue));var l=angular.isDefined(f.falseValue)?f.falseValue:!1;d.test(f.falseValue)&&(l=a.$eval(f.falseValue));var m="boolean"!=typeof k||"boolean"!=typeof l;m&&(g.$parsers.push(function(a){return a?k:l}),a.$watch(f.ngModel,function(){g.$render()})),g.$render=function(){var a=angular.equals(g.$modelValue,k);b(function(){i&&(e[0].checked=a),j.toggleClass(h.activeClass,a)})},e.bind(h.toggleEvent,function(){a.$apply(function(){i||g.$setViewValue(!j.hasClass("active")),m||g.$render()})})}}}]).directive("bsRadioGroup",function(){return{restrict:"A",require:"ngModel",compile:function(a,b){a.attr("data-toggle","buttons"),a.removeAttr("ng-model");var c=a[0].querySelectorAll('input[type="radio"]');angular.forEach(c,function(a){angular.element(a).attr("bs-radio",""),angular.element(a).attr("ng-model",b.ngModel)})}}}).directive("bsRadio",["$button","$$animateReflow",function(a,b){var c=a.defaults,d=/^(true|false|\d+)$/;return{restrict:"A",require:"ngModel",link:function(a,e,f,g){var h=c,i="INPUT"===e[0].nodeName,j=i?e.parent():e,k=d.test(f.value)?a.$eval(f.value):f.value;g.$render=function(){var a=angular.equals(g.$modelValue,k);b(function(){i&&(e[0].checked=a),j.toggleClass(h.activeClass,a)})},e.bind(h.toggleEvent,function(){a.$apply(function(){g.$setViewValue(k),g.$render()})})}}}]),angular.module("mgcrea.ngStrap.datepicker",["mgcrea.ngStrap.helpers.dateParser","mgcrea.ngStrap.tooltip"]).provider("$datepicker",function(){var a=this.defaults={animation:"am-fade",prefixClass:"datepicker",placement:"bottom-left",template:"datepicker/datepicker.tpl.html",trigger:"focus",container:!1,keyboard:!0,html:!1,delay:0,useNative:!1,dateType:"date",dateFormat:"shortDate",strictFormat:!1,autoclose:!1,minDate:-1/0,maxDate:+1/0,startView:0,minView:0,startWeek:0};this.$get=["$window","$document","$rootScope","$sce","$locale","dateFilter","datepickerViews","$tooltip",function(b,c,d,e,f,g,h,i){function j(b,c,d){function e(a){a.selected=g.$isSelected(a.date)}function f(){b[0].focus()}var g=i(b,angular.extend({},a,d)),j=d.scope,m=g.$options,n=g.$scope;m.startView&&(m.startView-=m.minView);var o=h(g);g.$views=o.views;var p=o.viewDate;n.$mode=m.startView;var q=g.$views[n.$mode];n.$select=function(a){g.select(a)},n.$selectPane=function(a){g.$selectPane(a)},n.$toggleMode=function(){g.setMode((n.$mode+1)%g.$views.length)},g.update=function(a){angular.isDate(a)&&!isNaN(a.getTime())&&(g.$date=a,q.update.call(q,a)),g.$build(!0)},g.select=function(a,b){angular.isDate(c.$dateValue)||(c.$dateValue=new Date(a)),c.$dateValue.setFullYear(a.getFullYear(),a.getMonth(),a.getDate()),!n.$mode||b?(c.$setViewValue(c.$dateValue),c.$render(),m.autoclose&&!b&&g.hide(!0)):(angular.extend(p,{year:a.getFullYear(),month:a.getMonth(),date:a.getDate()}),g.setMode(n.$mode-1),g.$build())},g.setMode=function(a){n.$mode=a,q=g.$views[n.$mode],g.$build()},g.$build=function(a){a===!0&&q.built||(a!==!1||q.built)&&q.build.call(q)},g.$updateSelected=function(){for(var a=0,b=n.rows.length;b>a;a++)angular.forEach(n.rows[a],e)},g.$isSelected=function(a){return q.isSelected(a)},g.$selectPane=function(a){var b=q.steps,c=new Date(Date.UTC(p.year+(b.year||0)*a,p.month+(b.month||0)*a,p.date+(b.day||0)*a));angular.extend(p,{year:c.getUTCFullYear(),month:c.getUTCMonth(),date:c.getUTCDate()}),g.$build()},g.$onMouseDown=function(a){if(a.preventDefault(),a.stopPropagation(),k){var b=angular.element(a.target);"button"!==b[0].nodeName.toLowerCase()&&(b=b.parent()),b.triggerHandler("click")}},g.$onKeyDown=function(a){if(/(38|37|39|40|13)/.test(a.keyCode)&&!a.shiftKey&&!a.altKey){if(a.preventDefault(),a.stopPropagation(),13===a.keyCode)return n.$mode?n.$apply(function(){g.setMode(n.$mode-1)}):g.hide(!0);q.onKeyDown(a),j.$digest()}};var r=g.init;g.init=function(){return l&&m.useNative?(b.prop("type","date"),void b.css("-webkit-appearance","textfield")):(k&&(b.prop("type","text"),b.attr("readonly","true"),b.on("click",f)),void r())};var s=g.destroy;g.destroy=function(){l&&m.useNative&&b.off("click",f),s()};var t=g.show;g.show=function(){t(),setTimeout(function(){g.$element.on(k?"touchstart":"mousedown",g.$onMouseDown),m.keyboard&&b.on("keydown",g.$onKeyDown)})};var u=g.hide;return g.hide=function(a){g.$element.off(k?"touchstart":"mousedown",g.$onMouseDown),m.keyboard&&b.off("keydown",g.$onKeyDown),u(a)},g}var k=(angular.element(b.document.body),"createTouch"in b.document),l=/(ip(a|o)d|iphone|android)/gi.test(b.navigator.userAgent);return a.lang||(a.lang=f.id),j.defaults=a,j}]}).directive("bsDatepicker",["$window","$parse","$q","$locale","dateFilter","$datepicker","$dateParser","$timeout",function(a,b,c,d,e,f,g){{var h=(f.defaults,/(ip(a|o)d|iphone|android)/gi.test(a.navigator.userAgent));a.requestAnimationFrame||a.setTimeout}return{restrict:"EAC",require:"ngModel",link:function(a,b,c,d){var i={scope:a,controller:d};angular.forEach(["placement","container","delay","trigger","keyboard","html","animation","template","autoclose","dateType","dateFormat","strictFormat","startWeek","useNative","lang","startView","minView"],function(a){angular.isDefined(c[a])&&(i[a]=c[a])}),h&&i.useNative&&(i.dateFormat="yyyy-MM-dd");var j=f(b,d,i);i=j.$options,angular.forEach(["minDate","maxDate"],function(a){angular.isDefined(c[a])&&c.$observe(a,function(b){if("today"===b){var c=new Date;j.$options[a]=+new Date(c.getFullYear(),c.getMonth(),c.getDate()+("maxDate"===a?1:0),0,0,0,"minDate"===a?0:-1)}else j.$options[a]=angular.isString(b)&&b.match(/^".+"$/)?+new Date(b.substr(1,b.length-2)):+new Date(b);!isNaN(j.$options[a])&&j.$build(!1)})}),a.$watch(c.ngModel,function(){j.update(d.$dateValue)},!0);var k=g({format:i.dateFormat,lang:i.lang,strict:i.strictFormat});d.$parsers.unshift(function(a){if(!a)return void d.$setValidity("date",!0);var b=k.parse(a,d.$dateValue);if(!b||isNaN(b.getTime()))d.$setValidity("date",!1);else{var c=b.getTime()>=i.minDate&&b.getTime()<=i.maxDate;d.$setValidity("date",c),c&&(d.$dateValue=b)}return"string"===i.dateType?e(a,i.dateFormat):"number"===i.dateType?d.$dateValue.getTime():"iso"===i.dateType?d.$dateValue.toISOString():new Date(d.$dateValue)}),d.$formatters.push(function(a){if(!angular.isUndefined(a)&&null!==a){var b=angular.isDate(a)?a:new Date(a);return d.$dateValue=b,d.$dateValue}}),d.$render=function(){b.val(!d.$dateValue||isNaN(d.$dateValue.getTime())?"":e(d.$dateValue,i.dateFormat))},a.$on("$destroy",function(){j.destroy(),i=null,j=null})}}}]).provider("datepickerViews",function(){function a(a,b){for(var c=[];a.length>0;)c.push(a.splice(0,b));return c}this.defaults={dayFormat:"dd",daySplit:7};this.$get=["$locale","$sce","dateFilter",function(b,c,d){return function(e){var f=e.$scope,g=e.$options,h=b.DATETIME_FORMATS.SHORTDAY,i=h.slice(g.startWeek).concat(h.slice(0,g.startWeek)),j=c.trustAsHtml(''+i.join('')+""),k=e.$date||new Date,l={year:k.getFullYear(),month:k.getMonth(),date:k.getDate()},m=(6e4*k.getTimezoneOffset(),[{format:"dd",split:7,steps:{month:1},update:function(a,b){!this.built||b||a.getFullYear()!==l.year||a.getMonth()!==l.month?(angular.extend(l,{year:e.$date.getFullYear(),month:e.$date.getMonth(),date:e.$date.getDate()}),e.$build()):a.getDate()!==l.date&&(l.date=e.$date.getDate(),e.$updateSelected())},build:function(){for(var b,c=new Date(l.year,l.month,1),h=new Date(+c-864e5*(c.getDay()-g.startWeek)),i=[],k=0;42>k;k++)b=new Date(h.getFullYear(),h.getMonth(),h.getDate()+k),i.push({date:b,label:d(b,this.format),selected:e.$date&&this.isSelected(b),muted:b.getMonth()!==l.month,disabled:this.isDisabled(b)});f.title=d(c,"MMMM yyyy"),f.labels=j,f.rows=a(i,this.split),this.built=!0},isSelected:function(a){return e.$date&&a.getFullYear()===e.$date.getFullYear()&&a.getMonth()===e.$date.getMonth()&&a.getDate()===e.$date.getDate()},isDisabled:function(a){return a.getTime()g.maxDate},onKeyDown:function(a){var b=e.$date.getTime();37===a.keyCode?e.select(new Date(b-864e5),!0):38===a.keyCode?e.select(new Date(b-6048e5),!0):39===a.keyCode?e.select(new Date(b+864e5),!0):40===a.keyCode&&e.select(new Date(b+6048e5),!0)}},{name:"month",format:"MMM",split:4,steps:{year:1},update:function(a){this.built&&a.getFullYear()===l.year?a.getMonth()!==l.month&&(angular.extend(l,{month:e.$date.getMonth(),date:e.$date.getDate()}),e.$updateSelected()):(angular.extend(l,{year:e.$date.getFullYear(),month:e.$date.getMonth(),date:e.$date.getDate()}),e.$build())},build:function(){for(var b,c=(new Date(l.year,0,1),[]),g=0;12>g;g++)b=new Date(l.year,g,1),c.push({date:b,label:d(b,this.format),selected:e.$isSelected(b),disabled:this.isDisabled(b)});f.title=d(b,"yyyy"),f.labels=!1,f.rows=a(c,this.split),this.built=!0},isSelected:function(a){return e.$date&&a.getFullYear()===e.$date.getFullYear()&&a.getMonth()===e.$date.getMonth()},isDisabled:function(a){var b=+new Date(a.getFullYear(),a.getMonth()+1,0);return bg.maxDate},onKeyDown:function(a){var b=e.$date.getMonth();37===a.keyCode?e.select(e.$date.setMonth(b-1),!0):38===a.keyCode?e.select(e.$date.setMonth(b-4),!0):39===a.keyCode?e.select(e.$date.setMonth(b+1),!0):40===a.keyCode&&e.select(e.$date.setMonth(b+4),!0)}},{name:"year",format:"yyyy",split:4,steps:{year:12},update:function(a,b){!this.built||b||parseInt(a.getFullYear()/20,10)!==parseInt(l.year/20,10)?(angular.extend(l,{year:e.$date.getFullYear(),month:e.$date.getMonth(),date:e.$date.getDate()}),e.$build()):a.getFullYear()!==l.year&&(angular.extend(l,{year:e.$date.getFullYear(),month:e.$date.getMonth(),date:e.$date.getDate()}),e.$updateSelected())},build:function(){for(var b,c=l.year-l.year%(3*this.split),g=[],h=0;12>h;h++)b=new Date(c+h,0,1),g.push({date:b,label:d(b,this.format),selected:e.$isSelected(b),disabled:this.isDisabled(b)});f.title=g[0].label+"-"+g[g.length-1].label,f.labels=!1,f.rows=a(g,this.split),this.built=!0},isSelected:function(a){return e.$date&&a.getFullYear()===e.$date.getFullYear()},isDisabled:function(a){var b=+new Date(a.getFullYear()+1,0,0);return bg.maxDate},onKeyDown:function(a){var b=e.$date.getFullYear();37===a.keyCode?e.select(e.$date.setYear(b-1),!0):38===a.keyCode?e.select(e.$date.setYear(b-4),!0):39===a.keyCode?e.select(e.$date.setYear(b+1),!0):40===a.keyCode&&e.select(e.$date.setYear(b+4),!0)}}]);return{views:g.minView?Array.prototype.slice.call(m,g.minView):m,viewDate:l}}}]}),angular.module("mgcrea.ngStrap.dropdown",["mgcrea.ngStrap.tooltip"]).provider("$dropdown",function(){var a=this.defaults={animation:"am-fade",prefixClass:"dropdown",placement:"bottom-left",template:"dropdown/dropdown.tpl.html",trigger:"click",container:!1,keyboard:!0,html:!1,delay:0};this.$get=["$window","$rootScope","$tooltip",function(b,c,d){function e(b,e){function h(a){return a.target!==b[0]?a.target!==b[0]&&i.hide():void 0}{var i={},j=angular.extend({},a,e);i.$scope=j.scope&&j.scope.$new()||c.$new()}i=d(b,j),i.$onKeyDown=function(a){if(/(38|40)/.test(a.keyCode)){a.preventDefault(),a.stopPropagation();var b=angular.element(i.$element[0].querySelectorAll("li:not(.divider) a"));if(b.length){var c;angular.forEach(b,function(a,b){g&&g.call(a,":focus")&&(c=b)}),38===a.keyCode&&c>0?c--:40===a.keyCode&&c1){var g=f.search(c[b]);a=a.split(c[b]).join(""),m[c[b]]&&(d[g]=m[c[b]])}return angular.forEach(d,function(a){e.push(a)}),e}function f(a){return a.replace(/\//g,"[\\/]").replace("/-/g","[-]").replace(/\./g,"[.]").replace(/\\s/g,"[\\s]")}function g(a){var b,c=Object.keys(l),d=a;for(b=0;bj?d=setTimeout(i,b-j):(d=null,c||(h=a.apply(f,e)))},j=c&&!d;return d||(d=setTimeout(i,b)),j&&(h=a.apply(f,e)),h}}).constant("throttle",function(a,b,c){var d,e,f,g=null,h=0;c||(c={});var i=function(){h=c.leading===!1?0:new Date,g=null,f=a.apply(d,e)};return function(){var j=new Date;h||c.leading!==!1||(h=j);var k=b-(j-h);return d=this,e=arguments,0>=k?(clearTimeout(g),g=null,h=j,f=a.apply(d,e)):g||c.trailing===!1||(g=setTimeout(i,k)),f}}),angular.module("mgcrea.ngStrap.helpers.dimensions",[]).factory("dimensions",["$document","$window",function(){var b=(angular.element,{}),c=b.nodeName=function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()};b.css=function(b,c,d){var e;return e=b.currentStyle?b.currentStyle[c]:a.getComputedStyle?a.getComputedStyle(b)[c]:b.style[c],d===!0?parseFloat(e)||0:e},b.offset=function(b){var c=b.getBoundingClientRect(),d=b.ownerDocument;return{width:b.offsetWidth,height:b.offsetHeight,top:c.top+(a.pageYOffset||d.documentElement.scrollTop)-(d.documentElement.clientTop||0),left:c.left+(a.pageXOffset||d.documentElement.scrollLeft)-(d.documentElement.clientLeft||0)}},b.position=function(a){var e,f,g={top:0,left:0};return"fixed"===b.css(a,"position")?f=a.getBoundingClientRect():(e=d(a),f=b.offset(a),f=b.offset(a),c(e,"html")||(g=b.offset(e)),g.top+=b.css(e,"borderTopWidth",!0),g.left+=b.css(e,"borderLeftWidth",!0)),{width:a.offsetWidth,height:a.offsetHeight,top:f.top-g.top-b.css(a,"marginTop",!0),left:f.left-g.left-b.css(a,"marginLeft",!0)}};var d=function(a){var d=a.ownerDocument,e=a.offsetParent||d;if(c(e,"#document"))return d.documentElement;for(;e&&!c(e,"html")&&"static"===b.css(e,"position");)e=e.offsetParent;return e||d.documentElement};return b.height=function(a,c){var d=a.offsetHeight;return c?d+=b.css(a,"marginTop",!0)+b.css(a,"marginBottom",!0):d-=b.css(a,"paddingTop",!0)+b.css(a,"paddingBottom",!0)+b.css(a,"borderTopWidth",!0)+b.css(a,"borderBottomWidth",!0),d},b.width=function(a,c){var d=a.offsetWidth;return c?d+=b.css(a,"marginLeft",!0)+b.css(a,"marginRight",!0):d-=b.css(a,"paddingLeft",!0)+b.css(a,"paddingRight",!0)+b.css(a,"borderLeftWidth",!0)+b.css(a,"borderRightWidth",!0),d},b}]),angular.module("mgcrea.ngStrap.helpers.parseOptions",[]).provider("$parseOptions",function(){var a=this.defaults={regexp:/^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w]*)|(?:\(\s*([\$\w][\$\w]*)\s*,\s*([\$\w][\$\w]*)\s*\)))\s+in\s+(.*?)(?:\s+track\s+by\s+(.*?))?$/};this.$get=["$parse","$q",function(b,c){function d(d,e){function f(a){return a.map(function(a,b){var c,d,e={};return e[k]=a,c=j(e),d=n(e)||b,{label:c,value:d}})}var g={},h=angular.extend({},a,e);g.$values=[];var i,j,k,l,m,n,o;return g.init=function(){g.$match=i=d.match(h.regexp),j=b(i[2]||i[1]),k=i[4]||i[6],l=i[5],m=b(i[3]||""),n=b(i[2]?i[1]:k),o=b(i[7])},g.valuesFn=function(a,b){return c.when(o(a,b)).then(function(a){return g.$values=a?f(a):{},g.$values})},g.init(),g}return d}]}),angular.module("mgcrea.ngStrap.modal",["mgcrea.ngStrap.helpers.dimensions"]).provider("$modal",function(){var a=this.defaults={animation:"am-fade",backdropAnimation:"am-fade",prefixClass:"modal",placement:"top",template:"modal/modal.tpl.html",contentTemplate:!1,container:!1,element:null,backdrop:!0,keyboard:!0,html:!1,show:!0};this.$get=["$window","$rootScope","$compile","$q","$templateCache","$http","$animate","$timeout","dimensions",function(c,d,e,f,g,h,i){function j(b){function c(a){a.target===a.currentTarget&&("static"===g.backdrop?f.focus():f.hide())}var f={},g=angular.extend({},a,b);f.$promise=l(g.template);var h=f.$scope=g.scope&&g.scope.$new()||d.$new();g.element||g.container||(g.container="body"),m(["title","content"],function(a){g[a]&&(h[a]=g[a])}),h.$hide=function(){h.$$postDigest(function(){f.hide()})},h.$show=function(){h.$$postDigest(function(){f.show()})},h.$toggle=function(){h.$$postDigest(function(){f.toggle()})},g.contentTemplate&&(f.$promise=f.$promise.then(function(a){var c=angular.element(a);return l(g.contentTemplate).then(function(a){var d=k('[ng-bind="content"]',c[0]).removeAttr("ng-bind").html(a);return b.template||d.next().remove(),c[0].outerHTML})}));var j,r,s=angular.element('
        ');return f.$promise.then(function(a){angular.isObject(a)&&(a=a.data),g.html&&(a=a.replace(q,'ng-bind-html="')),a=n.apply(a),j=e(a),f.init()}),f.init=function(){g.show&&h.$$postDigest(function(){f.show()})},f.destroy=function(){r&&(r.remove(),r=null),s&&(s.remove(),s=null),h.$destroy()},f.show=function(){var a=g.container?k(g.container):null,b=g.container?null:g.element;r=f.$element=j(h,function(){}),r.css({display:"block"}).addClass(g.placement),g.animation&&(g.backdrop&&s.addClass(g.backdropAnimation),r.addClass(g.animation)),g.backdrop&&i.enter(s,p,null,function(){}),i.enter(r,a,b,function(){}),h.$isShown=!0,h.$$phase||h.$digest();var d=r[0];o(function(){d.focus()}),p.addClass(g.prefixClass+"-open"),g.animation&&p.addClass(g.prefixClass+"-with-"+g.animation),g.backdrop&&(r.on("click",c),s.on("click",c)),g.keyboard&&r.on("keyup",f.$onKeyUp)},f.hide=function(){i.leave(r,function(){p.removeClass(g.prefixClass+"-open"),g.animation&&p.addClass(g.prefixClass+"-with-"+g.animation)}),g.backdrop&&i.leave(s,function(){}),h.$isShown=!1,h.$$phase||h.$digest(),g.backdrop&&(r.off("click",c),s.off("click",c)),g.keyboard&&r.off("keyup",f.$onKeyUp)},f.toggle=function(){h.$isShown?f.hide():f.show()},f.focus=function(){r[0].focus()},f.$onKeyUp=function(a){27===a.which&&f.hide()},f}function k(a,c){return angular.element((c||b).querySelectorAll(a))}function l(a){return f.when(g.get(a)||h.get(a)).then(function(b){return angular.isObject(b)?(g.put(a,b.data),b.data):b})}var m=angular.forEach,n=String.prototype.trim,o=c.requestAnimationFrame||c.setTimeout,p=angular.element(c.document.body),q=/ng-bind="/gi;return j}]}).directive("bsModal",["$window","$location","$sce","$modal",function(a,b,c,d){return{restrict:"EAC",scope:!0,link:function(a,b,e){var f={scope:a,element:b,show:!1};angular.forEach(["template","contentTemplate","placement","backdrop","keyboard","html","container","animation"],function(a){angular.isDefined(e[a])&&(f[a]=e[a])}),angular.forEach(["title","content"],function(b){e[b]&&e.$observe(b,function(d){a[b]=c.trustAsHtml(d)})}),e.bsModal&&a.$watch(e.bsModal,function(b){angular.isObject(b)?angular.extend(a,b):a.content=b},!0);var g=d(f);b.on(e.trigger||"click",g.toggle),a.$on("$destroy",function(){g.destroy(),f=null,g=null})}}}]),angular.module("mgcrea.ngStrap.navbar",[]).provider("$navbar",function(){var a=this.defaults={activeClass:"active",routeAttr:"data-match-route",strict:!1};this.$get=function(){return{defaults:a}}}).directive("bsNavbar",["$window","$location","$navbar",function(a,b,c){var d=c.defaults;return{restrict:"A",link:function(a,c,e){var f=angular.copy(d);angular.forEach(Object.keys(d),function(a){angular.isDefined(e[a])&&(f[a]=e[a])}),a.$watch(function(){return b.path()},function(a){var b=c[0].querySelectorAll("li["+f.routeAttr+"]");angular.forEach(b,function(b){var c=angular.element(b),d=c.attr(f.routeAttr).replace("/","\\/");f.strict&&(d="^"+d+"$");var e=new RegExp(d,["i"]);e.test(a)?c.addClass(f.activeClass):c.removeClass(f.activeClass)})})}}}]),angular.module("mgcrea.ngStrap.popover",["mgcrea.ngStrap.tooltip"]).provider("$popover",function(){var a=this.defaults={animation:"am-fade",placement:"right",template:"popover/popover.tpl.html",contentTemplate:!1,trigger:"click",keyboard:!0,html:!1,title:"",content:"",delay:0,container:!1};this.$get=["$tooltip",function(b){function c(c,d){var e=angular.extend({},a,d),f=b(c,e);return e.content&&(f.$scope.content=e.content),f}return c}]}).directive("bsPopover",["$window","$location","$sce","$popover",function(a,b,c,d){var e=a.requestAnimationFrame||a.setTimeout;return{restrict:"EAC",scope:!0,link:function(a,b,f){var g={scope:a};angular.forEach(["template","contentTemplate","placement","container","delay","trigger","keyboard","html","animation"],function(a){angular.isDefined(f[a])&&(g[a]=f[a])}),angular.forEach(["title","content"],function(b){f[b]&&f.$observe(b,function(d,f){a[b]=c.trustAsHtml(d),angular.isDefined(f)&&e(function(){h&&h.$applyPlacement()})})}),f.bsPopover&&a.$watch(f.bsPopover,function(b,c){angular.isObject(b)?angular.extend(a,b):a.content=b,angular.isDefined(c)&&e(function(){h&&h.$applyPlacement()})},!0);var h=d(b,g);a.$on("$destroy",function(){h.destroy(),g=null,h=null})}}}]),angular.module("mgcrea.ngStrap.scrollspy",["mgcrea.ngStrap.helpers.debounce","mgcrea.ngStrap.helpers.dimensions"]).provider("$scrollspy",function(){var a=this.$$spies={},c=this.defaults={debounce:150,throttle:100,offset:100};this.$get=["$window","$document","$rootScope","dimensions","debounce","throttle",function(d,e,f,g,h,i){function j(a,b){return a[0].nodeName&&a[0].nodeName.toLowerCase()===b.toLowerCase()}function k(e){var k=angular.extend({},c,e);k.element||(k.element=n);var o=j(k.element,"body"),p=o?l:k.element,q=o?"window":k.id;if(a[q])return a[q].$$count++,a[q];var r,s,t,u,v,w,x,y,z={},A=z.$trackedElements=[],B=[];return z.init=function(){this.$$count=1,u=h(this.checkPosition,k.debounce),v=i(this.checkPosition,k.throttle),p.on("click",this.checkPositionWithEventLoop),l.on("resize",u),p.on("scroll",v),w=h(this.checkOffsets,k.debounce),r=f.$on("$viewContentLoaded",w),s=f.$on("$includeContentLoaded",w),w(),q&&(a[q]=z)},z.destroy=function(){this.$$count--,this.$$count>0||(p.off("click",this.checkPositionWithEventLoop),l.off("resize",u),p.off("scroll",u),r(),s())},z.checkPosition=function(){if(B.length){if(y=(o?d.pageYOffset:p.prop("scrollTop"))||0,x=Math.max(d.innerHeight,m.prop("clientHeight")),yB[a+1].offsetTop))return z.$activateElement(B[a])}},z.checkPositionWithEventLoop=function(){setTimeout(this.checkPosition,1)},z.$activateElement=function(a){if(t){var b=z.$getTrackedElement(t);b&&(b.source.removeClass("active"),j(b.source,"li")&&j(b.source.parent().parent(),"li")&&b.source.parent().parent().removeClass("active"))}t=a.target,a.source.addClass("active"),j(a.source,"li")&&j(a.source.parent().parent(),"li")&&a.source.parent().parent().addClass("active")},z.$getTrackedElement=function(a){return A.filter(function(b){return b.target===a})[0]},z.checkOffsets=function(){angular.forEach(A,function(a){var c=b.querySelector(a.target);a.offsetTop=c?g.offset(c).top:null,k.offset&&null!==a.offsetTop&&(a.offsetTop-=1*k.offset)}),B=A.filter(function(a){return null!==a.offsetTop}).sort(function(a,b){return a.offsetTop-b.offsetTop}),u()},z.trackElement=function(a,b){A.push({target:a,source:b})},z.untrackElement=function(a,b){for(var c,d=A.length;d--;)if(A[d].target===a&&A[d].source===b){c=d;break}A=A.splice(c,1)},z.activate=function(a){A[a].addClass("active")},z.init(),z}var l=angular.element(d),m=angular.element(e.prop("documentElement")),n=angular.element(d.document.body);return k}]}).directive("bsScrollspy",["$rootScope","debounce","dimensions","$scrollspy",function(a,b,c,d){return{restrict:"EAC",link:function(a,b,c){var e={scope:a}; -angular.forEach(["offset","target"],function(a){angular.isDefined(c[a])&&(e[a]=c[a])});var f=d(e);f.trackElement(e.target,b),a.$on("$destroy",function(){f.untrackElement(e.target,b),f.destroy(),e=null,f=null})}}}]).directive("bsScrollspyList",["$rootScope","debounce","dimensions","$scrollspy",function(){return{restrict:"A",compile:function(a){var b=a[0].querySelectorAll("li > a[href]");angular.forEach(b,function(a){var b=angular.element(a);b.parent().attr("bs-scrollspy","").attr("data-target",b.attr("href"))})}}}]),angular.module("mgcrea.ngStrap.select",["mgcrea.ngStrap.tooltip","mgcrea.ngStrap.helpers.parseOptions"]).provider("$select",function(){var a=this.defaults={animation:"am-fade",prefixClass:"select",placement:"bottom-left",template:"select/select.tpl.html",trigger:"focus",container:!1,keyboard:!0,html:!1,delay:0,multiple:!1,sort:!0,caretHtml:' ',placeholder:"Choose among the following...",maxLength:3,maxLengthHtml:"selected"};this.$get=["$window","$document","$rootScope","$tooltip",function(b,c,d,e){function f(b,c,d){var f={},h=angular.extend({},a,d);f=e(b,h);var i=d.scope,j=f.$scope;j.$matches=[],j.$activeIndex=0,j.$isMultiple=h.multiple,j.$activate=function(a){j.$$postDigest(function(){f.activate(a)})},j.$select=function(a){j.$$postDigest(function(){f.select(a)})},j.$isVisible=function(){return f.$isVisible()},j.$isActive=function(a){return f.$isActive(a)},f.update=function(a){j.$matches=a,f.$updateActiveIndex()},f.activate=function(a){return h.multiple?(j.$activeIndex.sort(),f.$isActive(a)?j.$activeIndex.splice(j.$activeIndex.indexOf(a),1):j.$activeIndex.push(a),h.sort&&j.$activeIndex.sort()):j.$activeIndex=a,j.$activeIndex},f.select=function(a){var d=j.$matches[a].value;f.activate(a),c.$setViewValue(h.multiple?j.$activeIndex.map(function(a){return j.$matches[a].value}):d),c.$render(),i&&i.$digest(),h.multiple||("focus"===h.trigger?b[0].blur():f.$isShown&&f.hide()),j.$emit("$select.select",d,a)},f.$updateActiveIndex=function(){c.$modelValue&&j.$matches.length?j.$activeIndex=h.multiple&&angular.isArray(c.$modelValue)?c.$modelValue.map(function(a){return f.$getIndex(a)}):f.$getIndex(c.$modelValue):j.$activeIndex>=j.$matches.length&&(j.$activeIndex=h.multiple?[]:0)},f.$isVisible=function(){return h.minLength&&c?j.$matches.length&&c.$viewValue.length>=h.minLength:j.$matches.length},f.$isActive=function(a){return h.multiple?-1!==j.$activeIndex.indexOf(a):j.$activeIndex===a},f.$getIndex=function(a){var b=j.$matches.length,c=b;if(b){for(c=b;c--&&j.$matches[c].value!==a;);if(!(0>c))return c}},f.$onMouseDown=function(a){if(a.preventDefault(),a.stopPropagation(),g){var b=angular.element(a.target);b.triggerHandler("click")}},f.$onKeyDown=function(a){if(/(38|40|13)/.test(a.keyCode)){if(a.preventDefault(),a.stopPropagation(),13===a.keyCode)return f.select(j.$activeIndex);38===a.keyCode&&j.$activeIndex>0?j.$activeIndex--:40===a.keyCode&&j.$activeIndex'),i.after(b)}var j=e(c.ngOptions),k=d(b,g,h),l=j.$match[7].replace(/\|.+/,"").trim();a.$watch(l,function(){j.valuesFn(a,g).then(function(a){k.update(a),g.$render()})},!0),a.$watch(c.ngModel,function(){k.$updateActiveIndex()},!0),g.$render=function(){var a,d;h.multiple&&angular.isArray(g.$modelValue)?(a=g.$modelValue.map(function(a){return d=k.$getIndex(a),angular.isDefined(d)?k.$scope.$matches[d].label:!1}).filter(angular.isDefined),a=a.length>(h.maxLength||f.maxLength)?a.length+" "+(h.maxLengthHtml||f.maxLengthHtml):a.join(", ")):(d=k.$getIndex(g.$modelValue),a=angular.isDefined(d)?k.$scope.$matches[d].label:!1),b.html((a?a:c.placeholder||f.placeholder)+f.caretHtml)},a.$on("$destroy",function(){k.destroy(),h=null,k=null})}}}]),angular.module("mgcrea.ngStrap.tab",[]).run(["$templateCache",function(a){a.put("$pane","{{pane.content}}")}]).provider("$tab",function(){var a=this.defaults={animation:"am-fade",template:"tab/tab.tpl.html"};this.$get=function(){return{defaults:a}}}).directive("bsTabs",["$window","$animate","$tab",function(a,b,c){var d=c.defaults;return{restrict:"EAC",scope:!0,require:"?ngModel",templateUrl:function(a,b){return b.template||d.template},link:function(a,b,c,e){var f=d;angular.forEach(["animation"],function(a){angular.isDefined(c[a])&&(f[a]=c[a])}),c.bsTabs&&a.$watch(c.bsTabs,function(b){a.panes=b},!0),b.addClass("tabs"),f.animation&&b.addClass(f.animation),a.active=a.activePane=0,a.setActive=function(b){a.active=b,e&&e.$setViewValue(b)},e&&(e.$render=function(){a.active=1*e.$modelValue})}}}]),angular.module("mgcrea.ngStrap.timepicker",["mgcrea.ngStrap.helpers.dateParser","mgcrea.ngStrap.tooltip"]).provider("$timepicker",function(){var a=this.defaults={animation:"am-fade",prefixClass:"timepicker",placement:"bottom-left",template:"timepicker/timepicker.tpl.html",trigger:"focus",container:!1,keyboard:!0,html:!1,delay:0,useNative:!0,timeType:"date",timeFormat:"shortTime",autoclose:!1,minTime:-1/0,maxTime:+1/0,length:5,hourStep:1,minuteStep:5};this.$get=["$window","$document","$rootScope","$sce","$locale","dateFilter","$tooltip",function(b,c,d,e,f,g,h){function i(b,c,d){function e(a,c){if(b[0].createTextRange){var d=b[0].createTextRange();d.collapse(!0),d.moveStart("character",a),d.moveEnd("character",c),d.select()}else b[0].setSelectionRange?b[0].setSelectionRange(a,c):angular.isUndefined(b[0].selectionStart)&&(b[0].selectionStart=a,b[0].selectionEnd=c)}function i(){b[0].focus()}var l=h(b,angular.extend({},a,d)),m=d.scope,n=l.$options,o=l.$scope,p=0,q=c.$dateValue||new Date,r={hour:q.getHours(),meridian:q.getHours()<12,minute:q.getMinutes(),second:q.getSeconds(),millisecond:q.getMilliseconds()},s=f.DATETIME_FORMATS[n.timeFormat]||n.timeFormat,t=/(h+)[:]?(m+)[ ]?(a?)/i.exec(s).slice(1);o.$select=function(a,b){l.select(a,b)},o.$moveIndex=function(a,b){l.$moveIndex(a,b)},o.$switchMeridian=function(a){l.switchMeridian(a)},l.update=function(a){angular.isDate(a)&&!isNaN(a.getTime())?(l.$date=a,angular.extend(r,{hour:a.getHours(),minute:a.getMinutes(),second:a.getSeconds(),millisecond:a.getMilliseconds()}),l.$build()):l.$isBuilt||l.$build()},l.select=function(a,b,d){isNaN(c.$dateValue.getTime())&&(c.$dateValue=new Date(1970,0,1)),angular.isDate(a)||(a=new Date(a)),0===b?c.$dateValue.setHours(a.getHours()):1===b&&c.$dateValue.setMinutes(a.getMinutes()),c.$setViewValue(c.$dateValue),c.$render(),n.autoclose&&!d&&l.hide(!0)},l.switchMeridian=function(a){var b=(a||c.$dateValue).getHours();c.$dateValue.setHours(12>b?b+12:b-12),c.$render()},l.$build=function(){var a,b,c=o.midIndex=parseInt(n.length/2,10),d=[];for(a=0;an.maxTime},l.$moveIndex=function(a,b){var c;0===b?(c=new Date(1970,0,1,r.hour+a*n.length,r.minute),angular.extend(r,{hour:c.getHours()})):1===b&&(c=new Date(1970,0,1,r.hour,r.minute+a*n.length*5),angular.extend(r,{minute:c.getMinutes()})),l.$build()},l.$onMouseDown=function(a){if("input"!==a.target.nodeName.toLowerCase()&&a.preventDefault(),a.stopPropagation(),j){var b=angular.element(a.target);"button"!==b[0].nodeName.toLowerCase()&&(b=b.parent()),b.triggerHandler("click")}},l.$onKeyDown=function(a){if(/(38|37|39|40|13)/.test(a.keyCode)&&!a.shiftKey&&!a.altKey){if(a.preventDefault(),a.stopPropagation(),13===a.keyCode)return l.hide(!0);var b=new Date(l.$date),c=b.getHours(),d=g(b,"h").length,f=b.getMinutes(),h=g(b,"mm").length,i=/(37|39)/.test(a.keyCode),j=2+1*!!t[2];if(i&&(37===a.keyCode?p=1>p?j-1:p-1:39===a.keyCode&&(p=j-1>p?p+1:0)),0===p){if(i)return e(0,d);38===a.keyCode?b.setHours(c-n.hourStep):40===a.keyCode&&b.setHours(c+n.hourStep)}else if(1===p){if(i)return e(d+1,d+1+h);38===a.keyCode?b.setMinutes(f-n.minuteStep):40===a.keyCode&&b.setMinutes(f+n.minuteStep)}else if(2===p){if(i)return e(d+1+h+1,d+1+h+3);l.switchMeridian()}l.select(b,p,!0),m.$digest()}};var u=l.init;l.init=function(){return k&&n.useNative?(b.prop("type","time"),void b.css("-webkit-appearance","textfield")):(j&&(b.prop("type","text"),b.attr("readonly","true"),b.on("click",i)),void u())};var v=l.destroy;l.destroy=function(){k&&n.useNative&&b.off("click",i),v()};var w=l.show;l.show=function(){w(),setTimeout(function(){l.$element.on(j?"touchstart":"mousedown",l.$onMouseDown),n.keyboard&&b.on("keydown",l.$onKeyDown)})};var x=l.hide;return l.hide=function(a){l.$element.off(j?"touchstart":"mousedown",l.$onMouseDown),n.keyboard&&b.off("keydown",l.$onKeyDown),x(a)},l}var j=(angular.element(b.document.body),"createTouch"in b.document),k=/(ip(a|o)d|iphone|android)/gi.test(b.navigator.userAgent);return a.lang||(a.lang=f.id),i.defaults=a,i}]}).directive("bsTimepicker",["$window","$parse","$q","$locale","dateFilter","$timepicker","$dateParser","$timeout",function(a,b,c,d,e,f,g){{var h=f.defaults,i=/(ip(a|o)d|iphone|android)/gi.test(a.navigator.userAgent);a.requestAnimationFrame||a.setTimeout}return{restrict:"EAC",require:"ngModel",link:function(a,b,c,d){var j={scope:a,controller:d};angular.forEach(["placement","container","delay","trigger","keyboard","html","animation","template","autoclose","timeType","timeFormat","useNative","lang"],function(a){angular.isDefined(c[a])&&(j[a]=c[a])}),i&&(j.useNative||h.useNative)&&(j.timeFormat="HH:mm");var k=f(b,d,j);j=k.$options;var l=g({format:j.timeFormat,lang:j.lang});angular.forEach(["minTime","maxTime"],function(a){angular.isDefined(c[a])&&c.$observe(a,function(b){k.$options[a]="now"===b?(new Date).setFullYear(1970,0,1):angular.isString(b)&&b.match(/^".+"$/)?+new Date(b.substr(1,b.length-2)):l.parse(b),!isNaN(k.$options[a])&&k.$build()})}),a.$watch(c.ngModel,function(){k.update(d.$dateValue)},!0),d.$parsers.unshift(function(a){if(!a)return void d.$setValidity("date",!0);var b=l.parse(a,d.$dateValue);if(!b||isNaN(b.getTime()))d.$setValidity("date",!1);else{var c=b.getTime()>=j.minTime&&b.getTime()<=j.maxTime;d.$setValidity("date",c),c&&(d.$dateValue=b)}return"string"===j.timeType?e(a,j.timeFormat):"number"===j.timeType?d.$dateValue.getTime():"iso"===j.timeType?d.$dateValue.toISOString():d.$dateValue}),d.$formatters.push(function(a){var b="string"===j.timeType?l.parse(a,d.$dateValue):new Date(a);return d.$dateValue=b,d.$dateValue}),d.$render=function(){b.val(isNaN(d.$dateValue.getTime())?"":e(d.$dateValue,j.timeFormat))},a.$on("$destroy",function(){k.destroy(),j=null,k=null})}}}]),angular.module("mgcrea.ngStrap.tooltip",["ngAnimate","mgcrea.ngStrap.helpers.dimensions"]).provider("$tooltip",function(){var a=this.defaults={animation:"am-fade",prefixClass:"tooltip",container:!1,placement:"top",template:"tooltip/tooltip.tpl.html",contentTemplate:!1,trigger:"hover focus",keyboard:!1,html:!1,show:!1,title:"",type:"",delay:0};this.$get=["$window","$rootScope","$compile","$q","$templateCache","$http","$animate","$timeout","dimensions","$$animateReflow",function(c,d,e,f,g,h,i,j,k,l){function m(b,c){function f(){return"body"===j.container?k.offset(b[0]):k.position(b[0])}function g(a,b,c,d){var e,f=a.split("-");switch(f[0]){case"right":e={top:b.top+b.height/2-d/2,left:b.left+b.width};break;case"bottom":e={top:b.top+b.height,left:b.left+b.width/2-c/2};break;case"left":e={top:b.top+b.height/2-d/2,left:b.left-c};break;default:e={top:b.top-d,left:b.left+b.width/2-c/2}}if(!f[1])return e;if("top"===f[0]||"bottom"===f[0])switch(f[1]){case"left":e.left=b.left;break;case"right":e.left=b.left+b.width-c}else if("left"===f[0]||"right"===f[0])switch(f[1]){case"top":e.top=b.top-d;break;case"bottom":e.top=b.top+b.height}return e}var h={},j=h.$options=angular.extend({},a,c);h.$promise=o(j.template);var m=h.$scope=j.scope&&j.scope.$new()||d.$new();j.delay&&angular.isString(j.delay)&&(j.delay=parseFloat(j.delay)),j.title&&(h.$scope.title=j.title),m.$hide=function(){m.$$postDigest(function(){h.hide()})},m.$show=function(){m.$$postDigest(function(){h.show()})},m.$toggle=function(){m.$$postDigest(function(){h.toggle()})},h.$isShown=m.$isShown=!1;var s,t;j.contentTemplate&&(h.$promise=h.$promise.then(function(a){var b=angular.element(a);return o(j.contentTemplate).then(function(a){return n('[ng-bind="content"]',b[0]).removeAttr("ng-bind").html(a),b[0].outerHTML})}));var u,v,w,x;return h.$promise.then(function(a){angular.isObject(a)&&(a=a.data),j.html&&(a=a.replace(r,'ng-bind-html="')),a=p.apply(a),w=a,u=e(a),h.init()}),h.init=function(){j.delay&&angular.isNumber(j.delay)&&(j.delay={show:j.delay,hide:j.delay}),"self"===j.container?x=b:j.container&&(x=n(j.container));var a=j.trigger.split(" ");angular.forEach(a,function(a){"click"===a?b.on("click",h.toggle):"manual"!==a&&(b.on("hover"===a?"mouseenter":"focus",h.enter),b.on("hover"===a?"mouseleave":"blur",h.leave),"hover"!==a&&b.on(q?"touchstart":"mousedown",h.$onFocusElementMouseDown))}),j.show&&m.$$postDigest(function(){"focus"===j.trigger?b[0].focus():h.show()})},h.destroy=function(){for(var a=j.trigger.split(" "),c=a.length;c--;){var d=a[c];"click"===d?b.off("click",h.toggle):"manual"!==d&&(b.off("hover"===d?"mouseenter":"focus",h.enter),b.off("hover"===d?"mouseleave":"blur",h.leave),"hover"!==d&&b.off(q?"touchstart":"mousedown",h.$onFocusElementMouseDown))}v&&(v.remove(),v=null),m.$destroy()},h.enter=function(){return clearTimeout(s),t="in",j.delay&&j.delay.show?void(s=setTimeout(function(){"in"===t&&h.show()},j.delay.show)):h.show()},h.show=function(){var a=j.container?x:null,c=j.container?null:b;v&&v.remove(),v=h.$element=u(m,function(){}),v.css({top:"0px",left:"0px",display:"block"}).addClass(j.placement),j.animation&&v.addClass(j.animation),j.type&&v.addClass(j.prefixClass+"-"+j.type),i.enter(v,a,c,function(){}),h.$isShown=m.$isShown=!0,m.$$phase||m.$digest(),l(h.$applyPlacement),j.keyboard&&("focus"!==j.trigger?(h.focus(),v.on("keyup",h.$onKeyUp)):b.on("keyup",h.$onFocusKeyUp))},h.leave=function(){return clearTimeout(s),t="out",j.delay&&j.delay.hide?void(s=setTimeout(function(){"out"===t&&h.hide()},j.delay.hide)):h.hide()},h.hide=function(a){return h.$isShown?(i.leave(v,function(){v=null}),h.$isShown=m.$isShown=!1,m.$$phase||m.$digest(),j.keyboard&&v.off("keyup",h.$onKeyUp),a&&"focus"===j.trigger?b[0].blur():void 0):void 0},h.toggle=function(){h.$isShown?h.leave():h.enter()},h.focus=function(){v[0].focus()},h.$applyPlacement=function(){if(v){var a=f(),b=v.prop("offsetWidth"),c=v.prop("offsetHeight"),d=g(j.placement,a,b,c);d.top+="px",d.left+="px",v.css(d)}},h.$onKeyUp=function(a){27===a.which&&h.hide()},h.$onFocusKeyUp=function(a){27===a.which&&b[0].blur()},h.$onFocusElementMouseDown=function(a){a.preventDefault(),a.stopPropagation(),h.$isShown?b[0].blur():b[0].focus()},h}function n(a,c){return angular.element((c||b).querySelectorAll(a))}function o(a){return f.when(g.get(a)||h.get(a)).then(function(b){return angular.isObject(b)?(g.put(a,b.data),b.data):b})}var p=String.prototype.trim,q="createTouch"in c.document,r=/ng-bind="/gi;return m}]}).directive("bsTooltip",["$window","$location","$sce","$tooltip","$$animateReflow",function(a,b,c,d,e){return{restrict:"EAC",scope:!0,link:function(a,b,f){var g={scope:a};angular.forEach(["template","contentTemplate","placement","container","delay","trigger","keyboard","html","animation","type"],function(a){angular.isDefined(f[a])&&(g[a]=f[a])}),angular.forEach(["title"],function(b){f[b]&&f.$observe(b,function(d,f){a[b]=c.trustAsHtml(d),angular.isDefined(f)&&e(function(){h&&h.$applyPlacement()})})}),f.bsTooltip&&a.$watch(f.bsTooltip,function(b,c){angular.isObject(b)?angular.extend(a,b):a.content=b,angular.isDefined(c)&&e(function(){h&&h.$applyPlacement()})},!0);var h=d(b,g);a.$on("$destroy",function(){h.destroy(),g=null,h=null})}}}]),angular.module("mgcrea.ngStrap.typeahead",["mgcrea.ngStrap.tooltip","mgcrea.ngStrap.helpers.parseOptions"]).provider("$typeahead",function(){var a=this.defaults={animation:"am-fade",prefixClass:"typeahead",placement:"bottom-left",template:"typeahead/typeahead.tpl.html",trigger:"focus",container:!1,keyboard:!0,html:!1,delay:0,minLength:1,filter:"filter",limit:6};this.$get=["$window","$rootScope","$tooltip",function(b,c,d){function e(b,c){var e={},f=angular.extend({},a,c),g=f.controller;e=d(b,f);var h=c.scope,i=e.$scope;i.$matches=[],i.$activeIndex=0,i.$activate=function(a){i.$$postDigest(function(){e.activate(a)})},i.$select=function(a){i.$$postDigest(function(){e.select(a)})},i.$isVisible=function(){return e.$isVisible()},e.update=function(a){i.$matches=a,i.$activeIndex>=a.length&&(i.$activeIndex=0)},e.activate=function(a){i.$activeIndex=a},e.select=function(a){var c=i.$matches[a].value;g&&(g.$setViewValue(c),g.$render(),h&&h.$digest()),"focus"===f.trigger?b[0].blur():e.$isShown&&e.hide(),i.$activeIndex=0,i.$emit("$typeahead.select",c,a)},e.$isVisible=function(){return f.minLength&&g?i.$matches.length&&angular.isString(g.$viewValue)&&g.$viewValue.length>=f.minLength:!!i.$matches.length},e.$onMouseDown=function(a){a.preventDefault(),a.stopPropagation()},e.$onKeyDown=function(a){if(/(38|40|13)/.test(a.keyCode)){if(a.preventDefault(),a.stopPropagation(),13===a.keyCode)return e.select(i.$activeIndex);38===a.keyCode&&i.$activeIndex>0?i.$activeIndex--:40===a.keyCode&&i.$activeIndexj&&(a=a.slice(0,j)),m.update(a)})}),a.$on("$destroy",function(){m.destroy(),h=null,m=null})}}}])}(window,document),function(){"use strict";angular.module("mgcrea.ngStrap.alert").run(["$templateCache",function(a){a.put("alert/alert.tpl.html",'
         
        ')}]),angular.module("mgcrea.ngStrap.aside").run(["$templateCache",function(a){a.put("aside/aside.tpl.html",'')}]),angular.module("mgcrea.ngStrap.datepicker").run(["$templateCache",function(a){a.put("datepicker/datepicker.tpl.html",'')}]),angular.module("mgcrea.ngStrap.dropdown").run(["$templateCache",function(a){a.put("dropdown/dropdown.tpl.html",'
        ')}]),angular.module("mgcrea.ngStrap.modal").run(["$templateCache",function(a){a.put("modal/modal.tpl.html",'')}]),angular.module("mgcrea.ngStrap.popover").run(["$templateCache",function(a){a.put("popover/popover.tpl.html",'

        ')}]),angular.module("mgcrea.ngStrap.select").run(["$templateCache",function(a){a.put("select/select.tpl.html",'')}]),angular.module("mgcrea.ngStrap.tab").run(["$templateCache",function(a){a.put("tab/tab.tpl.html",'
        ')}]),angular.module("mgcrea.ngStrap.timepicker").run(["$templateCache",function(a){a.put("timepicker/timepicker.tpl.html",'')}]),angular.module("mgcrea.ngStrap.tooltip").run(["$templateCache",function(a){a.put("tooltip/tooltip.tpl.html",'
        ')}]),angular.module("mgcrea.ngStrap.typeahead").run(["$templateCache",function(a){a.put("typeahead/typeahead.tpl.html",'')}])}(window,document); -//# sourceMappingURL=angular-strap.min.map \ No newline at end of file +!function(e,t,n){"use strict";angular.module("mgcrea.ngStrap",["mgcrea.ngStrap.modal","mgcrea.ngStrap.aside","mgcrea.ngStrap.alert","mgcrea.ngStrap.button","mgcrea.ngStrap.select","mgcrea.ngStrap.datepicker","mgcrea.ngStrap.timepicker","mgcrea.ngStrap.navbar","mgcrea.ngStrap.tooltip","mgcrea.ngStrap.popover","mgcrea.ngStrap.dropdown","mgcrea.ngStrap.typeahead","mgcrea.ngStrap.scrollspy","mgcrea.ngStrap.affix","mgcrea.ngStrap.tab","mgcrea.ngStrap.collapse"]),angular.module("mgcrea.ngStrap.affix",["mgcrea.ngStrap.helpers.dimensions","mgcrea.ngStrap.helpers.debounce"]).provider("$affix",function(){var e=this.defaults={offsetTop:"auto",inlineStyles:!0};this.$get=["$window","debounce","dimensions",function(t,n,a){function o(o,s){function l(e,t,n){var a=u(),o=c();return v>=a?"top":null!==e&&a+e<=t.top?"middle":null!==y&&t.top+n+$>=o-y?"bottom":"middle"}function u(){return p[0]===t?t.pageYOffset:p[0].scrollTop}function c(){return p[0]===t?t.document.body.scrollHeight:p[0].scrollHeight}var d={},f=angular.extend({},e,s),p=f.target,g="affix affix-top affix-bottom",m=!1,$=0,h=0,v=0,y=0,w=null,b=null,D=o.parent();if(f.offsetParent)if(f.offsetParent.match(/^\d+$/))for(var k=0;k<1*f.offsetParent-1;k++)D=D.parent();else D=angular.element(f.offsetParent);return d.init=function(){this.$parseOffsets(),h=a.offset(o[0]).top+$,m=!o[0].style.width,p.on("scroll",this.checkPosition),p.on("click",this.checkPositionWithEventLoop),r.on("resize",this.$debouncedOnResize),this.checkPosition(),this.checkPositionWithEventLoop()},d.destroy=function(){p.off("scroll",this.checkPosition),p.off("click",this.checkPositionWithEventLoop),r.off("resize",this.$debouncedOnResize)},d.checkPositionWithEventLoop=function(){setTimeout(d.checkPosition,1)},d.checkPosition=function(){var e=u(),t=a.offset(o[0]),n=a.height(o[0]),r=l(b,t,n);w!==r&&(w=r,o.removeClass(g).addClass("affix"+("middle"!==r?"-"+r:"")),"top"===r?(b=null,m&&o.css("width",""),f.inlineStyles&&(o.css("position",f.offsetParent?"":"relative"),o.css("top",""))):"bottom"===r?(b=f.offsetUnpin?-(1*f.offsetUnpin):t.top-e,m&&o.css("width",""),f.inlineStyles&&(o.css("position",f.offsetParent?"":"relative"),o.css("top",f.offsetParent?"":i[0].offsetHeight-y-n-h+"px"))):(b=null,m&&o.css("width",o[0].offsetWidth+"px"),f.inlineStyles&&(o.css("position","fixed"),o.css("top",$+"px"))))},d.$onResize=function(){d.$parseOffsets(),d.checkPosition()},d.$debouncedOnResize=n(d.$onResize,50),d.$parseOffsets=function(){var e=o.css("position");f.inlineStyles&&o.css("position",f.offsetParent?"":"relative"),f.offsetTop&&("auto"===f.offsetTop&&(f.offsetTop="+0"),f.offsetTop.match(/^[-+]\d+$/)?($=1*-f.offsetTop,v=f.offsetParent?a.offset(D[0]).top+1*f.offsetTop:a.offset(o[0]).top-a.css(o[0],"marginTop",!0)+1*f.offsetTop):v=1*f.offsetTop),f.offsetBottom&&(y=f.offsetParent&&f.offsetBottom.match(/^[-+]\d+$/)?c()-(a.offset(D[0]).top+a.height(D[0]))+1*f.offsetBottom+1:1*f.offsetBottom),f.inlineStyles&&o.css("position",e)},d.init(),d}var i=angular.element(t.document.body),r=angular.element(t);return o}]}).directive("bsAffix",["$affix","$window",function(e,t){return{restrict:"EAC",require:"^?bsAffixTarget",link:function(n,a,o,i){var r={scope:n,target:i?i.$element:angular.element(t)};angular.forEach(["offsetTop","offsetBottom","offsetParent","offsetUnpin","inlineStyles"],function(e){if(angular.isDefined(o[e])){var t=o[e];/true/i.test(t)&&(t=!0),/false/i.test(t)&&(t=!1),r[e]=t}});var s=e(a,r);n.$on("$destroy",function(){s&&s.destroy(),r=null,s=null})}}}]).directive("bsAffixTarget",function(){return{controller:["$element",function(e){this.$element=e}]}}),angular.module("mgcrea.ngStrap.aside",["mgcrea.ngStrap.modal"]).provider("$aside",function(){var e=this.defaults={animation:"am-fade-and-slide-right",prefixClass:"aside",prefixEvent:"aside",placement:"right",template:"aside/aside.tpl.html",contentTemplate:!1,container:!1,element:null,backdrop:!0,keyboard:!0,html:!1,show:!0};this.$get=["$modal",function(t){function n(n){var a={},o=angular.extend({},e,n);return a=t(o)}return n}]}).directive("bsAside",["$window","$sce","$aside",function(e,t,n){e.requestAnimationFrame||e.setTimeout;return{restrict:"EAC",scope:!0,link:function(e,a,o){var i={scope:e,element:a,show:!1};angular.forEach(["template","contentTemplate","placement","backdrop","keyboard","html","container","animation"],function(e){angular.isDefined(o[e])&&(i[e]=o[e])}),angular.forEach(["title","content"],function(n){o[n]&&o.$observe(n,function(a){e[n]=t.trustAsHtml(a)})}),o.bsAside&&e.$watch(o.bsAside,function(t){angular.isObject(t)?angular.extend(e,t):e.content=t},!0);var r=n(i);a.on(o.trigger||"click",r.toggle),e.$on("$destroy",function(){r&&r.destroy(),i=null,r=null})}}}]),angular.module("mgcrea.ngStrap.alert",["mgcrea.ngStrap.modal"]).provider("$alert",function(){var e=this.defaults={animation:"am-fade",prefixClass:"alert",prefixEvent:"alert",placement:null,template:"alert/alert.tpl.html",container:!1,element:null,backdrop:!1,keyboard:!0,show:!0,duration:!1,type:!1,dismissable:!0};this.$get=["$modal","$timeout",function(t,n){function a(a){var o={},i=angular.extend({},e,a);o=t(i),o.$scope.dismissable=!!i.dismissable,i.type&&(o.$scope.type=i.type);var r=o.show;return i.duration&&(o.show=function(){r(),n(function(){o.hide()},1e3*i.duration)}),o}return a}]}).directive("bsAlert",["$window","$sce","$alert",function(e,t,n){e.requestAnimationFrame||e.setTimeout;return{restrict:"EAC",scope:!0,link:function(e,a,o){var i={scope:e,element:a,show:!1};angular.forEach(["template","placement","keyboard","html","container","animation","duration","dismissable"],function(e){angular.isDefined(o[e])&&(i[e]=o[e])}),e.hasOwnProperty("title")||(e.title=""),angular.forEach(["title","content","type"],function(n){o[n]&&o.$observe(n,function(a){e[n]=t.trustAsHtml(a)})}),o.bsAlert&&e.$watch(o.bsAlert,function(t){angular.isObject(t)?angular.extend(e,t):e.content=t},!0);var r=n(i);a.on(o.trigger||"click",r.toggle),e.$on("$destroy",function(){r&&r.destroy(),i=null,r=null})}}}]),angular.module("mgcrea.ngStrap.button",[]).provider("$button",function(){var e=this.defaults={activeClass:"active",toggleEvent:"click"};this.$get=function(){return{defaults:e}}}).directive("bsCheckboxGroup",function(){return{restrict:"A",require:"ngModel",compile:function(e,t){e.attr("data-toggle","buttons"),e.removeAttr("ng-model");var n=e[0].querySelectorAll('input[type="checkbox"]');angular.forEach(n,function(e){var n=angular.element(e);n.attr("bs-checkbox",""),n.attr("ng-model",t.ngModel+"."+n.attr("value"))})}}}).directive("bsCheckbox",["$button","$$rAF",function(e,t){var n=e.defaults,a=/^(true|false|\d+)$/;return{restrict:"A",require:"ngModel",link:function(e,o,i,r){var s=n,l="INPUT"===o[0].nodeName,u=l?o.parent():o,c=angular.isDefined(i.trueValue)?i.trueValue:!0;a.test(i.trueValue)&&(c=e.$eval(i.trueValue));var d=angular.isDefined(i.falseValue)?i.falseValue:!1;a.test(i.falseValue)&&(d=e.$eval(i.falseValue));var f="boolean"!=typeof c||"boolean"!=typeof d;f&&(r.$parsers.push(function(e){return e?c:d}),r.$formatters.push(function(e){return angular.equals(e,c)}),e.$watch(i.ngModel,function(){r.$render()})),r.$render=function(){var e=angular.equals(r.$modelValue,c);t(function(){l&&(o[0].checked=e),u.toggleClass(s.activeClass,e)})},o.bind(s.toggleEvent,function(){e.$apply(function(){l||r.$setViewValue(!u.hasClass("active")),f||r.$render()})})}}}]).directive("bsRadioGroup",function(){return{restrict:"A",require:"ngModel",compile:function(e,t){e.attr("data-toggle","buttons"),e.removeAttr("ng-model");var n=e[0].querySelectorAll('input[type="radio"]');angular.forEach(n,function(e){angular.element(e).attr("bs-radio",""),angular.element(e).attr("ng-model",t.ngModel)})}}}).directive("bsRadio",["$button","$$rAF",function(e,t){var n=e.defaults,a=/^(true|false|\d+)$/;return{restrict:"A",require:"ngModel",link:function(e,o,i,r){var s,l=n,u="INPUT"===o[0].nodeName,c=u?o.parent():o;i.$observe("value",function(t){s=a.test(t)?e.$eval(t):t,r.$render()}),r.$render=function(){var e=angular.equals(r.$modelValue,s);t(function(){u&&(o[0].checked=e),c.toggleClass(l.activeClass,e)})},o.bind(l.toggleEvent,function(){e.$apply(function(){r.$setViewValue(s),r.$render()})})}}}]),angular.module("mgcrea.ngStrap.collapse",[]).provider("$collapse",function(){var e=this.defaults={animation:"am-collapse",disallowToggle:!1,activeClass:"in",startCollapsed:!1,allowMultiple:!1},t=this.controller=function(t,n,a){function o(e){for(var t=l.$targets.$active,n=0;nt;t++)angular.forEach(g.rows[t],u.$setDisabledEl)},u.select=function(e,t){angular.isDate(n.$dateValue)||(n.$dateValue=new Date(e)),!g.$mode||t?(n.$setViewValue(angular.copy(e)),n.$render(),p.autoclose&&!t&&l(function(){u.hide(!0)})):(angular.extend($,{year:e.getFullYear(),month:e.getMonth(),date:e.getDate()}),u.setMode(g.$mode-1),u.$build())},u.setMode=function(e){g.$mode=e,h=u.$views[g.$mode],u.$build()},u.$build=function(e){e===!0&&h.built||(e!==!1||h.built)&&h.build.call(h)},u.$updateSelected=function(){for(var e=0,t=g.rows.length;t>e;e++)angular.forEach(g.rows[e],o)},u.$isSelected=function(e){return h.isSelected(e)},u.$setDisabledEl=function(e){e.disabled=h.isDisabled(e.date)},u.$selectPane=function(e){var t=h.steps,n=new Date(Date.UTC($.year+(t.year||0)*e,$.month+(t.month||0)*e,1));angular.extend($,{year:n.getUTCFullYear(),month:n.getUTCMonth(),date:n.getUTCDate()}),u.$build()},u.$onMouseDown=function(e){if(e.preventDefault(),e.stopPropagation(),d){var t=angular.element(e.target);"button"!==t[0].nodeName.toLowerCase()&&(t=t.parent()),t.triggerHandler("click")}},u.$onKeyDown=function(e){if(/(38|37|39|40|13)/.test(e.keyCode)&&!e.shiftKey&&!e.altKey){if(e.preventDefault(),e.stopPropagation(),13===e.keyCode)return g.$mode?g.$apply(function(){u.setMode(g.$mode-1)}):u.hide(!0);h.onKeyDown(e),f.$digest()}};var v=u.init;u.init=function(){return c&&p.useNative?(t.prop("type","date"),void t.css("-webkit-appearance","textfield")):(d&&(t.prop("type","text"),t.attr("readonly","true"),t.on("click",i)),void v())};var y=u.destroy;u.destroy=function(){c&&p.useNative&&t.off("click",i),y()};var w=u.show;u.show=function(){w(),l(function(){u.$isShown&&(u.$element.on(d?"touchstart":"mousedown",u.$onMouseDown),p.keyboard&&t.on("keydown",u.$onKeyDown))},0,!1)};var b=u.hide;return u.hide=function(e){u.$isShown&&(u.$element.off(d?"touchstart":"mousedown",u.$onMouseDown),p.keyboard&&t.off("keydown",u.$onKeyDown),b(e))},u}var c=(angular.element(t.document.body),/(ip(a|o)d|iphone|android)/gi.test(t.navigator.userAgent)),d="createTouch"in t.document&&c;return e.lang||(e.lang=i.getDefaultLocale()),u.defaults=e,u}]}).directive("bsDatepicker",["$window","$parse","$q","$dateFormatter","$dateParser","$datepicker",function(e,t,n,a,o,i){var r=(i.defaults,/(ip(a|o)d|iphone|android)/gi.test(e.navigator.userAgent));return{restrict:"EAC",require:"ngModel",link:function(e,t,n,s){function l(e){return e&&e.length?e:null}function u(e){if(angular.isDate(e)){var t=isNaN(f.$options.minDate)||e.getTime()>=f.$options.minDate,n=isNaN(f.$options.maxDate)||e.getTime()<=f.$options.maxDate,a=t&&n;s.$setValidity("date",a),s.$setValidity("min",t),s.$setValidity("max",n),a&&(s.$dateValue=e)}}function c(){return!s.$dateValue||isNaN(s.$dateValue.getTime())?"":g(s.$dateValue,d.dateFormat)}var d={scope:e,controller:s};angular.forEach(["placement","container","delay","trigger","keyboard","html","animation","template","autoclose","dateType","dateFormat","timezone","modelDateFormat","dayFormat","strictFormat","startWeek","startDate","useNative","lang","startView","minView","iconLeft","iconRight","daysOfWeekDisabled","id"],function(e){angular.isDefined(n[e])&&(d[e]=n[e])}),n.bsShow&&e.$watch(n.bsShow,function(e){f&&angular.isDefined(e)&&(angular.isString(e)&&(e=!!e.match(/true|,?(datepicker),?/i)),e===!0?f.show():f.hide())});var f=i(t,s,d);d=f.$options,r&&d.useNative&&(d.dateFormat="yyyy-MM-dd");var p=d.lang,g=function(e,t){return a.formatDate(e,t,p)},m=o({format:d.dateFormat,lang:p,strict:d.strictFormat});angular.forEach(["minDate","maxDate"],function(e){angular.isDefined(n[e])&&n.$observe(e,function(t){f.$options[e]=m.getDateForAttribute(e,t),!isNaN(f.$options[e])&&f.$build(!1),u(s.$dateValue)})}),e.$watch(n.ngModel,function(){f.update(s.$dateValue)},!0),angular.isDefined(n.disabledDates)&&e.$watch(n.disabledDates,function(e,t){e=l(e),t=l(t),e&&f.updateDisabledDates(e)}),s.$parsers.unshift(function(e){var t;if(!e)return s.$setValidity("date",!0),null;var n=m.parse(e,s.$dateValue);return!n||isNaN(n.getTime())?void s.$setValidity("date",!1):(u(n),"string"===d.dateType?(t=m.timezoneOffsetAdjust(n,d.timezone,!0),g(t,d.modelDateFormat||d.dateFormat)):(t=m.timezoneOffsetAdjust(s.$dateValue,d.timezone,!0),"number"===d.dateType?t.getTime():"unix"===d.dateType?t.getTime()/1e3:"iso"===d.dateType?t.toISOString():new Date(t)))}),s.$formatters.push(function(e){var t;return t=angular.isUndefined(e)||null===e?0/0:angular.isDate(e)?e:"string"===d.dateType?m.parse(e,null,d.modelDateFormat):new Date("unix"===d.dateType?1e3*e:e),s.$dateValue=m.timezoneOffsetAdjust(t,d.timezone),c()}),s.$render=function(){t.val(c())},e.$on("$destroy",function(){f&&f.destroy(),d=null,f=null})}}}]).provider("datepickerViews",function(){function e(e,t){for(var n=[];e.length>0;)n.push(e.splice(0,t));return n}function t(e,t){return(e%t+t)%t}this.defaults={dayFormat:"dd",daySplit:7};this.$get=["$dateFormatter","$dateParser","$sce",function(n,a,o){return function(i){var r=i.$scope,s=i.$options,l=s.lang,u=function(e,t){return n.formatDate(e,t,l)},c=a({format:s.dateFormat,lang:l,strict:s.strictFormat}),d=n.weekdaysShort(l),f=d.slice(s.startWeek).concat(d.slice(0,s.startWeek)),p=o.trustAsHtml(''+f.join('')+""),g=i.$date||(s.startDate?c.getDateForAttribute("startDate",s.startDate):new Date),m={year:g.getFullYear(),month:g.getMonth(),date:g.getDate()},$=[{format:s.dayFormat,split:7,steps:{month:1},update:function(e,t){!this.built||t||e.getFullYear()!==m.year||e.getMonth()!==m.month?(angular.extend(m,{year:i.$date.getFullYear(),month:i.$date.getMonth(),date:i.$date.getDate()}),i.$build()):e.getDate()!==m.date&&(m.date=i.$date.getDate(),i.$updateSelected())},build:function(){var n=new Date(m.year,m.month,1),a=n.getTimezoneOffset(),o=new Date(+n-864e5*t(n.getDay()-s.startWeek,7)),l=o.getTimezoneOffset(),d=c.timezoneOffsetAdjust(new Date,s.timezone).toDateString();l!==a&&(o=new Date(+o+6e4*(l-a)));for(var f,g=[],$=0;42>$;$++)f=c.daylightSavingAdjust(new Date(o.getFullYear(),o.getMonth(),o.getDate()+$)),g.push({date:f,isToday:f.toDateString()===d,label:u(f,this.format),selected:i.$date&&this.isSelected(f),muted:f.getMonth()!==m.month,disabled:this.isDisabled(f)});r.title=u(n,s.monthTitleFormat),r.showLabels=!0,r.labels=p,r.rows=e(g,this.split),this.built=!0},isSelected:function(e){return i.$date&&e.getFullYear()===i.$date.getFullYear()&&e.getMonth()===i.$date.getMonth()&&e.getDate()===i.$date.getDate()},isDisabled:function(e){var t=e.getTime();if(ts.maxDate)return!0;if(-1!==s.daysOfWeekDisabled.indexOf(e.getDay()))return!0;if(s.disabledDateRanges)for(var n=0;n=s.disabledDateRanges[n].start&&t<=s.disabledDateRanges[n].end)return!0;return!1},onKeyDown:function(e){if(i.$date){var t,n=i.$date.getTime();37===e.keyCode?t=new Date(n-864e5):38===e.keyCode?t=new Date(n-6048e5):39===e.keyCode?t=new Date(n+864e5):40===e.keyCode&&(t=new Date(n+6048e5)),this.isDisabled(t)||i.select(t,!0)}}},{name:"month",format:s.monthFormat,split:4,steps:{year:1},update:function(e){this.built&&e.getFullYear()===m.year?e.getMonth()!==m.month&&(angular.extend(m,{month:i.$date.getMonth(),date:i.$date.getDate()}),i.$updateSelected()):(angular.extend(m,{year:i.$date.getFullYear(),month:i.$date.getMonth(),date:i.$date.getDate()}),i.$build())},build:function(){for(var t,n=(new Date(m.year,0,1),[]),a=0;12>a;a++)t=new Date(m.year,a,1),n.push({date:t,label:u(t,this.format),selected:i.$isSelected(t),disabled:this.isDisabled(t)});r.title=u(t,s.yearTitleFormat),r.showLabels=!1,r.rows=e(n,this.split),this.built=!0},isSelected:function(e){return i.$date&&e.getFullYear()===i.$date.getFullYear()&&e.getMonth()===i.$date.getMonth()},isDisabled:function(e){var t=+new Date(e.getFullYear(),e.getMonth()+1,0);return ts.maxDate},onKeyDown:function(e){if(i.$date){var t=i.$date.getMonth(),n=new Date(i.$date);37===e.keyCode?n.setMonth(t-1):38===e.keyCode?n.setMonth(t-4):39===e.keyCode?n.setMonth(t+1):40===e.keyCode&&n.setMonth(t+4),this.isDisabled(n)||i.select(n,!0)}}},{name:"year",format:s.yearFormat,split:4,steps:{year:12},update:function(e,t){!this.built||t||parseInt(e.getFullYear()/20,10)!==parseInt(m.year/20,10)?(angular.extend(m,{year:i.$date.getFullYear(),month:i.$date.getMonth(),date:i.$date.getDate()}),i.$build()):e.getFullYear()!==m.year&&(angular.extend(m,{year:i.$date.getFullYear(),month:i.$date.getMonth(),date:i.$date.getDate()}),i.$updateSelected())},build:function(){for(var t,n=m.year-m.year%(3*this.split),a=[],o=0;12>o;o++)t=new Date(n+o,0,1),a.push({date:t,label:u(t,this.format),selected:i.$isSelected(t),disabled:this.isDisabled(t)});r.title=a[0].label+"-"+a[a.length-1].label,r.showLabels=!1,r.rows=e(a,this.split),this.built=!0},isSelected:function(e){return i.$date&&e.getFullYear()===i.$date.getFullYear()},isDisabled:function(e){var t=+new Date(e.getFullYear()+1,0,0);return ts.maxDate},onKeyDown:function(e){if(i.$date){var t=i.$date.getFullYear(),n=new Date(i.$date);37===e.keyCode?n.setYear(t-1):38===e.keyCode?n.setYear(t-4):39===e.keyCode?n.setYear(t+1):40===e.keyCode&&n.setYear(t+4),this.isDisabled(n)||i.select(n,!0)}}}];return{views:s.minView?Array.prototype.slice.call($,s.minView):$,viewDate:m}}}]}),angular.module("mgcrea.ngStrap.dropdown",["mgcrea.ngStrap.tooltip"]).provider("$dropdown",function(){var e=this.defaults={animation:"am-fade",prefixClass:"dropdown",prefixEvent:"dropdown",placement:"bottom-left",template:"dropdown/dropdown.tpl.html",trigger:"click",container:!1,keyboard:!0,html:!1,delay:0};this.$get=["$window","$rootScope","$tooltip","$timeout",function(t,n,a,o){function i(t,i){function l(e){return e.target!==t[0]?e.target!==t[0]&&u.hide():void 0}{var u={},c=angular.extend({},e,i);u.$scope=c.scope&&c.scope.$new()||n.$new()}u=a(t,c);var d=t.parent();u.$onKeyDown=function(e){if(/(38|40)/.test(e.keyCode)){e.preventDefault(),e.stopPropagation();var t=angular.element(u.$element[0].querySelectorAll("li:not(.divider) a"));if(t.length){var n;angular.forEach(t,function(e,t){s&&s.call(e,":focus")&&(n=t)}),38===e.keyCode&&n>0?n--:40===e.keyCode&&no;o++)if(e[o].toLowerCase()===a)return o;return-1}e.prototype.setMilliseconds=function(e){this.milliseconds=e},e.prototype.setSeconds=function(e){this.seconds=e},e.prototype.setMinutes=function(e){this.minutes=e},e.prototype.setHours=function(e){this.hours=e},e.prototype.getHours=function(){return this.hours},e.prototype.setDate=function(e){this.day=e},e.prototype.setMonth=function(e){this.month=e},e.prototype.setFullYear=function(e){this.year=e},e.prototype.fromDate=function(e){return this.year=e.getFullYear(),this.month=e.getMonth(),this.day=e.getDate(),this.hours=e.getHours(),this.minutes=e.getMinutes(),this.seconds=e.getSeconds(),this.milliseconds=e.getMilliseconds(),this},e.prototype.toDate=function(){return new Date(this.year,this.month,this.day,this.hours,this.minutes,this.seconds,this.milliseconds)};var o=e.prototype,i=this.defaults={format:"shortDate",strict:!1};this.$get=["$locale","dateFilter",function(r,s){var l=function(l){function u(e){var t,n=Object.keys(h),a=[],o=[],i=e;for(t=0;t1){var r=i.search(n[t]);e=e.split(n[t]).join(""),h[n[t]]&&(a[r]=h[n[t]])}return angular.forEach(a,function(e){e&&o.push(e)}),o}function c(e){return e.replace(/\//g,"[\\/]").replace("/-/g","[-]").replace(/\./g,"[.]").replace(/\\s/g,"[\\s]")}function d(e){var t,n=Object.keys($),a=e;for(t=0;t12?e.getHours()+2:0),e):null},m.timezoneOffsetAdjust=function(e,t,n){return e?(t&&"UTC"===t&&(e=new Date(e.getTime()),e.setMinutes(e.getMinutes()+(n?-1:1)*e.getTimezoneOffset())),e):null},m.init(),m};return l}]}]),angular.module("mgcrea.ngStrap.helpers.debounce",[]).factory("debounce",["$timeout",function(e){return function(t,n,a){var o=null;return function(){var i=this,r=arguments,s=a&&!o;return o&&e.cancel(o),o=e(function(){o=null,a||t.apply(i,r)},n,!1),s&&t.apply(i,r),o}}}]).factory("throttle",["$timeout",function(e){return function(t,n,a){var o=null;return a||(a={}),function(){var i=this,r=arguments;o||(a.leading!==!1&&t.apply(i,r),o=e(function(){o=null,a.trailing!==!1&&t.apply(i,r)},n,!1))}}}]),angular.module("mgcrea.ngStrap.helpers.dimensions",[]).factory("dimensions",["$document","$window",function(){var t=(angular.element,{}),n=t.nodeName=function(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()};t.css=function(t,n,a){var o;return o=t.currentStyle?t.currentStyle[n]:e.getComputedStyle?e.getComputedStyle(t)[n]:t.style[n],a===!0?parseFloat(o)||0:o},t.offset=function(t){var n=t.getBoundingClientRect(),a=t.ownerDocument;return{width:n.width||t.offsetWidth,height:n.height||t.offsetHeight,top:n.top+(e.pageYOffset||a.documentElement.scrollTop)-(a.documentElement.clientTop||0),left:n.left+(e.pageXOffset||a.documentElement.scrollLeft)-(a.documentElement.clientLeft||0)}},t.setOffset=function(e,n,a){var o,i,r,s,l,u,c,d=t.css(e,"position"),f=angular.element(e),p={};"static"===d&&(e.style.position="relative"),l=t.offset(e),r=t.css(e,"top"),u=t.css(e,"left"),c=("absolute"===d||"fixed"===d)&&(r+u).indexOf("auto")>-1,c?(o=t.position(e),s=o.top,i=o.left):(s=parseFloat(r)||0,i=parseFloat(u)||0),angular.isFunction(n)&&(n=n.call(e,a,l)),null!==n.top&&(p.top=n.top-l.top+s),null!==n.left&&(p.left=n.left-l.left+i),"using"in n?n.using.call(f,p):f.css({top:p.top+"px",left:p.left+"px"})},t.position=function(e){var o,i,r={top:0,left:0};return"fixed"===t.css(e,"position")?i=e.getBoundingClientRect():(o=a(e),i=t.offset(e),n(o,"html")||(r=t.offset(o)),r.top+=t.css(o,"borderTopWidth",!0),r.left+=t.css(o,"borderLeftWidth",!0)),{width:e.offsetWidth,height:e.offsetHeight,top:i.top-r.top-t.css(e,"marginTop",!0),left:i.left-r.left-t.css(e,"marginLeft",!0)}};var a=function(e){var a=e.ownerDocument,o=e.offsetParent||a;if(n(o,"#document"))return a.documentElement;for(;o&&!n(o,"html")&&"static"===t.css(o,"position");)o=o.offsetParent;return o||a.documentElement};return t.height=function(e,n){var a=e.offsetHeight;return n?a+=t.css(e,"marginTop",!0)+t.css(e,"marginBottom",!0):a-=t.css(e,"paddingTop",!0)+t.css(e,"paddingBottom",!0)+t.css(e,"borderTopWidth",!0)+t.css(e,"borderBottomWidth",!0),a +},t.width=function(e,n){var a=e.offsetWidth;return n?a+=t.css(e,"marginLeft",!0)+t.css(e,"marginRight",!0):a-=t.css(e,"paddingLeft",!0)+t.css(e,"paddingRight",!0)+t.css(e,"borderLeftWidth",!0)+t.css(e,"borderRightWidth",!0),a},t}]),angular.module("mgcrea.ngStrap.helpers.parseOptions",[]).provider("$parseOptions",function(){var e=this.defaults={regexp:/^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w]*)|(?:\(\s*([\$\w][\$\w]*)\s*,\s*([\$\w][\$\w]*)\s*\)))\s+in\s+(.*?)(?:\s+track\s+by\s+(.*?))?$/};this.$get=["$parse","$q",function(t,n){function a(a,o){function i(e,t){return e.map(function(e,n){var a,o,i={};return i[c]=e,a=u(t,i),o=p(t,i),{label:a,value:o,index:n}})}var r={},s=angular.extend({},e,o);r.$values=[];var l,u,c,d,f,p,g;return r.init=function(){r.$match=l=a.match(s.regexp),u=t(l[2]||l[1]),c=l[4]||l[6],d=l[5],f=t(l[3]||""),p=t(l[2]?l[1]:c),g=t(l[7])},r.valuesFn=function(e,t){return n.when(g(e,t)).then(function(t){return r.$values=t?i(t,e):{},r.$values})},r.displayValue=function(e){var t={};return t[c]=e,u(t)},r.init(),r}return a}]}),angular.version.minor<3&&angular.version.dot<14&&angular.module("ng").factory("$$rAF",["$window","$timeout",function(e,t){var n=e.requestAnimationFrame||e.webkitRequestAnimationFrame||e.mozRequestAnimationFrame,a=e.cancelAnimationFrame||e.webkitCancelAnimationFrame||e.mozCancelAnimationFrame||e.webkitCancelRequestAnimationFrame,o=!!n,i=o?function(e){var t=n(e);return function(){a(t)}}:function(e){var n=t(e,16.66,!1);return function(){t.cancel(n)}};return i.supported=o,i}]),angular.module("mgcrea.ngStrap.modal",["mgcrea.ngStrap.helpers.dimensions"]).provider("$modal",function(){var e=this.defaults={animation:"am-fade",backdropAnimation:"am-fade",prefixClass:"modal",prefixEvent:"modal",placement:"top",template:"modal/modal.tpl.html",contentTemplate:!1,container:!1,element:null,backdrop:!0,keyboard:!0,html:!1,show:!0};this.$get=["$window","$rootScope","$compile","$q","$templateCache","$http","$animate","$timeout","$sce","dimensions",function(n,a,o,i,r,s,l,u,c){function d(t){function n(){w.$emit(d.prefixEvent+".show",u)}function i(){w.$emit(d.prefixEvent+".hide",u),v.removeClass(d.prefixClass+"-open"),d.animation&&v.removeClass(d.prefixClass+"-with-"+d.animation)}function r(e){e.target===e.currentTarget&&("static"===d.backdrop?u.focus():u.hide())}function s(e){e.preventDefault()}var u={},d=u.$options=angular.extend({},e,t);u.$promise=g(d.template);var w=u.$scope=d.scope&&d.scope.$new()||a.$new();d.element||d.container||(d.container="body"),u.$id=d.id||d.element&&d.element.attr("id")||"",m(["title","content"],function(e){d[e]&&(w[e]=c.trustAsHtml(d[e]))}),w.$hide=function(){w.$$postDigest(function(){u.hide()})},w.$show=function(){w.$$postDigest(function(){u.show()})},w.$toggle=function(){w.$$postDigest(function(){u.toggle()})},u.$isShown=w.$isShown=!1,d.contentTemplate&&(u.$promise=u.$promise.then(function(e){var n=angular.element(e);return g(d.contentTemplate).then(function(e){var a=p('[ng-bind="content"]',n[0]).removeAttr("ng-bind").html(e);return t.template||a.next().remove(),n[0].outerHTML})}));var b,D,k=angular.element('
        ');return k.css({position:"fixed",top:"0px",left:"0px",bottom:"0px",right:"0px","z-index":1038}),u.$promise.then(function(e){angular.isObject(e)&&(e=e.data),d.html&&(e=e.replace(y,'ng-bind-html="')),e=$.apply(e),b=o(e),u.init()}),u.init=function(){d.show&&w.$$postDigest(function(){u.show()})},u.destroy=function(){D&&(D.remove(),D=null),k&&(k.remove(),k=null),w.$destroy()},u.show=function(){if(!u.$isShown){var e,t;if(angular.isElement(d.container)?(e=d.container,t=d.container[0].lastChild?angular.element(d.container[0].lastChild):null):d.container?(e=p(d.container),t=e[0].lastChild?angular.element(e[0].lastChild):null):(e=null,t=d.element),D=u.$element=b(w,function(){}),!w.$emit(d.prefixEvent+".show.before",u).defaultPrevented){D.css({display:"block"}).addClass(d.placement),d.animation&&(d.backdrop&&k.addClass(d.backdropAnimation),D.addClass(d.animation)),d.backdrop&&l.enter(k,v,null);var a=l.enter(D,e,t,n);a&&a.then&&a.then(n),u.$isShown=w.$isShown=!0,f(w);var o=D[0];h(function(){o.focus()}),v.addClass(d.prefixClass+"-open"),d.animation&&v.addClass(d.prefixClass+"-with-"+d.animation),d.backdrop&&(D.on("click",r),k.on("click",r),k.on("wheel",s)),d.keyboard&&D.on("keyup",u.$onKeyUp)}}},u.hide=function(){if(u.$isShown&&!w.$emit(d.prefixEvent+".hide.before",u).defaultPrevented){var e=l.leave(D,i);e&&e.then&&e.then(i),d.backdrop&&l.leave(k),u.$isShown=w.$isShown=!1,f(w),d.backdrop&&(D.off("click",r),k.off("click",r),k.off("wheel",s)),d.keyboard&&D.off("keyup",u.$onKeyUp)}},u.toggle=function(){u.$isShown?u.hide():u.show()},u.focus=function(){D[0].focus()},u.$onKeyUp=function(e){27===e.which&&u.$isShown&&(u.hide(),e.stopPropagation())},u}function f(e){e.$$phase||e.$root&&e.$root.$$phase||e.$digest()}function p(e,n){return angular.element((n||t).querySelectorAll(e))}function g(e){return w[e]?w[e]:w[e]=s.get(e,{cache:r}).then(function(e){return e.data})}var m=angular.forEach,$=String.prototype.trim,h=n.requestAnimationFrame||n.setTimeout,v=angular.element(n.document.body),y=/ng-bind="/gi,w={};return d}]}).directive("bsModal",["$window","$sce","$modal",function(e,t,n){return{restrict:"EAC",scope:!0,link:function(e,a,o){var i={scope:e,element:a,show:!1};angular.forEach(["template","contentTemplate","placement","container","animation","id"],function(e){angular.isDefined(o[e])&&(i[e]=o[e])});var r=/^(false|0|)$/;angular.forEach(["keyboard","html"],function(e){angular.isDefined(o[e])&&(i[e]=!r.test(o[e]))}),angular.isDefined(o.backdrop)&&(i.backdrop=r.test(o.backdrop)?!1:o.backdrop),angular.forEach(["title","content"],function(n){o[n]&&o.$observe(n,function(a){e[n]=t.trustAsHtml(a)})}),o.bsModal&&e.$watch(o.bsModal,function(t){angular.isObject(t)?angular.extend(e,t):e.content=t},!0);var s=n(i);a.on(o.trigger||"click",s.toggle),e.$on("$destroy",function(){s&&s.destroy(),i=null,s=null})}}}]),angular.module("mgcrea.ngStrap.navbar",[]).provider("$navbar",function(){var e=this.defaults={activeClass:"active",routeAttr:"data-match-route",strict:!1};this.$get=function(){return{defaults:e}}}).directive("bsNavbar",["$window","$location","$navbar",function(e,t,n){var a=n.defaults;return{restrict:"A",link:function(e,n,o){var i=angular.copy(a);angular.forEach(Object.keys(a),function(e){angular.isDefined(o[e])&&(i[e]=o[e])}),e.$watch(function(){return t.path()},function(e){var t=n[0].querySelectorAll("li["+i.routeAttr+"]");angular.forEach(t,function(t){var n=angular.element(t),a=n.attr(i.routeAttr).replace("/","\\/");i.strict&&(a="^"+a+"$");var o=new RegExp(a,"i");o.test(e)?n.addClass(i.activeClass):n.removeClass(i.activeClass)})})}}}]),angular.module("mgcrea.ngStrap.popover",["mgcrea.ngStrap.tooltip"]).provider("$popover",function(){var e=this.defaults={animation:"am-fade",customClass:"",container:!1,target:!1,placement:"right",template:"popover/popover.tpl.html",contentTemplate:!1,trigger:"click",keyboard:!0,html:!1,title:"",content:"",delay:0,autoClose:!1};this.$get=["$tooltip",function(t){function n(n,a){var o=angular.extend({},e,a),i=t(n,o);return o.content&&(i.$scope.content=o.content),i}return n}]}).directive("bsPopover",["$window","$sce","$popover",function(e,t,n){var a=e.requestAnimationFrame||e.setTimeout;return{restrict:"EAC",scope:!0,link:function(e,o,i){var r={scope:e};angular.forEach(["template","contentTemplate","placement","container","target","delay","trigger","keyboard","html","animation","customClass","autoClose","id"],function(e){angular.isDefined(i[e])&&(r[e]=i[e])}),angular.forEach(["title","content"],function(n){i[n]&&i.$observe(n,function(o,i){e[n]=t.trustAsHtml(o),angular.isDefined(i)&&a(function(){s&&s.$applyPlacement()})})}),i.bsPopover&&e.$watch(i.bsPopover,function(t,n){angular.isObject(t)?angular.extend(e,t):e.content=t,angular.isDefined(n)&&a(function(){s&&s.$applyPlacement()})},!0),i.bsShow&&e.$watch(i.bsShow,function(e){s&&angular.isDefined(e)&&(angular.isString(e)&&(e=!!e.match(/true|,?(popover),?/i)),e===!0?s.show():s.hide())}),i.viewport&&e.$watch(i.viewport,function(e){s&&angular.isDefined(e)&&s.setViewport(e)});var s=n(o,r);e.$on("$destroy",function(){s&&s.destroy(),r=null,s=null})}}}]),angular.module("mgcrea.ngStrap.scrollspy",["mgcrea.ngStrap.helpers.debounce","mgcrea.ngStrap.helpers.dimensions"]).provider("$scrollspy",function(){var e=this.$$spies={},n=this.defaults={debounce:150,throttle:100,offset:100};this.$get=["$window","$document","$rootScope","dimensions","debounce","throttle",function(a,o,i,r,s,l){function u(e,t){return e[0].nodeName&&e[0].nodeName.toLowerCase()===t.toLowerCase()}function c(o){var c=angular.extend({},n,o);c.element||(c.element=p);var g=u(c.element,"body"),m=g?d:c.element,$=g?"window":c.id;if(e[$])return e[$].$$count++,e[$];var h,v,y,w,b,D,k,S,x={},T=x.$trackedElements=[],C=[];return x.init=function(){this.$$count=1,w=s(this.checkPosition,c.debounce),b=l(this.checkPosition,c.throttle),m.on("click",this.checkPositionWithEventLoop),d.on("resize",w),m.on("scroll",b),D=s(this.checkOffsets,c.debounce),h=i.$on("$viewContentLoaded",D),v=i.$on("$includeContentLoaded",D),D(),$&&(e[$]=x)},x.destroy=function(){this.$$count--,this.$$count>0||(m.off("click",this.checkPositionWithEventLoop),d.off("resize",w),m.off("scroll",b),h(),v(),$&&delete e[$])},x.checkPosition=function(){if(C.length){if(S=(g?a.pageYOffset:m.prop("scrollTop"))||0,k=Math.max(a.innerHeight,f.prop("clientHeight")),SC[e+1].offsetTop))return x.$activateElement(C[e])}},x.checkPositionWithEventLoop=function(){setTimeout(x.checkPosition,1)},x.$activateElement=function(e){if(y){var t=x.$getTrackedElement(y);t&&(t.source.removeClass("active"),u(t.source,"li")&&u(t.source.parent().parent(),"li")&&t.source.parent().parent().removeClass("active"))}y=e.target,e.source.addClass("active"),u(e.source,"li")&&u(e.source.parent().parent(),"li")&&e.source.parent().parent().addClass("active")},x.$getTrackedElement=function(e){return T.filter(function(t){return t.target===e})[0]},x.checkOffsets=function(){angular.forEach(T,function(e){var n=t.querySelector(e.target);e.offsetTop=n?r.offset(n).top:null,c.offset&&null!==e.offsetTop&&(e.offsetTop-=1*c.offset)}),C=T.filter(function(e){return null!==e.offsetTop}).sort(function(e,t){return e.offsetTop-t.offsetTop}),w()},x.trackElement=function(e,t){T.push({target:e,source:t})},x.untrackElement=function(e,t){for(var n,a=T.length;a--;)if(T[a].target===e&&T[a].source===t){n=a;break}T=T.splice(n,1)},x.activate=function(e){T[e].addClass("active")},x.init(),x}var d=angular.element(a),f=angular.element(o.prop("documentElement")),p=angular.element(a.document.body);return c}]}).directive("bsScrollspy",["$rootScope","debounce","dimensions","$scrollspy",function(e,t,n,a){return{restrict:"EAC",link:function(e,t,n){var o={scope:e};angular.forEach(["offset","target"],function(e){angular.isDefined(n[e])&&(o[e]=n[e])});var i=a(o);i.trackElement(o.target,t),e.$on("$destroy",function(){i&&(i.untrackElement(o.target,t),i.destroy()),o=null,i=null})}}}]).directive("bsScrollspyList",["$rootScope","debounce","dimensions","$scrollspy",function(){return{restrict:"A",compile:function(e){var t=e[0].querySelectorAll("li > a[href]");angular.forEach(t,function(e){var t=angular.element(e);t.parent().attr("bs-scrollspy","").attr("data-target",t.attr("href"))})}}}]),angular.module("mgcrea.ngStrap.select",["mgcrea.ngStrap.tooltip","mgcrea.ngStrap.helpers.parseOptions"]).provider("$select",function(){var e=this.defaults={animation:"am-fade",prefixClass:"select",prefixEvent:"$select",placement:"bottom-left",template:"select/select.tpl.html",trigger:"focus",container:!1,keyboard:!0,html:!1,delay:0,multiple:!1,allNoneButtons:!1,sort:!0,caretHtml:' ',placeholder:"Choose among the following...",allText:"All",noneText:"None",maxLength:3,maxLengthHtml:"selected",iconCheckmark:"glyphicon glyphicon-ok"};this.$get=["$window","$document","$rootScope","$tooltip","$timeout",function(t,n,a,o,i){function r(t,n,a){var r={},s=angular.extend({},e,a);s.sort=s.sort.toString().match(/true|1/i),r=o(t,s);var u=r.$scope;u.$matches=[],u.$activeIndex=-1,u.$isMultiple=s.multiple,u.$showAllNoneButtons=s.allNoneButtons&&s.multiple,u.$iconCheckmark=s.iconCheckmark,u.$allText=s.allText,u.$noneText=s.noneText,u.$activate=function(e){u.$$postDigest(function(){r.activate(e)})},u.$select=function(e){u.$$postDigest(function(){r.select(e)})},u.$isVisible=function(){return r.$isVisible()},u.$isActive=function(e){return r.$isActive(e)},u.$selectAll=function(){for(var e=0;e=u.$matches.length&&(u.$activeIndex=s.multiple?[]:0)},r.$isVisible=function(){return s.minLength&&n?u.$matches.length&&n.$viewValue.length>=s.minLength:u.$matches.length},r.$isActive=function(e){return s.multiple?-1!==u.$activeIndex.indexOf(e):u.$activeIndex===e},r.$getIndex=function(e){var t=u.$matches.length,n=t;if(t){for(n=t;n--&&u.$matches[n].value!==e;);if(!(0>n))return n}},r.$onMouseDown=function(e){if(e.preventDefault(),e.stopPropagation(),l){var t=angular.element(e.target);t.triggerHandler("click")}},r.$onKeyDown=function(e){return/(9|13|38|40)/.test(e.keyCode)?(e.preventDefault(),e.stopPropagation(),s.multiple&&9===e.keyCode?r.hide():s.multiple||13!==e.keyCode&&9!==e.keyCode?void(s.multiple||(38===e.keyCode&&u.$activeIndex>0?u.$activeIndex--:38===e.keyCode&&u.$activeIndex<0?u.$activeIndex=u.$matches.length-1:40===e.keyCode&&u.$activeIndex'),l.after(t)}var u=o(n.bsOptions),c=a(t,r,s),d=u.$match[7].replace(/\|.+/,"").trim();e.$watch(d,function(){u.valuesFn(e,r).then(function(e){c.update(e),r.$render()})},!0),e.$watch(n.ngModel,function(){c.$updateActiveIndex(),r.$render()},!0),r.$render=function(){var e,n;s.multiple&&angular.isArray(r.$modelValue)?(e=r.$modelValue.map(function(e){return n=c.$getIndex(e),angular.isDefined(n)?c.$scope.$matches[n].label:!1}).filter(angular.isDefined),e=e.length>(s.maxLength||i.maxLength)?e.length+" "+(s.maxLengthHtml||i.maxLengthHtml):e.join(", ")):(n=c.$getIndex(r.$modelValue),e=angular.isDefined(n)?c.$scope.$matches[n].label:!1),t.html((e?e:s.placeholder)+(s.caretHtml?s.caretHtml:i.caretHtml))},s.multiple&&(r.$isEmpty=function(e){return!e||0===e.length}),e.$on("$destroy",function(){c&&c.destroy(),s=null,c=null})}}}]),angular.module("mgcrea.ngStrap.tab",[]).provider("$tab",function(){var e=this.defaults={animation:"am-fade",template:"tab/tab.tpl.html",navClass:"nav-tabs",activeClass:"active"},t=this.controller=function(t,n,a){var o=this;o.$options=angular.copy(e),angular.forEach(["animation","navClass","activeClass"],function(e){angular.isDefined(a[e])&&(o.$options[e]=a[e])}),t.$navClass=o.$options.navClass,t.$activeClass=o.$options.activeClass,o.$panes=t.$panes=[],o.$activePaneChangeListeners=o.$viewChangeListeners=[],o.$push=function(e){o.$panes.push(e)},o.$remove=function(e){var t=o.$panes.indexOf(e),n=o.$panes.$active;o.$panes.splice(t,1),n>t?n--:t===n&&n===o.$panes.length&&n--,o.$setActive(n)},o.$panes.$active=0,o.$setActive=t.$setActive=function(e){o.$panes.$active=e,o.$activePaneChangeListeners.forEach(function(e){e()})}};this.$get=function(){var n={};return n.defaults=e,n.controller=t,n}}).directive("bsTabs",["$window","$animate","$tab","$parse",function(e,t,n,a){var o=n.defaults;return{require:["?ngModel","bsTabs"],transclude:!0,scope:!0,controller:["$scope","$element","$attrs",n.controller],templateUrl:function(e,t){return t.template||o.template},link:function(e,t,n,o){var i=o[0],r=o[1];if(i&&(console.warn("Usage of ngModel is deprecated, please use bsActivePane instead!"),r.$activePaneChangeListeners.push(function(){i.$setViewValue(r.$panes.$active)}),i.$formatters.push(function(e){return r.$setActive(1*e),e})),n.bsActivePane){var s=a(n.bsActivePane);r.$activePaneChangeListeners.push(function(){s.assign(e,r.$panes.$active)}),e.$watch(n.bsActivePane,function(e){r.$setActive(1*e)},!0)}}}}]).directive("bsPane",["$window","$animate","$sce",function(e,t,n){return{require:["^?ngModel","^bsTabs"],scope:!0,link:function(e,a,o,i){function r(){var n=s.$panes.indexOf(e),o=s.$panes.$active;t[n===o?"addClass":"removeClass"](a,s.$options.activeClass)}var s=(i[0],i[1]);a.addClass("tab-pane"),o.$observe("title",function(t){e.title=n.trustAsHtml(t)}),s.$options.animation&&a.addClass(s.$options.animation),o.$observe("disabled",function(t){e.disabled=e.$eval(t)}),s.$push(e),e.$on("$destroy",function(){s.$remove(e)}),s.$activePaneChangeListeners.push(function(){r()}),r()}}}]),angular.module("mgcrea.ngStrap.timepicker",["mgcrea.ngStrap.helpers.dateParser","mgcrea.ngStrap.helpers.dateFormatter","mgcrea.ngStrap.tooltip"]).provider("$timepicker",function(){var e=this.defaults={animation:"am-fade",prefixClass:"timepicker",placement:"bottom-left",template:"timepicker/timepicker.tpl.html",trigger:"focus",container:!1,keyboard:!0,html:!1,delay:0,useNative:!0,timeType:"date",timeFormat:"shortTime",timezone:null,modelTimeFormat:null,autoclose:!1,minTime:-1/0,maxTime:+1/0,length:5,hourStep:1,minuteStep:5,roundDisplay:!1,iconUp:"glyphicon glyphicon-chevron-up",iconDown:"glyphicon glyphicon-chevron-down",arrowBehavior:"pager"};this.$get=["$window","$document","$rootScope","$sce","$dateFormatter","$tooltip","$timeout",function(t,n,a,o,i,r,s){function l(t,n,a){function o(e){var t=6e4*g.minuteStep;return new Date(Math.floor(e.getTime()/t)*t)}function l(e,n){if(t[0].createTextRange){var a=t[0].createTextRange();a.collapse(!0),a.moveStart("character",e),a.moveEnd("character",n),a.select()}else t[0].setSelectionRange?t[0].setSelectionRange(e,n):angular.isUndefined(t[0].selectionStart)&&(t[0].selectionStart=e,t[0].selectionEnd=n)}function d(){t[0].focus()}var f=r(t,angular.extend({},e,a)),p=a.scope,g=f.$options,m=f.$scope,$=g.lang,h=function(e,t,n){return i.formatDate(e,t,$,n)},v=0,y=g.roundDisplay?o(new Date):new Date,w=n.$dateValue||y,b={hour:w.getHours(),meridian:w.getHours()<12,minute:w.getMinutes(),second:w.getSeconds(),millisecond:w.getMilliseconds()},D=i.getDatetimeFormat(g.timeFormat,$),k=i.hoursFormat(D),S=i.timeSeparator(D),x=i.minutesFormat(D),T=i.showAM(D);m.$iconUp=g.iconUp,m.$iconDown=g.iconDown,m.$select=function(e,t){f.select(e,t)},m.$moveIndex=function(e,t){f.$moveIndex(e,t)},m.$switchMeridian=function(e){f.switchMeridian(e)},f.update=function(e){angular.isDate(e)&&!isNaN(e.getTime())?(f.$date=e,angular.extend(b,{hour:e.getHours(),minute:e.getMinutes(),second:e.getSeconds(),millisecond:e.getMilliseconds()}),f.$build()):f.$isBuilt||f.$build()},f.select=function(e,t,a){(!n.$dateValue||isNaN(n.$dateValue.getTime()))&&(n.$dateValue=new Date(1970,0,1)),angular.isDate(e)||(e=new Date(e)),0===t?n.$dateValue.setHours(e.getHours()):1===t&&n.$dateValue.setMinutes(e.getMinutes()),n.$setViewValue(angular.copy(n.$dateValue)),n.$render(),g.autoclose&&!a&&s(function(){f.hide(!0)})},f.switchMeridian=function(e){if(n.$dateValue&&!isNaN(n.$dateValue.getTime())){var t=(e||n.$dateValue).getHours();n.$dateValue.setHours(12>t?t+12:t-12),n.$setViewValue(angular.copy(n.$dateValue)),n.$render()}},f.$build=function(){var e,t,n=m.midIndex=parseInt(g.length/2,10),a=[];for(e=0;e1*g.maxTime},m.$arrowAction=function(e,t){"picker"===g.arrowBehavior?f.$setTimeByStep(e,t):f.$moveIndex(e,t)},f.$setTimeByStep=function(e,t){{var n=new Date(f.$date),a=n.getHours(),o=(h(n,k).length,n.getMinutes());h(n,x).length}0===t?n.setHours(a-parseInt(g.hourStep,10)*e):n.setMinutes(o-parseInt(g.minuteStep,10)*e),f.select(n,t,!0)},f.$moveIndex=function(e,t){var n;0===t?(n=new Date(1970,0,1,b.hour+e*g.length,b.minute),angular.extend(b,{hour:n.getHours()})):1===t&&(n=new Date(1970,0,1,b.hour,b.minute+e*g.length*g.minuteStep),angular.extend(b,{minute:n.getMinutes()})),f.$build()},f.$onMouseDown=function(e){if("input"!==e.target.nodeName.toLowerCase()&&e.preventDefault(),e.stopPropagation(),c){var t=angular.element(e.target);"button"!==t[0].nodeName.toLowerCase()&&(t=t.parent()),t.triggerHandler("click")}},f.$onKeyDown=function(e){if(/(38|37|39|40|13)/.test(e.keyCode)&&!e.shiftKey&&!e.altKey){if(e.preventDefault(),e.stopPropagation(),13===e.keyCode)return f.hide(!0);var t=new Date(f.$date),n=t.getHours(),a=h(t,k).length,o=t.getMinutes(),i=h(t,x).length,r=/(37|39)/.test(e.keyCode),s=2+1*T;r&&(37===e.keyCode?v=1>v?s-1:v-1:39===e.keyCode&&(v=s-1>v?v+1:0));var u=[0,a];0===v?(38===e.keyCode?t.setHours(n-parseInt(g.hourStep,10)):40===e.keyCode&&t.setHours(n+parseInt(g.hourStep,10)),a=h(t,k).length,u=[0,a]):1===v?(38===e.keyCode?t.setMinutes(o-parseInt(g.minuteStep,10)):40===e.keyCode&&t.setMinutes(o+parseInt(g.minuteStep,10)),i=h(t,x).length,u=[a+1,a+1+i]):2===v&&(r||f.switchMeridian(),u=[a+1+i+1,a+1+i+3]),f.select(t,v,!0),l(u[0],u[1]),p.$digest()}};var C=f.init;f.init=function(){return u&&g.useNative?(t.prop("type","time"),void t.css("-webkit-appearance","textfield")):(c&&(t.prop("type","text"),t.attr("readonly","true"),t.on("click",d)),void C())};var M=f.destroy;f.destroy=function(){u&&g.useNative&&t.off("click",d),M()};var E=f.show;f.show=function(){E(),s(function(){f.$element.on(c?"touchstart":"mousedown",f.$onMouseDown),g.keyboard&&t.on("keydown",f.$onKeyDown)},0,!1)};var A=f.hide;return f.hide=function(e){f.$isShown&&(f.$element.off(c?"touchstart":"mousedown",f.$onMouseDown),g.keyboard&&t.off("keydown",f.$onKeyDown),A(e))},f}var u=(angular.element(t.document.body),/(ip(a|o)d|iphone|android)/gi.test(t.navigator.userAgent)),c="createTouch"in t.document&&u;return e.lang||(e.lang=i.getDefaultLocale()),l.defaults=e,l}]}).directive("bsTimepicker",["$window","$parse","$q","$dateFormatter","$dateParser","$timepicker",function(e,t,n,a,o,i){{var r=i.defaults,s=/(ip(a|o)d|iphone|android)/gi.test(e.navigator.userAgent);e.requestAnimationFrame||e.setTimeout}return{restrict:"EAC",require:"ngModel",link:function(e,t,n,l){function u(e){if(angular.isDate(e)){var t=isNaN(d.minTime)||new Date(e.getTime()).setFullYear(1970,0,1)>=d.minTime,n=isNaN(d.maxTime)||new Date(e.getTime()).setFullYear(1970,0,1)<=d.maxTime,a=t&&n;l.$setValidity("date",a),l.$setValidity("min",t),l.$setValidity("max",n),a&&(l.$dateValue=e)}}function c(){return!l.$dateValue||isNaN(l.$dateValue.getTime())?"":m(l.$dateValue,d.timeFormat)}var d={scope:e,controller:l};angular.forEach(["placement","container","delay","trigger","keyboard","html","animation","template","autoclose","timeType","timeFormat","timezone","modelTimeFormat","useNative","hourStep","minuteStep","length","arrowBehavior","iconUp","iconDown","id"],function(e){angular.isDefined(n[e])&&(d[e]=n[e])});var f=/^(false|0|)$/;angular.forEach(["roundDisplay"],function(e){angular.isDefined(n[e])&&(d[e]=!f.test(n[e]))}),n.bsShow&&e.$watch(n.bsShow,function(e){p&&angular.isDefined(e)&&(angular.isString(e)&&(e=!!e.match(/true|,?(timepicker),?/i)),e===!0?p.show():p.hide())}),s&&(d.useNative||r.useNative)&&(d.timeFormat="HH:mm");var p=i(t,l,d);d=p.$options;var g=d.lang,m=function(e,t,n){return a.formatDate(e,t,g,n)},$=o({format:d.timeFormat,lang:g});angular.forEach(["minTime","maxTime"],function(e){angular.isDefined(n[e])&&n.$observe(e,function(t){p.$options[e]=$.getTimeForAttribute(e,t),!isNaN(p.$options[e])&&p.$build(),u(l.$dateValue)})}),e.$watch(n.ngModel,function(){p.update(l.$dateValue)},!0),l.$parsers.unshift(function(e){var t;if(!e)return l.$setValidity("date",!0),null;var n=angular.isDate(e)?e:$.parse(e,l.$dateValue);return!n||isNaN(n.getTime())?void l.$setValidity("date",!1):(u(n),"string"===d.timeType?(t=$.timezoneOffsetAdjust(n,d.timezone,!0),m(t,d.modelTimeFormat||d.timeFormat)):(t=$.timezoneOffsetAdjust(l.$dateValue,d.timezone,!0),"number"===d.timeType?t.getTime():"unix"===d.timeType?t.getTime()/1e3:"iso"===d.timeType?t.toISOString():new Date(t)))}),l.$formatters.push(function(e){var t;return t=angular.isUndefined(e)||null===e?0/0:angular.isDate(e)?e:"string"===d.timeType?$.parse(e,null,d.modelTimeFormat):new Date("unix"===d.timeType?1e3*e:e),l.$dateValue=$.timezoneOffsetAdjust(t,d.timezone),c()}),l.$render=function(){t.val(c())},e.$on("$destroy",function(){p&&p.destroy(),d=null,p=null})}}}]),angular.module("mgcrea.ngStrap.typeahead",["mgcrea.ngStrap.tooltip","mgcrea.ngStrap.helpers.parseOptions"]).provider("$typeahead",function(){var e=this.defaults={animation:"am-fade",prefixClass:"typeahead",prefixEvent:"$typeahead",placement:"bottom-left",template:"typeahead/typeahead.tpl.html",trigger:"focus",container:!1,keyboard:!0,html:!1,delay:0,minLength:1,filter:"filter",limit:6,autoSelect:!1,comparator:""};this.$get=["$window","$rootScope","$tooltip","$timeout",function(t,n,a,o){function i(t,n,i){var r={},s=angular.extend({},e,i);r=a(t,s);var l=i.scope,u=r.$scope;u.$resetMatches=function(){u.$matches=[],u.$activeIndex=s.autoSelect?0:-1},u.$resetMatches(),u.$activate=function(e){u.$$postDigest(function(){r.activate(e)})},u.$select=function(e){u.$$postDigest(function(){r.select(e)})},u.$isVisible=function(){return r.$isVisible()},r.update=function(e){u.$matches=e,u.$activeIndex>=e.length&&(u.$activeIndex=s.autoSelect?0:-1),/^(bottom|bottom-left|bottom-right)$/.test(s.placement)||o(r.$applyPlacement)},r.activate=function(e){u.$activeIndex=e},r.select=function(e){var t=u.$matches[e].value;n.$setViewValue(t),n.$render(),u.$resetMatches(),l&&l.$digest(),u.$emit(s.prefixEvent+".select",t,e,r)},r.$isVisible=function(){return s.minLength&&n?u.$matches.length&&angular.isString(n.$viewValue)&&n.$viewValue.length>=s.minLength:!!u.$matches.length},r.$getIndex=function(e){var t=u.$matches.length,n=t;if(t){for(n=t;n--&&u.$matches[n].value!==e;);if(!(0>n))return n}},r.$onMouseDown=function(e){e.preventDefault(),e.stopPropagation()},r.$onKeyDown=function(e){/(38|40|13)/.test(e.keyCode)&&(r.$isVisible()&&(e.preventDefault(),e.stopPropagation()),13===e.keyCode&&u.$matches.length?r.select(u.$activeIndex):38===e.keyCode&&u.$activeIndex>0?u.$activeIndex--:40===e.keyCode&&u.$activeIndex0)return void s.$setViewValue(s.$viewValue.substring(0,s.$viewValue.length-1));e.length>c&&(e=e.slice(0,c));var n=g.$isVisible();n&&g.update(e),(1!==e.length||e[0].value!==t)&&(!n&&g.update(e),s.$render())})}),s.$formatters.push(function(e){var t=p.displayValue(e);return t===n?"":t}),s.$render=function(){if(s.$isEmpty(s.$viewValue))return t.val("");var e=g.$getIndex(s.$modelValue),n=angular.isDefined(e)?g.$scope.$matches[e].label:s.$viewValue;n=angular.isObject(n)?p.displayValue(n):n,t.val(n?n.toString().replace(/<(?:.|\n)*?>/gm,"").trim():"")},e.$on("$destroy",function(){g&&g.destroy(),l=null,g=null})}}}]),angular.module("mgcrea.ngStrap.tooltip",["mgcrea.ngStrap.helpers.dimensions"]).provider("$tooltip",function(){var e=this.defaults={animation:"am-fade",customClass:"",prefixClass:"tooltip",prefixEvent:"tooltip",container:!1,target:!1,placement:"top",template:"tooltip/tooltip.tpl.html",contentTemplate:!1,trigger:"hover focus",keyboard:!1,html:!1,show:!1,title:"",type:"",delay:0,autoClose:!1,bsEnabled:!0,viewport:{selector:"body",padding:0}};this.$get=["$window","$rootScope","$compile","$q","$templateCache","$http","$animate","$sce","dimensions","$$rAF","$timeout",function(n,a,o,i,r,s,l,u,c,d,f){function p(i,r){function s(){N.$emit(H.prefixEvent+".show",P)}function p(){if(N.$emit(H.prefixEvent+".hide",P),z===B){if(W&&"focus"===H.trigger)return i[0].blur();O()}}function b(){var e=H.trigger.split(" ");angular.forEach(e,function(e){"click"===e?i.on("click",P.toggle):"manual"!==e&&(i.on("hover"===e?"mouseenter":"focus",P.enter),i.on("hover"===e?"mouseleave":"blur",P.leave),"button"===I&&"hover"!==e&&i.on(v?"touchstart":"mousedown",P.$onFocusElementMouseDown)) +})}function D(){for(var e=H.trigger.split(" "),t=e.length;t--;){var n=e[t];"click"===n?i.off("click",P.toggle):"manual"!==n&&(i.off("hover"===n?"mouseenter":"focus",P.enter),i.off("hover"===n?"mouseleave":"blur",P.leave),"button"===I&&"hover"!==n&&i.off(v?"touchstart":"mousedown",P.$onFocusElementMouseDown))}}function k(){"focus"!==H.trigger?z.on("keyup",P.$onKeyUp):i.on("keyup",P.$onFocusKeyUp)}function S(){"focus"!==H.trigger?z.off("keyup",P.$onKeyUp):i.off("keyup",P.$onFocusKeyUp)}function x(){f(function(){z.on("click",C),w.on("click",P.hide),_=!0},0,!1)}function T(){_&&(z.off("click",C),w.off("click",P.hide),_=!1)}function C(e){e.stopPropagation()}function M(e){e=e||H.target||i;var a=e[0],o="BODY"===a.tagName,r=a.getBoundingClientRect(),s={};for(var l in r)s[l]=r[l];null===s.width&&(s=angular.extend({},s,{width:r.right-r.left,height:r.bottom-r.top}));var u=o?{top:0,left:0}:c.offset(a),d={scroll:o?t.documentElement.scrollTop||t.body.scrollTop:e.prop("scrollTop")||0},f=o?{width:t.documentElement.clientWidth,height:n.innerHeight}:null;return angular.extend({},s,d,f,u)}function E(e,t,n,a){var o,i=e.split("-");switch(i[0]){case"right":o={top:t.top+t.height/2-a/2,left:t.left+t.width};break;case"bottom":o={top:t.top+t.height,left:t.left+t.width/2-n/2};break;case"left":o={top:t.top+t.height/2-a/2,left:t.left-n};break;default:o={top:t.top-a,left:t.left+t.width/2-n/2}}if(!i[1])return o;if("top"===i[0]||"bottom"===i[0])switch(i[1]){case"left":o.left=t.left;break;case"right":o.left=t.left+t.width-n}else if("left"===i[0]||"right"===i[0])switch(i[1]){case"top":o.top=t.top-a;break;case"bottom":o.top=t.top+t.height}return o}function A(e,t){var n=z[0],a=n.offsetWidth,o=n.offsetHeight,i=parseInt(c.css(n,"margin-top"),10),r=parseInt(c.css(n,"margin-left"),10);isNaN(i)&&(i=0),isNaN(r)&&(r=0),e.top=e.top+i,e.left=e.left+r,c.setOffset(n,angular.extend({using:function(e){z.css({top:Math.round(e.top)+"px",left:Math.round(e.left)+"px"})}},e),0);var s=n.offsetWidth,l=n.offsetHeight;if("top"===t&&l!==o&&(e.top=e.top+o-l),!/top-left|top-right|bottom-left|bottom-right/.test(t)){var u=F(t,e,s,l);if(u.left?e.left+=u.left:e.top+=u.top,c.setOffset(n,e),/top|right|bottom|left/.test(t)){var d=/top|bottom/.test(t),f=d?2*u.left-a+s:2*u.top-o+l,p=d?"offsetWidth":"offsetHeight";V(f,n[p],d)}}}function F(e,t,n,a){var o={top:0,left:0},i=H.viewport&&m(H.viewport.selector||H.viewport);if(!i)return o;var r=H.viewport&&H.viewport.padding||0,s=M(i);if(/right|left/.test(e)){var l=t.top-r-s.scroll,u=t.top+r-s.scroll+a;ls.top+s.height&&(o.top=s.top+s.height-u)}else{var c=t.left-r,d=t.left+r+n;cs.width&&(o.left=s.left+s.width-d)}return o}function V(e,t,n){var a=m(".tooltip-arrow, .arrow",z[0]);a.css(n?"left":"top",50*(1-e/t)+"%").css(n?"top":"left","")}function O(){clearTimeout(R),P.$isShown&&null!==z&&(H.autoClose&&T(),H.keyboard&&S()),j&&(j.$destroy(),j=null),z&&(z.remove(),z=P.$element=null)}var P={},I=i[0].nodeName.toLowerCase(),H=P.$options=angular.extend({},e,r);P.$promise=$(H.template);var N=P.$scope=H.scope&&H.scope.$new()||a.$new();if(H.delay&&angular.isString(H.delay)){var L=H.delay.split(",").map(parseFloat);H.delay=L.length>1?{show:L[0],hide:L[1]}:L[0]}P.$id=H.id||i.attr("id")||"",H.title&&(N.title=u.trustAsHtml(H.title)),N.$setEnabled=function(e){N.$$postDigest(function(){P.setEnabled(e)})},N.$hide=function(){N.$$postDigest(function(){P.hide()})},N.$show=function(){N.$$postDigest(function(){P.show()})},N.$toggle=function(){N.$$postDigest(function(){P.toggle()})},P.$isShown=N.$isShown=!1;var R,Y;H.contentTemplate&&(P.$promise=P.$promise.then(function(e){var t=angular.element(e);return $(H.contentTemplate).then(function(e){var n=m('[ng-bind="content"]',t[0]);return n.length||(n=m('[ng-bind="title"]',t[0])),n.removeAttr("ng-bind").html(e),t[0].outerHTML})}));var q,z,K,U,j;P.$promise.then(function(e){angular.isObject(e)&&(e=e.data),H.html&&(e=e.replace(y,'ng-bind-html="')),e=h.apply(e),K=e,q=o(e),P.init()}),P.init=function(){H.delay&&angular.isNumber(H.delay)&&(H.delay={show:H.delay,hide:H.delay}),"self"===H.container?U=i:angular.isElement(H.container)?U=H.container:H.container&&(U=m(H.container)),b(),H.target&&(H.target=angular.isElement(H.target)?H.target:m(H.target)),H.show&&N.$$postDigest(function(){"focus"===H.trigger?i[0].focus():P.show()})},P.destroy=function(){D(),O(),N.$destroy()},P.enter=function(){return clearTimeout(R),Y="in",H.delay&&H.delay.show?void(R=setTimeout(function(){"in"===Y&&P.show()},H.delay.show)):P.show()},P.show=function(){if(H.bsEnabled&&!P.$isShown){N.$emit(H.prefixEvent+".show.before",P);var e,t;H.container?(e=U,t=U[0].lastChild?angular.element(U[0].lastChild):null):(e=null,t=i),z&&O(),j=P.$scope.$new(),z=P.$element=q(j,function(){}),z.css({top:"-9999px",left:"-9999px",display:"block",visibility:"hidden"}),H.animation&&z.addClass(H.animation),H.type&&z.addClass(H.prefixClass+"-"+H.type),H.customClass&&z.addClass(H.customClass),t?t.after(z):e.prepend(z),P.$isShown=N.$isShown=!0,g(N),P.$applyPlacement();var n=l.enter(z,e,t,s);n&&n.then&&n.then(s),g(N),d(function(){z&&z.css({visibility:"visible"})}),H.keyboard&&("focus"!==H.trigger&&P.focus(),k()),H.autoClose&&x()}},P.leave=function(){return clearTimeout(R),Y="out",H.delay&&H.delay.hide?void(R=setTimeout(function(){"out"===Y&&P.hide()},H.delay.hide)):P.hide()};var W,B;P.hide=function(e){if(P.$isShown){N.$emit(H.prefixEvent+".hide.before",P),W=e,B=z;var t=l.leave(z,p);t&&t.then&&t.then(p),P.$isShown=N.$isShown=!1,g(N),H.keyboard&&null!==z&&S(),H.autoClose&&null!==z&&T()}},P.toggle=function(){P.$isShown?P.leave():P.enter()},P.focus=function(){z[0].focus()},P.setEnabled=function(e){H.bsEnabled=e},P.setViewport=function(e){H.viewport=e},P.$applyPlacement=function(){if(z){var t=H.placement,n=/\s?auto?\s?/i,a=n.test(t);a&&(t=t.replace(n,"")||e.placement),z.addClass(H.placement);var o=M(),r=z.prop("offsetWidth"),s=z.prop("offsetHeight");if(a){var l=t,u=H.container?m(H.container):i.parent(),c=M(u);l.indexOf("bottom")>=0&&o.bottom+s>c.bottom?t=l.replace("bottom","top"):l.indexOf("top")>=0&&o.top-sc.width?t="right"===l?"left":t.replace("left","right"):("left"===l||"bottom-right"===l||"top-right"===l)&&o.left-r  
        ')}]),angular.module("mgcrea.ngStrap.aside").run(["$templateCache",function(a){a.put("aside/aside.tpl.html",'')}]),angular.module("mgcrea.ngStrap.datepicker").run(["$templateCache",function(a){a.put("datepicker/datepicker.tpl.html",'')}]),angular.module("mgcrea.ngStrap.dropdown").run(["$templateCache",function(a){a.put("dropdown/dropdown.tpl.html",'')}]),angular.module("mgcrea.ngStrap.modal").run(["$templateCache",function(a){a.put("modal/modal.tpl.html",'')}]),angular.module("mgcrea.ngStrap.popover").run(["$templateCache",function(a){a.put("popover/popover.tpl.html",'

        ')}]),angular.module("mgcrea.ngStrap.select").run(["$templateCache",function(a){a.put("select/select.tpl.html",'')}]),angular.module("mgcrea.ngStrap.tab").run(["$templateCache",function(a){a.put("tab/tab.tpl.html",'
        ')}]),angular.module("mgcrea.ngStrap.timepicker").run(["$templateCache",function(a){a.put("timepicker/timepicker.tpl.html",'')}]),angular.module("mgcrea.ngStrap.tooltip").run(["$templateCache",function(a){a.put("tooltip/tooltip.tpl.html",'
        ')}]),angular.module("mgcrea.ngStrap.typeahead").run(["$templateCache",function(a){a.put("typeahead/typeahead.tpl.html",'')}])}(window,document); -//# sourceMappingURL=angular-strap.tpl.min.map \ No newline at end of file +!function(){"use strict";angular.module("mgcrea.ngStrap.aside").run(["$templateCache",function(t){t.put("aside/aside.tpl.html",'')}]),angular.module("mgcrea.ngStrap.alert").run(["$templateCache",function(t){t.put("alert/alert.tpl.html",'
         
        ')}]),angular.module("mgcrea.ngStrap.datepicker").run(["$templateCache",function(t){t.put("datepicker/datepicker.tpl.html",'')}]),angular.module("mgcrea.ngStrap.dropdown").run(["$templateCache",function(t){t.put("dropdown/dropdown.tpl.html",'')}]),angular.module("mgcrea.ngStrap.modal").run(["$templateCache",function(t){t.put("modal/modal.tpl.html",'')}]),angular.module("mgcrea.ngStrap.popover").run(["$templateCache",function(t){t.put("popover/popover.tpl.html",'

        ')}]),angular.module("mgcrea.ngStrap.select").run(["$templateCache",function(t){t.put("select/select.tpl.html",'')}]),angular.module("mgcrea.ngStrap.tab").run(["$templateCache",function(t){t.put("tab/tab.tpl.html",'
        ')}]),angular.module("mgcrea.ngStrap.timepicker").run(["$templateCache",function(t){t.put("timepicker/timepicker.tpl.html",'')}]),angular.module("mgcrea.ngStrap.typeahead").run(["$templateCache",function(t){t.put("typeahead/typeahead.tpl.html",'')}]),angular.module("mgcrea.ngStrap.tooltip").run(["$templateCache",function(t){t.put("tooltip/tooltip.tpl.html",'
        ')}])}(window,document); \ No newline at end of file diff --git a/static/lib/hotkeys.min.css b/static/lib/hotkeys.min.css new file mode 100644 index 000000000..c7f8d24fb --- /dev/null +++ b/static/lib/hotkeys.min.css @@ -0,0 +1,8 @@ +/*! + * angular-hotkeys v1.4.5 + * https://chieffancypants.github.io/angular-hotkeys + * Copyright (c) 2014 Wes Cruver + * License: MIT + */ + +.cfp-hotkeys-container{display:table!important;position:fixed;width:100%;height:100%;top:0;left:0;color:#333;font-size:1em;background-color:rgba(255,255,255,.9)}.cfp-hotkeys-container.fade{z-index:-1024;visibility:hidden;opacity:0;-webkit-transition:opacity .15s linear;-moz-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.cfp-hotkeys-container.fade.in{z-index:10002;visibility:visible;opacity:1}.cfp-hotkeys-title{font-weight:700;text-align:center;font-size:1.2em}.cfp-hotkeys{width:100%;height:100%;display:table-cell;vertical-align:middle}.cfp-hotkeys table{margin:auto;color:#333}.cfp-content{display:table-cell;vertical-align:middle}.cfp-hotkeys-keys{padding:5px;text-align:right}.cfp-hotkeys-key{display:inline-block;color:#fff;background-color:#333;border:1px solid #333;border-radius:5px;text-align:center;margin-right:5px;box-shadow:inset 0 1px 0 #666,0 1px 0 #bbb;padding:5px 9px;font-size:1em}.cfp-hotkeys-text{padding-left:10px;font-size:1em}.cfp-hotkeys-close{position:fixed;top:20px;right:20px;font-size:2em;font-weight:700;padding:5px 10px;border:1px solid #ddd;border-radius:5px;min-height:45px;min-width:45px;text-align:center}.cfp-hotkeys-close:hover{background-color:#fff;cursor:pointer}@media all and (max-width:500px){.cfp-hotkeys{font-size:.8em}}@media all and (min-width:750px){.cfp-hotkeys{font-size:1.2em}} \ No newline at end of file diff --git a/static/lib/hotkeys.min.js b/static/lib/hotkeys.min.js new file mode 100644 index 000000000..2c658c1d8 --- /dev/null +++ b/static/lib/hotkeys.min.js @@ -0,0 +1,7 @@ +/*! + * angular-hotkeys v1.4.5 + * https://chieffancypants.github.io/angular-hotkeys + * Copyright (c) 2014 Wes Cruver + * License: MIT + */ +!function(){"use strict";angular.module("cfp.hotkeys",[]).provider("hotkeys",function(){this.includeCheatSheet=!0,this.templateTitle="Keyboard Shortcuts:",this.template='',this.cheatSheetHotkey="?",this.cheatSheetDescription="Show / hide this help menu",this.$get=["$rootElement","$rootScope","$compile","$window","$document",function(a,b,c,d,e){function f(a){var b={command:"⌘",shift:"⇧",left:"←",right:"→",up:"↑",down:"↓","return":"↩",backspace:"⌫"};a=a.split("+");for(var c=0;c=0?"command":"ctrl"),a[c]=b[a[c]]||a[c];return a.join(" + ")}function g(a,b,c,d,e,f){this.combo=a instanceof Array?a:[a],this.description=b,this.callback=c,this.action=d,this.allowIn=e,this.persistent=f}function h(){for(var a=o.hotkeys.length;a--;){var b=o.hotkeys[a];b&&!b.persistent&&k(b)}}function i(){o.helpVisible=!o.helpVisible,o.helpVisible?(t=l("esc"),k("esc"),j("esc",t.description,i)):(k("esc"),t!==!1&&j(t))}function j(a,b,c,d,e,f){var h,i=["INPUT","SELECT","TEXTAREA"],j=Object.prototype.toString.call(a);if("[object Object]"===j&&(b=a.description,c=a.callback,d=a.action,f=a.persistent,e=a.allowIn,a=a.combo),b instanceof Function?(d=c,c=b,b="$$undefined$$"):angular.isUndefined(b)&&(b="$$undefined$$"),void 0===f&&(f=!0),"function"==typeof c){h=c,e instanceof Array||(e=[]);for(var k,l=0;l-1)b=!0;else for(var e=0;e-1?(o.hotkeys[e].combo.length>1?o.hotkeys[e].combo.splice(o.hotkeys[e].combo.indexOf(b),1):o.hotkeys.splice(e,1),!0):!1}function l(a){for(var b,c=0;c-1)return b;return!1}function m(a){return a.$id in p||(p[a.$id]=[],a.$on("$destroy",function(){for(var b=p[a.$id].length;b--;)k(p[a.$id][b]),delete p[a.$id][b]})),{add:function(b){var c;return c=arguments.length>1?j.apply(this,arguments):j(b),p[a.$id].push(c),this}}}function n(a){return function(c,d){if(a instanceof Array){var e=a[0],f=a[1];a=function(){f.scope.$eval(e)}}b.$apply(function(){a(c,l(d))})}}Mousetrap.stopCallback=function(a,b){return(" "+b.className+" ").indexOf(" mousetrap ")>-1?!1:b.contentEditable&&"true"==b.contentEditable},g.prototype.format=function(){for(var a=this.combo[0],b=a.split(/[\s]/),c=0;c95&&112>a||y.hasOwnProperty(a)&&(w[y[a]]=a)}return w}function q(a,b,c){return c||(c=p()[a]?"keydown":"keypress"),"keypress"==c&&b.length&&(c="keydown"),c}function r(a,b,c,e){function g(b){return function(){H=b,++E[a],o()}}function h(b){k(c,b,a),"keyup"!==e&&(F=d(b)),setTimeout(f,10)}E[a]=0;for(var i=0;i1?void r(a,h,b,c):(f=t(a,c),C[f.key]=C[f.key]||[],g(f.key,f.modifiers,{type:f.action},d,a,e),void C[f.key][d?"unshift":"push"]({callback:b,modifiers:f.modifiers,action:f.action,seq:d,level:e,combo:a}))}function v(a,b,c){for(var d=0;d":".","?":"/","|":"\\"},B={option:"alt",command:"meta","return":"enter",escape:"esc",mod:/Mac|iPod|iPhone|iPad/.test(navigator.platform)?"meta":"ctrl"},C={},D={},E={},F=!1,G=!1,H=!1,I=1;20>I;++I)y[111+I]="f"+I;for(I=0;9>=I;++I)y[I+96]=I;c(b,"keypress",m),c(b,"keydown",m),c(b,"keyup",m);var J={bind:function(a,b,c){return a=a instanceof Array?a:[a],v(a,b,c),this},unbind:function(a,b){return J.bind(a,function(){},b)},trigger:function(a,b){return D[a+":"+b]&&D[a+":"+b]({},a),this},reset:function(){return C={},D={},this},stopCallback:function(a,b){return(" "+b.className+" ").indexOf(" mousetrap ")>-1?!1:"INPUT"==b.tagName||"SELECT"==b.tagName||"TEXTAREA"==b.tagName||b.isContentEditable},handleKey:l};a.Mousetrap=J,"function"==typeof define&&define.amd&&define(J)}(window,document); \ No newline at end of file diff --git a/static/partials/build-view.html b/static/partials/build-view.html index 1a2f00c77..43f48a7ce 100644 --- a/static/partials/build-view.html +++ b/static/partials/build-view.html @@ -61,4 +61,4 @@
        -
        \ No newline at end of file +
        diff --git a/static/partials/image-view.html b/static/partials/image-view.html index a4b8eaabb..181938afc 100644 --- a/static/partials/image-view.html +++ b/static/partials/image-view.html @@ -1,82 +1,59 @@ -
        -
        -
        - -

        - - -

        +
        +
        +
        + + + + {{ repository.namespace }}/{{ repository.name }} + + + + + {{ image.id.substr(0, 12) }} +
        - -
        - -
        - -
        -
        Full Image ID
        -
        -
        -
        -
        Created
        -
        -
        Compressed Image Size
        -
        {{ image.value.size | bytes }} -
        +
        +
        + + + + + + +
        -
        Command
        -
        -
        {{ getFormattedCommand(image.value) }}
        -
        -
        +
        + +
        +

        Image Layers

        +
        +
        +
        - -
        - File Changes: -
        -
        - -
        - - -
        - -
        -
        -
        -
        - Showing {{(combinedChanges | filter:search | limitTo:50).length}} of {{(combinedChanges | filter:search).length}} results + +
        +
        +

        Image File Changes

        +
        +
        No file changes
        +
        + There were no file system changes in this image layer. +
        -
        - -
        -
        -
        -
        -
        - No matching changes -
        -
        - - - - {{folder}}/{{getFilename(change.file)}} - + +
        +
        -
        - - -
        -
        -
        +
        diff --git a/static/partials/landing-login.html b/static/partials/landing-login.html index 5afb89af2..024762706 100644 --- a/static/partials/landing-login.html +++ b/static/partials/landing-login.html @@ -47,7 +47,7 @@
        - +
        Welcome {{ user.username }}!
        Browse all repositories Create a new repository diff --git a/static/partials/landing-normal.html b/static/partials/landing-normal.html index f8b969cd8..7865fdfac 100644 --- a/static/partials/landing-normal.html +++ b/static/partials/landing-normal.html @@ -46,7 +46,7 @@
        - +
        Welcome {{ user.username }}!
        Browse all repositories Create a new repository diff --git a/static/partials/landing.html b/static/partials/landing.html index 719d1a9cb..7813e1ab8 100644 --- a/static/partials/landing.html +++ b/static/partials/landing.html @@ -1,3 +1,3 @@ -
        +
        diff --git a/static/partials/manage-application.html b/static/partials/manage-application.html index ea7fc0860..d4101c5a1 100644 --- a/static/partials/manage-application.html +++ b/static/partials/manage-application.html @@ -10,7 +10,7 @@

        {{ application.name || '(Untitled)' }}

        - + {{ organization.name }}

        @@ -100,7 +100,7 @@
        Note: The generated token will act on behalf of user - + {{ user.username }}
        diff --git a/static/partials/new-organization.html b/static/partials/new-organization.html index 0e94be5ec..3d8143008 100644 --- a/static/partials/new-organization.html +++ b/static/partials/new-organization.html @@ -1,93 +1,80 @@ -
        -
        -
        - -
        - -
        -
        -

        Create Organization

        - -
        -
          -
        • - - Login with an account -
        • -
        • - - Setup your organization -
        • -
        • - - Create teams -
        • -
        -
        - +
        +
        +
        + + + Repositories + + + + Create New Organization +
        -
        - -
        -
        - In order to create a new organization, you must first be signed in as the - user that will become an admin for the organization. -
        -
        -
        -
        -
        -
        -
        +
        +
        - -
        -
        -
        -

        Setup the new organization

        - -
        -
        - - - This will also be the namespace for your repositories. Must be alphanumeric and all lowercase. +
        + +
        +
        + In order to create a new organization, you must first be signed in as the + user that will become an admin for the organization.
        - -
        - - - This address must be different from your account's email. +
        +
        +
        +
        +
        - -
        - Choose your organization's plan -
        + +
        +
        + +
        + + + This will also be the namespace for your repositories. Must be alphanumeric and all lowercase. +
        + +
        + + + This address must be different from your account's email. +
        + + +
        + Choose your organization's plan +
        +
        + +
        + +
        +
        +
        -
        - + +
        +
        +

        Organization Created

        +

        Manage Teams Now

        - -
        -
        -
        - - -
        -
        -
        -

        Organization Created

        -

        Manage Teams Now

        +
        diff --git a/static/partials/new-repo.html b/static/partials/new-repo.html index 291e34752..c8e0b3369 100644 --- a/static/partials/new-repo.html +++ b/static/partials/new-repo.html @@ -1,178 +1,183 @@ -
        -
        -
        -
        -
        +
        +
        +
        + + + Repositories + + + + Create New Repository + +
        -
        -
        -
        - -
        -
        -
        - -
        -
        - - -
        - -
        -
        -
        - - - / - - - - - Repository names must match [a-z0-9_-]+ - +
        +
        +
        +
        -
        -
        Repository Description
        -
        -
        +
        +
        + +
        + + + +
        + +
        +
        +
        + + + / + + + + + Repository names must match [a-z0-9_-]+ + +
        +
        + +
        +
        Repository Description
        +
        +
        +
        +
        +
        + + +
        + +
        +
        Repository Visibility
        +
        +
        + + + +
        + + Anyone can see and pull from this repository. You choose who can push. +
        +
        +
        + + + +
        + + You choose who can see, pull and push from/to this repository. +
        +
        + + +
        +
        + In order to make this repository private + under your personal namespace + under the organization {{ repo.namespace }}, you will need to upgrade your plan to + + {{ planRequired.title }} + . + This will cost ${{ planRequired.price / 100 }}/month. +
        + Upgrade now + or did you mean to create this repository + under {{ user.organizations[0].name }}? +
        +
        + +
        + +
        +
        + This organization has reached its private repository limit. Please contact your administrator. +
        +
        +
        +
        +
        + +
        + +
        +
        +
        Initialize repository
        + +
        + +
        + + + +
        + + +
        + + + +
        + + +
        + + + +
        +
        +
        +
        +
        + +
        + +
        +
        +
        Upload Dockerfile or Archive
        +
        +
        +
        +
        +
        +
        +
        +
        + +
        + +
        +
        + You will be redirected to authorize via GitHub once the repository has been created +
        +
        +
        + +
        + +
        + +
        +
        + +
        - - -
        - -
        -
        Repository Visibility
        -
        -
        - - - -
        - - Anyone can see and pull from this repository. You choose who can push. -
        -
        -
        - - - -
        - - You choose who can see, pull and push from/to this repository. -
        -
        - - -
        -
        - In order to make this repository private - under your personal namespace - under the organization {{ repo.namespace }}, you will need to upgrade your plan to - - {{ planRequired.title }} - . - This will cost ${{ planRequired.price / 100 }}/month. -
        - Upgrade now - or did you mean to create this repository - under {{ user.organizations[0].name }}? -
        -
        - -
        - -
        -
        - This organization has reached its private repository limit. Please contact your administrator. -
        -
        -
        -
        -
        - -
        - -
        -
        -
        Initialize repository
        - -
        - -
        - - - -
        - - -
        - - - -
        - - -
        - - - -
        - - -
        - - - -
        -
        -
        -
        -
        - -
        - -
        -
        -
        Upload DockerfileArchive
        -
        -
        -
        -
        -
        -
        -
        -
        - -
        - -
        -
        - You will be redirected to authorize via GitHub once the repository has been created -
        -
        -
        - -
        - -
        - -
        -
        - -
        diff --git a/static/partials/old-image-view.html b/static/partials/old-image-view.html new file mode 100644 index 000000000..a4b8eaabb --- /dev/null +++ b/static/partials/old-image-view.html @@ -0,0 +1,82 @@ +
        +
        +
        + +

        + + +

        +
        + + +
        + +
        + + +
        +
        Full Image ID
        +
        +
        +
        +
        Created
        +
        +
        Compressed Image Size
        +
        {{ image.value.size | bytes }} +
        + +
        Command
        +
        +
        {{ getFormattedCommand(image.value) }}
        +
        +
        + + +
        + File Changes: +
        +
        + +
        + + +
        + +
        +
        +
        +
        + Showing {{(combinedChanges | filter:search | limitTo:50).length}} of {{(combinedChanges | filter:search).length}} results +
        +
        + +
        +
        +
        +
        +
        + No matching changes +
        +
        + + + + {{folder}}/{{getFilename(change.file)}} + +
        +
        +
        +
        + + +
        +
        +
        +
        +
        +
        diff --git a/static/partials/old-new-organization.html b/static/partials/old-new-organization.html new file mode 100644 index 000000000..0e94be5ec --- /dev/null +++ b/static/partials/old-new-organization.html @@ -0,0 +1,94 @@ +
        +
        +
        + +
        + +
        +
        +

        Create Organization

        + +
        +
          +
        • + + Login with an account +
        • +
        • + + Setup your organization +
        • +
        • + + Create teams +
        • +
        +
        + +
        +
        + + +
        +
        + In order to create a new organization, you must first be signed in as the + user that will become an admin for the organization. +
        +
        +
        +
        +
        +
        +
        + + +
        +
        +
        +

        Setup the new organization

        + +
        +
        + + + This will also be the namespace for your repositories. Must be alphanumeric and all lowercase. +
        + +
        + + + This address must be different from your account's email. +
        + + +
        + Choose your organization's plan +
        +
        + +
        + +
        +
        +
        +
        +
        + + +
        +
        +
        +

        Organization Created

        +

        Manage Teams Now

        +
        +
        +
        +
        diff --git a/static/partials/old-new-repo.html b/static/partials/old-new-repo.html new file mode 100644 index 000000000..291e34752 --- /dev/null +++ b/static/partials/old-new-repo.html @@ -0,0 +1,237 @@ +
        +
        +
        +
        +
        + +
        +
        +
        + +
        +
        +
        + +
        +
        + + +
        + +
        +
        +
        + + + / + + + + + Repository names must match [a-z0-9_-]+ + +
        +
        + +
        +
        Repository Description
        +
        +
        +
        +
        +
        + + +
        + +
        +
        Repository Visibility
        +
        +
        + + + +
        + + Anyone can see and pull from this repository. You choose who can push. +
        +
        +
        + + + +
        + + You choose who can see, pull and push from/to this repository. +
        +
        + + +
        +
        + In order to make this repository private + under your personal namespace + under the organization {{ repo.namespace }}, you will need to upgrade your plan to + + {{ planRequired.title }} + . + This will cost ${{ planRequired.price / 100 }}/month. +
        + Upgrade now + or did you mean to create this repository + under {{ user.organizations[0].name }}? +
        +
        + +
        + +
        +
        + This organization has reached its private repository limit. Please contact your administrator. +
        +
        +
        +
        +
        + +
        + +
        +
        +
        Initialize repository
        + +
        + +
        + + + +
        + + +
        + + + +
        + + +
        + + + +
        + + +
        + + + +
        +
        +
        +
        +
        + +
        + +
        +
        +
        Upload DockerfileArchive
        +
        +
        +
        +
        +
        +
        +
        +
        + +
        + +
        +
        + You will be redirected to authorize via GitHub once the repository has been created +
        +
        +
        + +
        + +
        + +
        +
        + +
        +
        + + + + + + + + + + diff --git a/static/partials/old-org-view.html b/static/partials/old-org-view.html new file mode 100644 index 000000000..c5aae8994 --- /dev/null +++ b/static/partials/old-org-view.html @@ -0,0 +1,85 @@ +
        +
        +
        +
        + + + Create Team + + + Settings +
        +
        + + + +
        +
        +
        +
        + + + {{ team.name }} + + + {{ team.name }} + +
        + +
        +
        + +
        + + +
        +
        +
        +
        +
        + + + + + + + diff --git a/static/partials/old-team-view.html b/static/partials/old-team-view.html new file mode 100644 index 000000000..2ff2cd21f --- /dev/null +++ b/static/partials/old-team-view.html @@ -0,0 +1,128 @@ +
        +
        +
        +
        + +
        +
        + +
        +
        + +
        + This team has no members +
        + +
        + No matching team members found +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        Team Members
        + + + +
        Robot Accounts
        + + + +
        Invited To Join
        + + + + + + {{ member.email }} + + + +
        + +
        +
        +
        +
        +
        + +
        +
        +
        + + + + + + diff --git a/static/partials/old-tutorial.html b/static/partials/old-tutorial.html new file mode 100644 index 000000000..c590ac9ff --- /dev/null +++ b/static/partials/old-tutorial.html @@ -0,0 +1,3 @@ +
        +
        +
        diff --git a/static/partials/org-admin.html b/static/partials/org-admin.html index 5a0d912ff..dfe3c4b36 100644 --- a/static/partials/org-admin.html +++ b/static/partials/org-admin.html @@ -43,7 +43,6 @@
        - + +
        +
        +
        +
        -
        + + +
        +
        +
        +
        +
        + + +
        +
        +
        +
        +
        + + +
        +
        +
        + + +
        +
        +
        + + +
        +

        Plan Usage and Billing

        +
        +
        +
        +
        + + +
        +

        Billing Invoices

        +
        +
        + + +
        +
        +

        Organization Settings

        + +
        +
        Organization's e-mail address
        +
        + + + + +
        +
        +
        +
        +
        - - - - - - - diff --git a/static/partials/organizations.html b/static/partials/organizations.html index b556ed300..7bcc4f05c 100644 --- a/static/partials/organizations.html +++ b/static/partials/organizations.html @@ -23,7 +23,7 @@

        Organizations

        diff --git a/static/partials/repo-list.html b/static/partials/repo-list.html index d5ef953ec..07590a0ac 100644 --- a/static/partials/repo-list.html +++ b/static/partials/repo-list.html @@ -11,10 +11,10 @@
        -
        +
        -
        +
        diff --git a/static/partials/repo-view.html b/static/partials/repo-view.html index f63885110..b51016bc5 100644 --- a/static/partials/repo-view.html +++ b/static/partials/repo-view.html @@ -37,12 +37,12 @@ + ng-show="viewScope.repository.can_admin"> + ng-show="viewScope.repository.can_admin">
        @@ -93,4 +93,4 @@
        -
        \ No newline at end of file +
        diff --git a/static/partials/setup.html b/static/partials/setup.html index dba8a2b33..3556c6fcc 100644 --- a/static/partials/setup.html +++ b/static/partials/setup.html @@ -296,4 +296,4 @@
        -
        \ No newline at end of file +
        diff --git a/static/partials/super-user.html b/static/partials/super-user.html index 6d150634d..1e77afa06 100644 --- a/static/partials/super-user.html +++ b/static/partials/super-user.html @@ -139,7 +139,7 @@ - + diff --git a/static/partials/team-view.html b/static/partials/team-view.html index 36fa3a385..a0b7ce10c 100644 --- a/static/partials/team-view.html +++ b/static/partials/team-view.html @@ -1,82 +1,113 @@ -
        -
        -
        -
        - -
        +
        +
        +
        + + + + {{ organization.name }} + + + + + {{ teamname }} +
        -
        -
        +
        +
        +
        + +
        -
        - This team has no members +
        -
        - No matching team members found +
        +
        This team has no members.
        +
        + Click the "Add Team Member" button above to add or invite team members. +
        - - - - +
        Team Members
        + + + - + - - - + + + - + - - - + + + - +
        Team Members
        - + - + + + Remove {{ member.name }} + +
        Robot Accounts
        Robot Accounts
        - + + + Remove {{ member.name }} + +
        Invited To Join
        Invited to Join
        - + - + {{ member.email }} - + + + Revoke invite + +
        diff --git a/static/partials/tutorial.html b/static/partials/tutorial.html index c590ac9ff..cb8620ac5 100644 --- a/static/partials/tutorial.html +++ b/static/partials/tutorial.html @@ -1,3 +1,10 @@ -
        -
        -
        +
        +
        + + Tutorial +
        + +
        +
        +
        +
        \ No newline at end of file diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index 2e3249764..5f636e5cb 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -9,7 +9,7 @@
        - +
        + +
        +
        +
        Generate Encrypted Password
        + +
        +
        + Due to Docker storing passwords entered on the command line in plaintext, it is highly recommended to use the button below to generate an an encrypted version of your password. +
        + +
        + This installation is set to require encrypted passwords when + using the Docker command line interface. To generate an encrypted password, click the button below. +
        + + +
        +
        +
        + +
        Change Password
        @@ -152,6 +175,9 @@ Password changed successfully
        +
        Note: Changing your password will also invalidate any generated encrypted passwords.
        + +
        - + {{ user.username }}
        This will continue to be the namespace for your repositories
        @@ -350,7 +376,6 @@
        -