From 3f1e8f3c27f83972431ab5e35e4ba2f00b6b030e Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 13 Apr 2015 13:31:07 -0400 Subject: [PATCH 01/22] Add a RepositoryActionCount table so we can use it (instead of LogEntry) when scoring repo search results --- .../service/repositoryactioncounter/log/run | 2 + conf/init/service/repositoryactioncounter/run | 8 +++ data/database.py | 18 ++++++- ...4b75632_add_repositoryactioncount_table.py | 36 +++++++++++++ data/model/legacy.py | 30 +++++------ data/model/sqlalchemybridge.py | 6 ++- initdb.py | 5 ++ workers/repositoryactioncounter.py | 51 +++++++++++++++++++ 8 files changed, 137 insertions(+), 19 deletions(-) create mode 100755 conf/init/service/repositoryactioncounter/log/run create mode 100755 conf/init/service/repositoryactioncounter/run create mode 100644 data/migrations/versions/30c044b75632_add_repositoryactioncount_table.py create mode 100644 workers/repositoryactioncounter.py diff --git a/conf/init/service/repositoryactioncounter/log/run b/conf/init/service/repositoryactioncounter/log/run new file mode 100755 index 000000000..d86d5766f --- /dev/null +++ b/conf/init/service/repositoryactioncounter/log/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec logger -i -t repositoryactioncounter \ No newline at end of file diff --git a/conf/init/service/repositoryactioncounter/run b/conf/init/service/repositoryactioncounter/run new file mode 100755 index 000000000..08e0e3164 --- /dev/null +++ b/conf/init/service/repositoryactioncounter/run @@ -0,0 +1,8 @@ +#! /bin/bash + +echo 'Starting repository action count worker' + +cd / +venv/bin/python -m workers.repositoryactioncounter 2>&1 + +echo 'Repository action worker exited' \ No newline at end of file diff --git a/data/database.py b/data/database.py index 8bc0488a7..b039cf099 100644 --- a/data/database.py +++ b/data/database.py @@ -299,7 +299,7 @@ class Repository(BaseModel): # Therefore, we define our own deletion order here and use the dependency system to verify it. ordered_dependencies = [RepositoryAuthorizedEmail, RepositoryTag, Image, LogEntry, RepositoryBuild, RepositoryBuildTrigger, RepositoryNotification, - RepositoryPermission, AccessToken, Star] + RepositoryPermission, AccessToken, Star, RepositoryActionCount] for query, fk in self.dependencies(search_nullable=True): model = fk.model_class @@ -560,6 +560,20 @@ class LogEntry(BaseModel): metadata_json = TextField(default='{}') +class RepositoryActionCount(BaseModel): + repository = ForeignKeyField(Repository, index=True) + count = IntegerField() + date = DateField(index=True) + + class Meta: + database = db + read_slaves = (read_slave,) + indexes = ( + # create a unique index on repository and date + (('repository', 'date'), True), + ) + + class OAuthApplication(BaseModel): client_id = CharField(index=True, default=random_string_generator(length=20)) client_secret = CharField(default=random_string_generator(length=40)) @@ -645,4 +659,4 @@ all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission, ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification, RepositoryAuthorizedEmail, ImageStorageTransformation, DerivedImageStorage, TeamMemberInvite, ImageStorageSignature, ImageStorageSignatureKind, - AccessTokenKind, Star] + AccessTokenKind, Star, RepositoryActionCount] diff --git a/data/migrations/versions/30c044b75632_add_repositoryactioncount_table.py b/data/migrations/versions/30c044b75632_add_repositoryactioncount_table.py new file mode 100644 index 000000000..8df45958e --- /dev/null +++ b/data/migrations/versions/30c044b75632_add_repositoryactioncount_table.py @@ -0,0 +1,36 @@ +"""Add RepositoryActionCount table + +Revision ID: 30c044b75632 +Revises: 2b4dc0818a5e +Create Date: 2015-04-13 13:21:18.159602 + +""" + +# revision identifiers, used by Alembic. +revision = '30c044b75632' +down_revision = '2b4dc0818a5e' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.create_table('repositoryactioncount', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('repository_id', sa.Integer(), nullable=False), + sa.Column('count', sa.Integer(), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], name=op.f('fk_repositoryactioncount_repository_id_repository')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_repositoryactioncount')) + ) + op.create_index('repositoryactioncount_date', 'repositoryactioncount', ['date'], unique=False) + op.create_index('repositoryactioncount_repository_id', 'repositoryactioncount', ['repository_id'], unique=False) + op.create_index('repositoryactioncount_repository_id_date', 'repositoryactioncount', ['repository_id', 'date'], unique=True) + ### end Alembic commands ### + + +def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.drop_table('repositoryactioncount') + ### end Alembic commands ### diff --git a/data/model/legacy.py b/data/model/legacy.py index 6f8ded0a7..ed7f1f8d1 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, get_epoch_timestamp) + AccessTokenKind, Star, get_epoch_timestamp, RepositoryActionCount) from peewee import JOIN_LEFT_OUTER, fn from util.validation import (validate_username, validate_email, validate_password, INVALID_PASSWORD_MESSAGE) @@ -995,20 +995,19 @@ 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): + def get_search_results(search_clause, with_count=False): if len(results) >= limit: return - selected = [Repository, Namespace] + select_items = [Repository, Namespace] if with_count: - selected.append(fn.Count(LogEntry.id).alias('count')) + select_items.append(fn.Sum(RepositoryActionCount.count).alias('count')) - query = (Repository.select(*selected) + query = (Repository.select(*select_items) .join(Namespace, JOIN_LEFT_OUTER, on=(Namespace.id == Repository.namespace_user)) .switch(Repository) .where(search_clause) @@ -1021,9 +1020,10 @@ def get_sorted_matching_repositories(prefix, only_public, checker, limit=10): 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())) + query = (query.switch(Repository) + .join(RepositoryActionCount) + .where(RepositoryActionCount.date >= last_week) + .order_by(fn.Sum(RepositoryActionCount.count).desc())) for result in query: if len(results) >= limit: @@ -1042,13 +1042,13 @@ def get_sorted_matching_repositories(prefix, only_public, checker, limit=10): 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) + # 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) + get_search_results(Namespace.username ** (prefix + '%'), with_count=True) + get_search_results(Namespace.username ** (prefix + '%'), with_count=False) return results diff --git a/data/model/sqlalchemybridge.py b/data/model/sqlalchemybridge.py index 8b7d8b664..43248b55a 100644 --- a/data/model/sqlalchemybridge.py +++ b/data/model/sqlalchemybridge.py @@ -1,7 +1,7 @@ from sqlalchemy import (Table, MetaData, Column, ForeignKey, Integer, String, Boolean, Text, - DateTime, BigInteger, Index) + DateTime, Date, BigInteger, Index) from peewee import (PrimaryKeyField, CharField, BooleanField, DateTimeField, TextField, - ForeignKeyField, BigIntegerField, IntegerField) + ForeignKeyField, BigIntegerField, IntegerField, DateField) OPTIONS_TO_COPY = [ @@ -42,6 +42,8 @@ def gen_sqlalchemy_metadata(peewee_model_list): alchemy_type = Boolean elif isinstance(field, DateTimeField): alchemy_type = DateTime + elif isinstance(field, DateField): + alchemy_type = Date elif isinstance(field, TextField): alchemy_type = Text elif isinstance(field, ForeignKeyField): diff --git a/initdb.py b/initdb.py index 104e0fc19..402a9e186 100644 --- a/initdb.py +++ b/initdb.py @@ -16,6 +16,8 @@ from data import model from data.model import oauth from app import app, storage as store +from workers import repositoryactioncounter + logger = logging.getLogger(__name__) @@ -582,6 +584,9 @@ def populate_database(): 'trigger_id': trigger.uuid, 'config': json.loads(trigger.config), 'service': trigger.service.name}) + while repositoryactioncounter.count_repository_actions(): + pass + if __name__ == '__main__': log_level = getattr(logging, app.config['LOGGING_LEVEL']) logging.basicConfig(level=log_level) diff --git a/workers/repositoryactioncounter.py b/workers/repositoryactioncounter.py new file mode 100644 index 000000000..1341cdfc9 --- /dev/null +++ b/workers/repositoryactioncounter.py @@ -0,0 +1,51 @@ +import logging + +from apscheduler.schedulers.blocking import BlockingScheduler + +from data.database import Repository, LogEntry, RepositoryActionCount, db_random_func, fn +from datetime import date, datetime, timedelta + +POLL_PERIOD_SECONDS = 30 + +logger = logging.getLogger(__name__) +sched = BlockingScheduler() + +@sched.scheduled_job(trigger='interval', seconds=10) +def count_repository_actions(): + """ Counts actions for a random repository for the previous day. """ + + try: + # Get a random repository to count. + today = date.today() + yesterday = today - timedelta(days=1) + has_yesterday_actions = (RepositoryActionCount.select(RepositoryActionCount.repository) + .where(RepositoryActionCount.date == yesterday)) + + to_count = (Repository.select() + .where(~(Repository.id << (has_yesterday_actions))) + .order_by(db_random_func()).get()) + + logger.debug('Counting: %s', to_count.id) + + actions = (LogEntry.select() + .where(LogEntry.repository == to_count, + LogEntry.datetime >= yesterday, + LogEntry.datetime < today) + .count()) + + # Create the row. + try: + RepositoryActionCount.create(repository=to_count, date=yesterday, count=actions) + except: + logger.exception('Exception when writing count') + + return True + + except Repository.DoesNotExist: + logger.debug('No further repositories to count') + return False + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + sched.start() From 657c9d1cc95355efe260057eaf9401b470f98692 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 13 Apr 2015 14:24:52 -0400 Subject: [PATCH 02/22] Make the search results only show the first line, properly marked down, for repositories --- static/directives/new-header-bar.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/directives/new-header-bar.html b/static/directives/new-header-bar.html index f0f11f5f6..94aa60710 100644 --- a/static/directives/new-header-bar.html +++ b/static/directives/new-header-bar.html @@ -185,7 +185,8 @@ {{ result.namespace.name }}/{{ result.name }}
- {{ result.description }} +
From 657ba576a8798b0def3e3d8d41f8bf831ded47d9 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 13 Apr 2015 14:25:09 -0400 Subject: [PATCH 03/22] Make sure to import app so that the DB proxy gets properly initialized --- workers/repositoryactioncounter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/workers/repositoryactioncounter.py b/workers/repositoryactioncounter.py index 1341cdfc9..5a73c194b 100644 --- a/workers/repositoryactioncounter.py +++ b/workers/repositoryactioncounter.py @@ -2,6 +2,7 @@ import logging from apscheduler.schedulers.blocking import BlockingScheduler +from app import app from data.database import Repository, LogEntry, RepositoryActionCount, db_random_func, fn from datetime import date, datetime, timedelta From 0533130de3e970c24c8d3654c89b29a8c147be5a Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 15 Apr 2015 15:23:50 -0400 Subject: [PATCH 04/22] Fix NPE --- static/js/directives/repo-view/repo-panel-tags.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/js/directives/repo-view/repo-panel-tags.js b/static/js/directives/repo-view/repo-panel-tags.js index 283ce2dde..cf2f25595 100644 --- a/static/js/directives/repo-view/repo-panel-tags.js +++ b/static/js/directives/repo-view/repo-panel-tags.js @@ -164,7 +164,7 @@ angular.module('quay').directive('repoPanelTags', function () { 'end_ts': tag.end_ts, 'time': currentTime, 'docker_image_id': imageId, - 'old_docker_image_id': oldImageId + 'old_docker_image_id': oldImageId || '' }) } From daa2ce19aa7ce69a36604ef8c3b68375cce8cbb5 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 15 Apr 2015 15:38:59 -0400 Subject: [PATCH 05/22] Fix typo --- static/directives/repo-view/repo-panel-tags.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/directives/repo-view/repo-panel-tags.html b/static/directives/repo-view/repo-panel-tags.html index 575d91dcc..13f82ed80 100644 --- a/static/directives/repo-view/repo-panel-tags.html +++ b/static/directives/repo-view/repo-panel-tags.html @@ -140,7 +140,7 @@ {{ tag.size | bytes }} - + Date: Wed, 15 Apr 2015 16:11:04 -0400 Subject: [PATCH 06/22] Fix off by one issue --- static/js/directives/repo-view/repo-panel-tags.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/js/directives/repo-view/repo-panel-tags.js b/static/js/directives/repo-view/repo-panel-tags.js index cf2f25595..74e83847a 100644 --- a/static/js/directives/repo-view/repo-panel-tags.js +++ b/static/js/directives/repo-view/repo-panel-tags.js @@ -194,7 +194,7 @@ angular.module('quay').directive('repoPanelTags', function () { var next = tagData[i - 1]; if (new Date(current.time).getDate() != new Date(next.time).getDate()) { - tagData.splice(i - 1, 0, { + tagData.splice(i, 0, { 'date_break': true, 'date': new Date(current.time) }); From 2a77bd2c9281eeeb5500a589dc0dd438d141b291 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 15 Apr 2015 18:39:05 -0400 Subject: [PATCH 07/22] - UI improvements in prep for adding undo ability - Move the tag history into its own directive and clean up the code --- .../directives/repo-view/repo-panel-tags.css | 127 -------------- static/css/directives/ui/repo-tag-history.css | 131 +++++++++++++++ static/directives/repo-tag-history.html | 45 +++++ .../directives/repo-view/repo-panel-tags.html | 54 +----- .../directives/repo-view/repo-panel-tags.js | 135 +-------------- static/js/directives/ui/repo-tag-history.js | 155 ++++++++++++++++++ 6 files changed, 335 insertions(+), 312 deletions(-) create mode 100644 static/css/directives/ui/repo-tag-history.css create mode 100644 static/directives/repo-tag-history.html create mode 100644 static/js/directives/ui/repo-tag-history.js diff --git a/static/css/directives/repo-view/repo-panel-tags.css b/static/css/directives/repo-view/repo-panel-tags.css index 1a81a5604..9df9c2679 100644 --- a/static/css/directives/repo-view/repo-panel-tags.css +++ b/static/css/directives/repo-view/repo-panel-tags.css @@ -68,131 +68,4 @@ .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/repo-tag-history.css b/static/css/directives/ui/repo-tag-history.css new file mode 100644 index 000000000..7ee39b7bc --- /dev/null +++ b/static/css/directives/ui/repo-tag-history.css @@ -0,0 +1,131 @@ +.repo-tag-history-element .history-list { + margin: 10px; + border-left: 2px solid #eee; + margin-right: 150px; +} + +.repo-tag-history-element .history-entry { + position:relative; + margin-top: 20px; + padding-left: 26px; + + transition: all 350ms ease-in-out; +} + +.repo-tag-history-element .history-entry .history-text { + transition: transform 350ms ease-in-out, opacity 350ms ease-in-out; + overflow: hidden; + height: 40px; +} + +.repo-tag-history-element .history-entry.filtered-mismatch { + margin-top: 10px; +} + +.repo-tag-history-element .history-entry.filtered-mismatch .history-text { + height: 18px; + opacity: 0; +} + +.repo-tag-history-element .history-entry.filtered-mismatch .history-icon { + opacity: 0.5; + transform: scale(0.5, 0.5); +} + +.repo-tag-history-element .history-entry.filtered-mismatch.current .history-icon { + background-color: #ccc !important; +} + +.repo-tag-history-element .history-entry .history-date-break { + font-size: 16px; +} + +.repo-tag-history-element .history-entry .history-date-break:before { + content: ""; + position: absolute; + border-radius: 50%; + width: 12px; + height: 12px; + background: #ccc; + top: 4px; + left: -7px; +} + +.repo-tag-history-element .history-entry .history-icon { + position: absolute; + left: -17px; + top: -4px; + + border-radius: 50%; + width: 32px; + height: 32px; + line-height: 32px; + text-align: center; + font-size: 20px; + color: white; + background: #ccc; + + display: inline-block; + transition: all 350ms ease-in-out; +} + +.repo-tag-history-element .history-entry.move .history-icon:before { + content: "\f061"; + font-family: FontAwesome; +} + +.repo-tag-history-element .history-entry.create .history-icon:before { + content: "\f02b"; + font-family: FontAwesome; +} + +.repo-tag-history-element .history-entry.delete .history-icon:before { + content: "\f014"; + font-family: FontAwesome; +} + +.repo-tag-history-element .history-entry.current.move .history-icon { + background-color: #77BFF0; +} + +.repo-tag-history-element .history-entry.current.create .history-icon { + background-color: #98df8a; +} + +.repo-tag-history-element .history-entry.current.delete .history-icon { + background-color: #ff9896; +} + +.repo-tag-history-element .history-entry .history-icon .fa-tag { + margin-right: 0px; +} + +.repo-tag-history-element .history-entry .tag-span { + display: inline-block; + border-radius: 4px; + padding: 2px; + background: #eee; + padding-right: 6px; + color: black; + cursor: pointer; +} + +.repo-tag-history-element .history-entry .tag-span.checked { + background: #F6FCFF; +} + +.repo-tag-history-element .history-entry .tag-span:before { + content: "\f02b"; + font-family: FontAwesome; + margin-left: 4px; + margin-right: 4px; +} + +.repo-tag-history-element .history-entry .history-description { + color: #777; +} + +.repo-tag-history-element .history-entry .history-datetime { + font-size: 12px; + color: #ccc; +} \ No newline at end of file diff --git a/static/directives/repo-tag-history.html b/static/directives/repo-tag-history.html new file mode 100644 index 000000000..73bd1c6b2 --- /dev/null +++ b/static/directives/repo-tag-history.html @@ -0,0 +1,45 @@ +
+
+ + + + + +
+
+
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' }}
+
+
+
+
+
\ No newline at end of file diff --git a/static/directives/repo-view/repo-panel-tags.html b/static/directives/repo-view/repo-panel-tags.html index 13f82ed80..9454d8f4e 100644 --- a/static/directives/repo-view/repo-panel-tags.html +++ b/static/directives/repo-view/repo-panel-tags.html @@ -1,5 +1,5 @@
-
+
+ + +
+ +
+ This will change the image to which the tag points. +
+ + Are you sure you want to revert tag + {{ revertTagInfo.tag.name }} to image + {{ revertTagInfo.image_id.substr(0, 12) }}? +
+
\ No newline at end of file diff --git a/static/js/directives/repo-view/repo-panel-tags.js b/static/js/directives/repo-view/repo-panel-tags.js index 3d0604503..97b63250b 100644 --- a/static/js/directives/repo-view/repo-panel-tags.js +++ b/static/js/directives/repo-view/repo-panel-tags.js @@ -24,6 +24,7 @@ angular.module('quay').directive('repoPanelTags', function () { }; $scope.iterationState = {}; + $scope.tagHistory = {}; $scope.tagActionHandler = null; $scope.showingHistory = false; @@ -84,6 +85,9 @@ angular.module('quay').directive('repoPanelTags', function () { 'count': imageMap[image_id].length, 'tags': imageMap[image_id] }); + + imageMap[image_id]['color'] = colors(index); + ++index; } }); @@ -224,6 +228,24 @@ angular.module('quay').directive('repoPanelTags', function () { return names.join(','); }; + + $scope.loadTagHistory = function(tag) { + delete $scope.tagHistory[tag.name]; + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'specificTag': tag.name, + 'limit': 5 + }; + + ApiService.listRepoTags(null, params).then(function(resp) { + $scope.tagHistory[tag.name] = resp.tags; + }, ApiService.errorDisplay('Could not load tag history')); + }; + + $scope.askRevertTag = function(tag, image_id) { + $scope.tagActionHandler.askRevertTag(tag, image_id); + }; } }; return directiveDefinitionObject; diff --git a/static/js/directives/ui/logs-view.js b/static/js/directives/ui/logs-view.js index 8f85c3261..9a353b2c8 100644 --- a/static/js/directives/ui/logs-view.js +++ b/static/js/directives/ui/logs-view.js @@ -98,6 +98,7 @@ angular.module('quay').directive('logsView', function () { return 'Remove permission for token {token} from repository {repo}'; } }, + 'revert_tag': 'Tag {tag} reverted to image {image} from image {original_image}', 'delete_tag': 'Tag {tag} deleted in repository {repo} by user {username}', 'create_tag': 'Tag {tag} created in repository {repo} on image {image} by user {username}', 'move_tag': 'Tag {tag} moved from image {original_image} to image {image} in repository {repo} by user {username}', @@ -213,6 +214,7 @@ angular.module('quay').directive('logsView', function () { 'delete_tag': 'Delete Tag', 'create_tag': 'Create Tag', 'move_tag': 'Move Tag', + 'revert_tag':' Revert Tag', 'org_create_team': 'Create team', 'org_delete_team': 'Delete team', 'org_add_team_member': 'Add team member', diff --git a/static/js/directives/ui/repo-tag-history.js b/static/js/directives/ui/repo-tag-history.js index 840441c1c..c6ba6120d 100644 --- a/static/js/directives/ui/repo-tag-history.js +++ b/static/js/directives/ui/repo-tag-history.js @@ -56,6 +56,7 @@ angular.module('quay').directive('repoTagHistory', function () { 'action': action, 'start_ts': tag.start_ts, 'end_ts': tag.end_ts, + 'reversion': tag.reversion, 'time': time * 1000, // JS expects ms, not s since epoch. 'docker_image_id': opt_docker_id || dockerImageId, 'old_docker_image_id': opt_old_docker_id || '' @@ -73,7 +74,8 @@ angular.module('quay').directive('repoTagHistory', function () { var futureEntry = currentEntries.length > 0 ? currentEntries[currentEntries.length - 1] : {}; if (futureEntry.start_ts == tag.end_ts) { removeEntry(futureEntry); - addEntry('move', tag.end_ts, futureEntry.docker_image_id, dockerImageId); + addEntry(futureEntry.reversion ? 'revert': 'move', tag.end_ts, + futureEntry.docker_image_id, dockerImageId); } else { addEntry('delete', tag.end_ts) } diff --git a/static/js/directives/ui/tag-operations-dialog.js b/static/js/directives/ui/tag-operations-dialog.js index 4a5aa1877..3fc3ea080 100644 --- a/static/js/directives/ui/tag-operations-dialog.js +++ b/static/js/directives/ui/tag-operations-dialog.js @@ -121,6 +121,25 @@ angular.module('quay').directive('tagOperationsDialog', function () { }, errorHandler); }; + $scope.revertTag = function(tag, image_id, callback) { + if (!$scope.repository.can_write) { return; } + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'tag': tag.name + }; + + var data = { + 'image': image_id + }; + + var errorHandler = ApiService.errorDisplay('Cannot revert tag', callback); + ApiService.revertTag(data, params).then(function() { + callback(true); + markChanged([], [tag]); + }, errorHandler); + }; + $scope.actionHandler = { 'askDeleteTag': function(tag) { $scope.deleteTagInfo = { @@ -140,6 +159,20 @@ angular.module('quay').directive('tagOperationsDialog', function () { $scope.addingTag = false; $scope.addTagForm.$setPristine(); $element.find('#createOrMoveTagModal').modal('show'); + }, + + 'askRevertTag': function(tag, image_id) { + if (tag.image_id == image_id) { + bootbox.alert('This is the current image for the tag'); + return; + } + + $scope.revertTagInfo = { + 'tag': tag, + 'image_id': image_id + }; + + $element.find('#revertTagModal').modal('show'); } }; } From a8f8c317f9c352e107ef7c6c3990bec9d87c91e7 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Sun, 19 Apr 2015 15:20:01 -0400 Subject: [PATCH 09/22] Fix branch in alembic migrations --- .../4ce2169efd3b_add_reversion_column_to_the_tags_table.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/migrations/versions/4ce2169efd3b_add_reversion_column_to_the_tags_table.py b/data/migrations/versions/4ce2169efd3b_add_reversion_column_to_the_tags_table.py index 9329942b0..733971ef1 100644 --- a/data/migrations/versions/4ce2169efd3b_add_reversion_column_to_the_tags_table.py +++ b/data/migrations/versions/4ce2169efd3b_add_reversion_column_to_the_tags_table.py @@ -1,14 +1,14 @@ """Add reversion column to the tags table Revision ID: 4ce2169efd3b -Revises: 2b4dc0818a5e +Revises: 30c044b75632 Create Date: 2015-04-16 17:10:16.039835 """ # revision identifiers, used by Alembic. revision = '4ce2169efd3b' -down_revision = '2b4dc0818a5e' +down_revision = '30c044b75632' from alembic import op import sqlalchemy as sa From e16657ed0e30cc43a4b0eb2763642e7e53b1b60a Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Sun, 19 Apr 2015 15:25:33 -0400 Subject: [PATCH 10/22] Add security tests for the new revert endpoint --- test/test_api_security.py | 57 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/test/test_api_security.py b/test/test_api_security.py index 979986c8c..bc65ecdfe 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -11,7 +11,7 @@ from initdb import setup_database_for_testing, finished_database_for_testing from endpoints.api import api_bp, api from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam, TeamMemberInvite -from endpoints.api.tag import RepositoryTagImages, RepositoryTag, ListRepositoryTags +from endpoints.api.tag import RepositoryTagImages, RepositoryTag, ListRepositoryTags, RevertTag from endpoints.api.search import FindRepositories, EntitySearch from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, RepositoryBuildLogs, @@ -2481,6 +2481,61 @@ class TestRepositoryImage5avqBuynlargeOrgrepo(ApiTestCase): self._run_test('GET', 404, 'devtable', None) +class TestRevertTagHp8rPublicPublicrepo(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(RevertTag, tag="HP8R", repository="public/publicrepo") + + def test_post_anonymous(self): + self._run_test('POST', 401, None, {u'image': 'WXNG'}) + + def test_post_freshuser(self): + self._run_test('POST', 403, 'freshuser', {u'image': 'WXNG'}) + + def test_post_reader(self): + self._run_test('POST', 403, 'reader', {u'image': 'WXNG'}) + + def test_post_devtable(self): + self._run_test('POST', 403, 'devtable', {u'image': 'WXNG'}) + + +class TestRevertTagHp8rDevtableShared(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(RevertTag, tag="HP8R", repository="devtable/shared") + + def test_post_anonymous(self): + self._run_test('POST', 401, None, {u'image': 'WXNG'}) + + def test_post_freshuser(self): + self._run_test('POST', 403, 'freshuser', {u'image': 'WXNG'}) + + def test_post_reader(self): + self._run_test('POST', 403, 'reader', {u'image': 'WXNG'}) + + def test_post_devtable(self): + self._run_test('POST', 404, 'devtable', {u'image': 'WXNG'}) + + +class TestRevertTagHp8rBuynlargeOrgrepo(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(RevertTag, tag="HP8R", repository="buynlarge/orgrepo") + + def test_post_anonymous(self): + self._run_test('POST', 401, None, {u'image': 'WXNG'}) + + def test_post_freshuser(self): + self._run_test('POST', 403, 'freshuser', {u'image': 'WXNG'}) + + def test_post_reader(self): + self._run_test('POST', 403, 'reader', {u'image': 'WXNG'}) + + def test_post_devtable(self): + self._run_test('POST', 404, 'devtable', {u'image': 'WXNG'}) + + + class TestRepositoryTagHp8rPublicPublicrepo(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) From d1e2d072ea661fc4f3949c949e74a388b5cbf42b Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Sun, 19 Apr 2015 15:43:16 -0400 Subject: [PATCH 11/22] Add unit tests and a stronger restriction on the revert API call --- data/model/legacy.py | 20 ++++++++++++++------ endpoints/api/tag.py | 2 +- test/test_api_usage.py | 41 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/data/model/legacy.py b/data/model/legacy.py index 00b46d532..b3b5e806f 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -2829,12 +2829,20 @@ def repository_is_starred(user, repository): return False -def revert_tag(namespace_name, repository_name, tag_name, docker_image_id): +def revert_tag(repository, tag_name, docker_image_id): """ Reverts a tag to a specific image ID. """ - image = get_image_by_id(namespace_name, repository_name, docker_image_id) - if image is None: - raise DataModelException('Cannot revert to unknown image') + # Verify that the image ID already existed under this repository under the + # tag. + try: + (RepositoryTag.select() + .join(Image) + .where(RepositoryTag.repository == repository) + .where(RepositoryTag.name == tag_name) + .where(Image.docker_image_id == docker_image_id) + .get()) + except RepositoryTag.DoesNotExist: + raise DataModelException('Cannot revert to unknown or invalid image') - return create_or_update_tag(namespace_name, repository_name, tag_name, docker_image_id, - reversion=True) + return create_or_update_tag(repository.namespace_user.username, repository.name, + tag_name, docker_image_id, reversion=True) diff --git a/endpoints/api/tag.py b/endpoints/api/tag.py index c5a6b2288..9ca880e3f 100644 --- a/endpoints/api/tag.py +++ b/endpoints/api/tag.py @@ -177,7 +177,7 @@ class RevertTag(RepositoryParamResource): # Revert the tag back to the previous image. image_id = request.get_json()['image'] - model.revert_tag(namespace, repository, tag, image_id) + model.revert_tag(tag_image.repository, tag, image_id) model.garbage_collect_repository(namespace, repository) # Log the reversion. diff --git a/test/test_api_usage.py b/test/test_api_usage.py index e994fe205..561794174 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -14,7 +14,7 @@ from initdb import setup_database_for_testing, finished_database_for_testing from data import model, database from endpoints.api.team import TeamMember, TeamMemberList, TeamMemberInvite, OrganizationTeam -from endpoints.api.tag import RepositoryTagImages, RepositoryTag +from endpoints.api.tag import RepositoryTagImages, RepositoryTag, RevertTag, ListRepositoryTags from endpoints.api.search import FindRepositories, EntitySearch, ConductSearch from endpoints.api.image import RepositoryImage, RepositoryImageList from endpoints.api.build import (RepositoryBuildStatus, RepositoryBuildLogs, RepositoryBuildList, @@ -1746,6 +1746,45 @@ class TestGetImageChanges(ApiTestCase): # image_id=image_id)) +class TestRevertTag(ApiTestCase): + def test_reverttag_invalidtag(self): + self.login(ADMIN_ACCESS_USER) + + self.postResponse(RevertTag, + params=dict(repository=ADMIN_ACCESS_USER + '/history', tag='invalidtag'), + data=dict(image='invalid_image'), + expected_code=404) + + def test_reverttag_invalidimage(self): + self.login(ADMIN_ACCESS_USER) + + self.postResponse(RevertTag, + params=dict(repository=ADMIN_ACCESS_USER + '/history', tag='latest'), + data=dict(image='invalid_image'), + expected_code=400) + + def test_reverttag(self): + self.login(ADMIN_ACCESS_USER) + + json = self.getJsonResponse(ListRepositoryTags, + params=dict(repository=ADMIN_ACCESS_USER + '/history', tag='latest')) + + self.assertEquals(2, len(json['tags'])) + self.assertFalse('end_ts' in json['tags'][0]) + + previous_image_id = json['tags'][1]['docker_image_id'] + + self.postJsonResponse(RevertTag, + params=dict(repository=ADMIN_ACCESS_USER + '/history', tag='latest'), + data=dict(image=previous_image_id)) + + json = self.getJsonResponse(ListRepositoryTags, + params=dict(repository=ADMIN_ACCESS_USER + '/history', tag='latest')) + self.assertEquals(3, len(json['tags'])) + self.assertFalse('end_ts' in json['tags'][0]) + self.assertEquals(previous_image_id, json['tags'][0]['docker_image_id']) + + class TestListAndDeleteTag(ApiTestCase): def test_listdeletecreateandmovetag(self): self.login(ADMIN_ACCESS_USER) From e56d5a9fe57d183816af3d3b06ad6e19f130dc7e Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Sun, 19 Apr 2015 15:48:34 -0400 Subject: [PATCH 12/22] Rebuild test db --- test/data/test.db | Bin 753664 -> 774144 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test/data/test.db b/test/data/test.db index c9adcb7cd8708d65a25a33562f4b0ffa454c7e8d..cc20de04d7980b766a16366dd07e118961d98c78 100644 GIT binary patch delta 16414 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 js6Tn-{olxUZn<77hlj|o(B?uXZPI?d1LkA)6+x From ed342ae831afeb51cf13e2b79d0eb57aba3e2696 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Sun, 19 Apr 2015 16:03:06 -0400 Subject: [PATCH 13/22] Add migration for properly creating the repository_id+datetime index --- ...b_add_index_for_repository_datetime_to_.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 data/migrations/versions/67eb43c778b_add_index_for_repository_datetime_to_.py diff --git a/data/migrations/versions/67eb43c778b_add_index_for_repository_datetime_to_.py b/data/migrations/versions/67eb43c778b_add_index_for_repository_datetime_to_.py new file mode 100644 index 000000000..00ff374e4 --- /dev/null +++ b/data/migrations/versions/67eb43c778b_add_index_for_repository_datetime_to_.py @@ -0,0 +1,26 @@ +"""add index for repository+datetime to logentry + +Revision ID: 67eb43c778b +Revises: 1c3decf6b9c4 +Create Date: 2015-04-19 16:00:39.126289 + +""" + +# revision identifiers, used by Alembic. +revision = '67eb43c778b' +down_revision = '1c3decf6b9c4' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.create_index('logentry_repository_id_datetime', 'logentry', ['repository_id', 'datetime'], unique=False) + ### end Alembic commands ### + + +def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.drop_index('logentry_repository_id_datetime', table_name='logentry') + ### end Alembic commands ### From 43ff6839b83f807d0a99ffc1979bcbf67d35feec Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Sun, 19 Apr 2015 18:12:06 -0400 Subject: [PATCH 14/22] Hide hidden tags in the tags timeline --- data/model/legacy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/data/model/legacy.py b/data/model/legacy.py index b3b5e806f..813fe0e67 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -1767,6 +1767,7 @@ def list_repository_tag_history(repository, limit=100, specific_tag=None): .select(RepositoryTag, Image) .join(Image) .where(RepositoryTag.repository == repository) + .where(RepositoryTag.hidden == False) .order_by(RepositoryTag.lifetime_start_ts.desc()) .limit(limit)) From 56f8bd1661d61fcf37a69d72597c504d6d461aaa Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Sun, 19 Apr 2015 18:13:13 -0400 Subject: [PATCH 15/22] Clarify the empty message on the tag timeline --- static/directives/repo-tag-history.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/directives/repo-tag-history.html b/static/directives/repo-tag-history.html index f6e4c30c1..16d8e5e7c 100644 --- a/static/directives/repo-tag-history.html +++ b/static/directives/repo-tag-history.html @@ -7,8 +7,8 @@
-
This repository is empty.
-
Push a tag or initiate a build to populate this repository.
+
No recent tag activity.
+
There has not been any recent tag activity on this repository.
Date: Mon, 20 Apr 2015 12:00:26 -0400 Subject: [PATCH 16/22] Fix NPE --- static/js/directives/ui/build-logs-view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/js/directives/ui/build-logs-view.js b/static/js/directives/ui/build-logs-view.js index a5f27552d..bce65cd53 100644 --- a/static/js/directives/ui/build-logs-view.js +++ b/static/js/directives/ui/build-logs-view.js @@ -159,7 +159,7 @@ angular.module('quay').directive('buildLogsView', function () { // Note: order is important here. var setup = filter.getSetupHtml(); - var stream = filter.addInputToStream(message); + var stream = filter.addInputToStream(message || ''); var teardown = filter.getTeardownHtml(); return setup + stream + teardown; }; From d714c00ddb814e96e9e685bd45e4e91aecfb62c5 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 20 Apr 2015 12:01:17 -0400 Subject: [PATCH 17/22] Fix NPE --- static/js/pages/repo-view.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/static/js/pages/repo-view.js b/static/js/pages/repo-view.js index 7cb8ed365..69f4b6af0 100644 --- a/static/js/pages/repo-view.js +++ b/static/js/pages/repo-view.js @@ -148,11 +148,11 @@ // Watch for changes to the repository. $scope.$watch('repo', function() { - if ($scope.tree) { - $timeout(function() { + $timeout(function() { + if ($scope.tree) { $scope.tree.notifyResized(); - }); - } + } + }); }); // Watch for changes to the tag parameter. From 16e05e83b1a288621971c459367d432ce5d07f2a Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 20 Apr 2015 12:51:47 -0400 Subject: [PATCH 18/22] Score based on the robot short name --- endpoints/api/search.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/endpoints/api/search.py b/endpoints/api/search.py index 20a34b495..0871cd6eb 100644 --- a/endpoints/api/search.py +++ b/endpoints/api/search.py @@ -137,7 +137,7 @@ class FindRepositories(ApiResource): -def search_entity_view(username, entity): +def search_entity_view(username, entity, get_short_name=None): kind = 'user' avatar_data = avatar.get_data_for_user(entity) href = '/user/' + entity.username @@ -156,7 +156,7 @@ def search_entity_view(username, entity): kind = 'robot' avatar_data = None - return { + data = { 'kind': kind, 'avatar': avatar_data, 'name': entity.username, @@ -164,6 +164,11 @@ def search_entity_view(username, entity): 'href': href } + if get_short_name: + data['short_name'] = get_short_name(entity.username) + + return data + def conduct_team_search(username, query, encountered_teams, results): """ Finds the matching teams where the user is a member. """ @@ -242,9 +247,12 @@ def conduct_namespace_search(username, query, results): def conduct_robot_search(username, query, results): """ Finds matching robot accounts. """ + def get_short_name(name): + return parse_robot_username(name)[1] + matching_robots = model.get_matching_robots(query, username, limit=5) for robot in matching_robots: - results.append(search_entity_view(username, robot)) + results.append(search_entity_view(username, robot, get_short_name)) @resource('/v1/find/all') @@ -282,6 +290,7 @@ class ConductSearch(ApiResource): # 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) + name = result.get('short_name', result['name']) + result['score'] = result['score'] * liquidmetal.score(name, query) return {'results': sorted(results, key=itemgetter('score'), reverse=True)} From ae55b8dd0ee00ff78d016e53c00bdf663eb068e9 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 20 Apr 2015 13:00:38 -0400 Subject: [PATCH 19/22] Make the search action not return scores of zero if there is no character matching --- endpoints/api/search.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/endpoints/api/search.py b/endpoints/api/search.py index 0871cd6eb..591499928 100644 --- a/endpoints/api/search.py +++ b/endpoints/api/search.py @@ -291,6 +291,7 @@ class ConductSearch(ApiResource): # Modify the results' scores via how close the query term is to each result's name. for result in results: name = result.get('short_name', result['name']) - result['score'] = result['score'] * liquidmetal.score(name, query) + lm_score = liquidmetal.score(name, query) or 1 + result['score'] = result['score'] * lm_score return {'results': sorted(results, key=itemgetter('score'), reverse=True)} From 62770674d43ef0b9ccaa249db21679b27d48ddbf Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 20 Apr 2015 13:00:56 -0400 Subject: [PATCH 20/22] Switch to a 0.5 modifier --- endpoints/api/search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/endpoints/api/search.py b/endpoints/api/search.py index 591499928..375be7b77 100644 --- a/endpoints/api/search.py +++ b/endpoints/api/search.py @@ -291,7 +291,7 @@ class ConductSearch(ApiResource): # Modify the results' scores via how close the query term is to each result's name. for result in results: name = result.get('short_name', result['name']) - lm_score = liquidmetal.score(name, query) or 1 + lm_score = liquidmetal.score(name, query) or 0.5 result['score'] = result['score'] * lm_score return {'results': sorted(results, key=itemgetter('score'), reverse=True)} From ecf1135dcd3e9d4a2880d3ea303b606db94cd601 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 20 Apr 2015 13:01:51 -0400 Subject: [PATCH 21/22] Clarify the robot permissions --- static/directives/robots-manager.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/static/directives/robots-manager.html b/static/directives/robots-manager.html index 03627b49c..7647d78a3 100644 --- a/static/directives/robots-manager.html +++ b/static/directives/robots-manager.html @@ -42,7 +42,7 @@ Robot Account Name Teams - Repository Permissions + Direct Repository Permissions @@ -76,11 +76,11 @@ - (No permissions on any repositories) + (No direct permissions on any repositories) - Permissions on + Direct permissions on {{ robotInfo.repositories.length }} repository repositories From 72abd80f08df36d71b2a92474f06b5c0d4704e0f Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 20 Apr 2015 13:11:21 -0400 Subject: [PATCH 22/22] Make robot accounts filter rather than display the dialog when "showRobot" is passed to the manager --- static/css/directives/ui/filter-box.css | 17 +++++++++++++++++ static/directives/filter-box.html | 6 ++++++ static/directives/robots-manager.html | 5 ++--- static/js/directives/ui/filter-box.js | 21 +++++++++++++++++++++ static/js/directives/ui/robots-manager.js | 2 +- 5 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 static/css/directives/ui/filter-box.css create mode 100644 static/directives/filter-box.html create mode 100644 static/js/directives/ui/filter-box.js diff --git a/static/css/directives/ui/filter-box.css b/static/css/directives/ui/filter-box.css new file mode 100644 index 000000000..89ae1eadf --- /dev/null +++ b/static/css/directives/ui/filter-box.css @@ -0,0 +1,17 @@ +.filter-box { + display: block; + text-align: right; + margin-top: 20px; + margin-bottom: 26px; +} + +.filter-box .form-control { + max-width: 300px; + display: inline-block; +} + +.filter-box .filter-message { + display: inline-block; + margin-right: 10px; + color: #ccc; +} \ No newline at end of file diff --git a/static/directives/filter-box.html b/static/directives/filter-box.html new file mode 100644 index 000000000..ff2f4119d --- /dev/null +++ b/static/directives/filter-box.html @@ -0,0 +1,6 @@ +
+ + Showing {{ (collection|filter:filterModel).length }} of {{ collection.length }} {{ filterName }} + + +
\ No newline at end of file diff --git a/static/directives/robots-manager.html b/static/directives/robots-manager.html index 7647d78a3..4337e4f5a 100644 --- a/static/directives/robots-manager.html +++ b/static/directives/robots-manager.html @@ -19,9 +19,8 @@ be shared, such as deployment systems.
- - - +
No robot accounts defined.
diff --git a/static/js/directives/ui/filter-box.js b/static/js/directives/ui/filter-box.js new file mode 100644 index 000000000..38b8fbdd5 --- /dev/null +++ b/static/js/directives/ui/filter-box.js @@ -0,0 +1,21 @@ +/** + * An element which displays a right-aligned control bar with an for filtering a collection. + */ +angular.module('quay').directive('filterBox', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/filter-box.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'collection': '=collection', + 'filterModel': '=filterModel', + 'filterName': '@filterName' + }, + controller: function($scope, $element) { + + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/robots-manager.js b/static/js/directives/ui/robots-manager.js index 970681ea7..122931005 100644 --- a/static/js/directives/ui/robots-manager.js +++ b/static/js/directives/ui/robots-manager.js @@ -118,7 +118,7 @@ angular.module('quay').directive('robotsManager', function () { if ($routeParams.showRobot) { var index = $scope.findRobotIndexByName($routeParams.showRobot); if (index >= 0) { - $scope.showRobot($scope.robots[index]); + $scope.robotFilter = $routeParams.showRobot; } } });