Merge master into ibranch
This commit is contained in:
commit
f8192a1140
34 changed files with 940 additions and 358 deletions
2
conf/init/service/repositoryactioncounter/log/run
Executable file
2
conf/init/service/repositoryactioncounter/log/run
Executable file
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/sh
|
||||||
|
exec logger -i -t repositoryactioncounter
|
8
conf/init/service/repositoryactioncounter/run
Executable file
8
conf/init/service/repositoryactioncounter/run
Executable file
|
@ -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'
|
|
@ -299,7 +299,7 @@ class Repository(BaseModel):
|
||||||
# Therefore, we define our own deletion order here and use the dependency system to verify it.
|
# Therefore, we define our own deletion order here and use the dependency system to verify it.
|
||||||
ordered_dependencies = [RepositoryAuthorizedEmail, RepositoryTag, Image, LogEntry,
|
ordered_dependencies = [RepositoryAuthorizedEmail, RepositoryTag, Image, LogEntry,
|
||||||
RepositoryBuild, RepositoryBuildTrigger, RepositoryNotification,
|
RepositoryBuild, RepositoryBuildTrigger, RepositoryNotification,
|
||||||
RepositoryPermission, AccessToken, Star]
|
RepositoryPermission, AccessToken, Star, RepositoryActionCount]
|
||||||
|
|
||||||
for query, fk in self.dependencies(search_nullable=True):
|
for query, fk in self.dependencies(search_nullable=True):
|
||||||
model = fk.model_class
|
model = fk.model_class
|
||||||
|
@ -497,6 +497,7 @@ class RepositoryTag(BaseModel):
|
||||||
lifetime_start_ts = IntegerField(default=get_epoch_timestamp)
|
lifetime_start_ts = IntegerField(default=get_epoch_timestamp)
|
||||||
lifetime_end_ts = IntegerField(null=True, index=True)
|
lifetime_end_ts = IntegerField(null=True, index=True)
|
||||||
hidden = BooleanField(default=False)
|
hidden = BooleanField(default=False)
|
||||||
|
reversion = BooleanField(default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
database = db
|
database = db
|
||||||
|
@ -560,6 +561,20 @@ class LogEntry(BaseModel):
|
||||||
metadata_json = TextField(default='{}')
|
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):
|
class OAuthApplication(BaseModel):
|
||||||
client_id = CharField(index=True, default=random_string_generator(length=20))
|
client_id = CharField(index=True, default=random_string_generator(length=20))
|
||||||
client_secret = CharField(default=random_string_generator(length=40))
|
client_secret = CharField(default=random_string_generator(length=40))
|
||||||
|
@ -645,4 +660,4 @@ all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission,
|
||||||
ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification,
|
ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification,
|
||||||
RepositoryAuthorizedEmail, ImageStorageTransformation, DerivedImageStorage,
|
RepositoryAuthorizedEmail, ImageStorageTransformation, DerivedImageStorage,
|
||||||
TeamMemberInvite, ImageStorageSignature, ImageStorageSignatureKind,
|
TeamMemberInvite, ImageStorageSignature, ImageStorageSignatureKind,
|
||||||
AccessTokenKind, Star]
|
AccessTokenKind, Star, RepositoryActionCount]
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Add revert_tag log entry kind
|
||||||
|
|
||||||
|
Revision ID: 1c3decf6b9c4
|
||||||
|
Revises: 4ce2169efd3b
|
||||||
|
Create Date: 2015-04-16 17:14:11.154856
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '1c3decf6b9c4'
|
||||||
|
down_revision = '4ce2169efd3b'
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade(tables):
|
||||||
|
op.bulk_insert(tables.logentrykind,
|
||||||
|
[
|
||||||
|
{'id': 47, 'name':'revert_tag'},
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade(tables):
|
||||||
|
op.execute(
|
||||||
|
(tables.logentrykind.delete()
|
||||||
|
.where(tables.logentrykind.c.name == op.inline_literal('revert_tag')))
|
||||||
|
|
||||||
|
)
|
|
@ -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 ###
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Add reversion column to the tags table
|
||||||
|
|
||||||
|
Revision ID: 4ce2169efd3b
|
||||||
|
Revises: 30c044b75632
|
||||||
|
Create Date: 2015-04-16 17:10:16.039835
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '4ce2169efd3b'
|
||||||
|
down_revision = '30c044b75632'
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade(tables):
|
||||||
|
### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('repositorytag', sa.Column('reversion', sa.Boolean(), nullable=False))
|
||||||
|
### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade(tables):
|
||||||
|
### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('repositorytag', 'reversion')
|
||||||
|
### end Alembic commands ###
|
|
@ -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 ###
|
|
@ -18,7 +18,7 @@ from data.database import (User, Repository, Image, AccessToken, Role, Repositor
|
||||||
DerivedImageStorage, ImageStorageTransformation, random_string_generator,
|
DerivedImageStorage, ImageStorageTransformation, random_string_generator,
|
||||||
db, BUILD_PHASE, QuayUserField, ImageStorageSignature, QueueItem,
|
db, BUILD_PHASE, QuayUserField, ImageStorageSignature, QueueItem,
|
||||||
ImageStorageSignatureKind, validate_database_url, db_for_update,
|
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 peewee import JOIN_LEFT_OUTER, fn
|
||||||
from util.validation import (validate_username, validate_email, validate_password,
|
from util.validation import (validate_username, validate_email, validate_password,
|
||||||
INVALID_PASSWORD_MESSAGE)
|
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
|
""" Returns repositories matching the given prefix string and passing the given checker
|
||||||
function.
|
function.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
last_week = datetime.now() - timedelta(weeks=1)
|
last_week = datetime.now() - timedelta(weeks=1)
|
||||||
results = []
|
results = []
|
||||||
existing_ids = []
|
existing_ids = []
|
||||||
|
|
||||||
def get_search_results(search_clause, with_count):
|
def get_search_results(search_clause, with_count=False):
|
||||||
if len(results) >= limit:
|
if len(results) >= limit:
|
||||||
return
|
return
|
||||||
|
|
||||||
selected = [Repository, Namespace]
|
select_items = [Repository, Namespace]
|
||||||
if with_count:
|
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))
|
.join(Namespace, JOIN_LEFT_OUTER, on=(Namespace.id == Repository.namespace_user))
|
||||||
.switch(Repository)
|
.switch(Repository)
|
||||||
.where(search_clause)
|
.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))
|
query = query.where(~(Repository.id << existing_ids))
|
||||||
|
|
||||||
if with_count:
|
if with_count:
|
||||||
query = (query.join(LogEntry, JOIN_LEFT_OUTER)
|
query = (query.switch(Repository)
|
||||||
.where(LogEntry.datetime >= last_week)
|
.join(RepositoryActionCount)
|
||||||
.order_by(fn.Count(LogEntry.id).desc()))
|
.where(RepositoryActionCount.date >= last_week)
|
||||||
|
.order_by(fn.Sum(RepositoryActionCount.count).desc()))
|
||||||
|
|
||||||
for result in query:
|
for result in query:
|
||||||
if len(results) >= limit:
|
if len(results) >= limit:
|
||||||
|
@ -1042,13 +1042,13 @@ def get_sorted_matching_repositories(prefix, only_public, checker, limit=10):
|
||||||
existing_ids.append(result.id)
|
existing_ids.append(result.id)
|
||||||
|
|
||||||
# For performance reasons, we conduct the repo name and repo namespace searches on their
|
# 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
|
# own. This also affords us the ability to give higher precedence to repository names matching
|
||||||
# higher precedence to repository names matching over namespaces, which is semantically correct.
|
# over namespaces, which is semantically correct.
|
||||||
get_search_results((Repository.name ** (prefix + '%')), with_count=True)
|
get_search_results(Repository.name ** (prefix + '%'), with_count=True)
|
||||||
get_search_results((Repository.name ** (prefix + '%')), with_count=False)
|
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=True)
|
||||||
get_search_results((Namespace.username ** (prefix + '%')), with_count=False)
|
get_search_results(Namespace.username ** (prefix + '%'), with_count=False)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
@ -1762,13 +1762,18 @@ def _tag_alive(query, now_ts=None):
|
||||||
(RepositoryTag.lifetime_end_ts > now_ts))
|
(RepositoryTag.lifetime_end_ts > now_ts))
|
||||||
|
|
||||||
|
|
||||||
def list_repository_tag_history(repository, limit=100):
|
def list_repository_tag_history(repository, limit=100, specific_tag=None):
|
||||||
query = (RepositoryTag
|
query = (RepositoryTag
|
||||||
.select(RepositoryTag, Image)
|
.select(RepositoryTag, Image)
|
||||||
.join(Image)
|
.join(Image)
|
||||||
.where(RepositoryTag.repository == repository)
|
.where(RepositoryTag.repository == repository)
|
||||||
|
.where(RepositoryTag.hidden == False)
|
||||||
.order_by(RepositoryTag.lifetime_start_ts.desc())
|
.order_by(RepositoryTag.lifetime_start_ts.desc())
|
||||||
.limit(limit))
|
.limit(limit))
|
||||||
|
|
||||||
|
if specific_tag:
|
||||||
|
query = query.where(RepositoryTag.name == specific_tag)
|
||||||
|
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
@ -1990,7 +1995,7 @@ def get_parent_images(namespace_name, repository_name, image_obj):
|
||||||
|
|
||||||
|
|
||||||
def create_or_update_tag(namespace_name, repository_name, tag_name,
|
def create_or_update_tag(namespace_name, repository_name, tag_name,
|
||||||
tag_docker_image_id):
|
tag_docker_image_id, reversion=False):
|
||||||
try:
|
try:
|
||||||
repo = _get_repository(namespace_name, repository_name)
|
repo = _get_repository(namespace_name, repository_name)
|
||||||
except Repository.DoesNotExist:
|
except Repository.DoesNotExist:
|
||||||
|
@ -2015,7 +2020,7 @@ def create_or_update_tag(namespace_name, repository_name, tag_name,
|
||||||
raise DataModelException('Invalid image with id: %s' % tag_docker_image_id)
|
raise DataModelException('Invalid image with id: %s' % tag_docker_image_id)
|
||||||
|
|
||||||
return RepositoryTag.create(repository=repo, image=image, name=tag_name,
|
return RepositoryTag.create(repository=repo, image=image, name=tag_name,
|
||||||
lifetime_start_ts=now_ts)
|
lifetime_start_ts=now_ts, reversion=reversion)
|
||||||
|
|
||||||
def delete_tag(namespace_name, repository_name, tag_name):
|
def delete_tag(namespace_name, repository_name, tag_name):
|
||||||
now_ts = get_epoch_timestamp()
|
now_ts = get_epoch_timestamp()
|
||||||
|
@ -2823,3 +2828,22 @@ def repository_is_starred(user, repository):
|
||||||
return True
|
return True
|
||||||
except Star.DoesNotExist:
|
except Star.DoesNotExist:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def revert_tag(repository, tag_name, docker_image_id):
|
||||||
|
""" Reverts a tag to a specific image ID. """
|
||||||
|
# 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(repository.namespace_user.username, repository.name,
|
||||||
|
tag_name, docker_image_id, reversion=True)
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from sqlalchemy import (Table, MetaData, Column, ForeignKey, Integer, String, Boolean, Text,
|
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,
|
from peewee import (PrimaryKeyField, CharField, BooleanField, DateTimeField, TextField,
|
||||||
ForeignKeyField, BigIntegerField, IntegerField)
|
ForeignKeyField, BigIntegerField, IntegerField, DateField)
|
||||||
|
|
||||||
|
|
||||||
OPTIONS_TO_COPY = [
|
OPTIONS_TO_COPY = [
|
||||||
|
@ -42,6 +42,8 @@ def gen_sqlalchemy_metadata(peewee_model_list):
|
||||||
alchemy_type = Boolean
|
alchemy_type = Boolean
|
||||||
elif isinstance(field, DateTimeField):
|
elif isinstance(field, DateTimeField):
|
||||||
alchemy_type = DateTime
|
alchemy_type = DateTime
|
||||||
|
elif isinstance(field, DateField):
|
||||||
|
alchemy_type = Date
|
||||||
elif isinstance(field, TextField):
|
elif isinstance(field, TextField):
|
||||||
alchemy_type = Text
|
alchemy_type = Text
|
||||||
elif isinstance(field, ForeignKeyField):
|
elif isinstance(field, ForeignKeyField):
|
||||||
|
|
|
@ -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'
|
kind = 'user'
|
||||||
avatar_data = avatar.get_data_for_user(entity)
|
avatar_data = avatar.get_data_for_user(entity)
|
||||||
href = '/user/' + entity.username
|
href = '/user/' + entity.username
|
||||||
|
@ -156,7 +156,7 @@ def search_entity_view(username, entity):
|
||||||
kind = 'robot'
|
kind = 'robot'
|
||||||
avatar_data = None
|
avatar_data = None
|
||||||
|
|
||||||
return {
|
data = {
|
||||||
'kind': kind,
|
'kind': kind,
|
||||||
'avatar': avatar_data,
|
'avatar': avatar_data,
|
||||||
'name': entity.username,
|
'name': entity.username,
|
||||||
|
@ -164,6 +164,11 @@ def search_entity_view(username, entity):
|
||||||
'href': href
|
'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):
|
def conduct_team_search(username, query, encountered_teams, results):
|
||||||
""" Finds the matching teams where the user is a member. """
|
""" 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):
|
def conduct_robot_search(username, query, results):
|
||||||
""" Finds matching robot accounts. """
|
""" 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)
|
matching_robots = model.get_matching_robots(query, username, limit=5)
|
||||||
for robot in matching_robots:
|
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')
|
@resource('/v1/find/all')
|
||||||
|
@ -282,6 +290,8 @@ class ConductSearch(ApiResource):
|
||||||
|
|
||||||
# Modify the results' scores via how close the query term is to each result's name.
|
# Modify the results' scores via how close the query term is to each result's name.
|
||||||
for result in results:
|
for result in results:
|
||||||
result['score'] = result['score'] * liquidmetal.score(result['name'], query)
|
name = result.get('short_name', result['name'])
|
||||||
|
lm_score = liquidmetal.score(name, query) or 0.5
|
||||||
|
result['score'] = result['score'] * lm_score
|
||||||
|
|
||||||
return {'results': sorted(results, key=itemgetter('score'), reverse=True)}
|
return {'results': sorted(results, key=itemgetter('score'), reverse=True)}
|
||||||
|
|
|
@ -2,7 +2,7 @@ from flask import request, abort
|
||||||
|
|
||||||
from endpoints.api import (resource, nickname, require_repo_read, require_repo_write,
|
from endpoints.api import (resource, nickname, require_repo_read, require_repo_write,
|
||||||
RepositoryParamResource, log_action, NotFound, validate_json_request,
|
RepositoryParamResource, log_action, NotFound, validate_json_request,
|
||||||
path_param, format_date)
|
path_param, format_date, parse_args, query_param)
|
||||||
from endpoints.api.image import image_view
|
from endpoints.api.image import image_view
|
||||||
from data import model
|
from data import model
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
|
@ -17,8 +17,11 @@ class ListRepositoryTags(RepositoryParamResource):
|
||||||
""" Resource for listing repository tags. """
|
""" Resource for listing repository tags. """
|
||||||
|
|
||||||
@require_repo_write
|
@require_repo_write
|
||||||
|
@parse_args
|
||||||
|
@query_param('specificTag', 'Filters the tags to the specific tag.', type=str, default='')
|
||||||
|
@query_param('limit', 'Limit to the number of results to return. Max 100.', type=int, default=50)
|
||||||
@nickname('listRepoTags')
|
@nickname('listRepoTags')
|
||||||
def get(self, namespace, repository):
|
def get(self, args, namespace, repository):
|
||||||
repo = model.get_repository(namespace, repository)
|
repo = model.get_repository(namespace, repository)
|
||||||
if not repo:
|
if not repo:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
@ -27,6 +30,7 @@ class ListRepositoryTags(RepositoryParamResource):
|
||||||
tag_info = {
|
tag_info = {
|
||||||
'name': tag.name,
|
'name': tag.name,
|
||||||
'docker_image_id': tag.image.docker_image_id,
|
'docker_image_id': tag.image.docker_image_id,
|
||||||
|
'reversion': tag.reversion,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tag.lifetime_start_ts > 0:
|
if tag.lifetime_start_ts > 0:
|
||||||
|
@ -37,7 +41,9 @@ class ListRepositoryTags(RepositoryParamResource):
|
||||||
|
|
||||||
return tag_info
|
return tag_info
|
||||||
|
|
||||||
tags = model.list_repository_tag_history(repo, limit=100)
|
specific_tag = args.get('specificTag') or None
|
||||||
|
limit = min(100, max(1, args.get('limit', 50)))
|
||||||
|
tags = model.list_repository_tag_history(repo, limit=limit, specific_tag=specific_tag)
|
||||||
return {'tags': [tag_view(tag) for tag in tags]}
|
return {'tags': [tag_view(tag) for tag in tags]}
|
||||||
|
|
||||||
|
|
||||||
|
@ -134,3 +140,54 @@ class RepositoryTagImages(RepositoryParamResource):
|
||||||
return {
|
return {
|
||||||
'images': [image_view(image, image_map) for image in all_images]
|
'images': [image_view(image, image_map) for image in all_images]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/repository/<repopath:repository>/tag/<tag>/revert')
|
||||||
|
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
||||||
|
@path_param('tag', 'The name of the tag')
|
||||||
|
class RevertTag(RepositoryParamResource):
|
||||||
|
""" Resource for reverting a repository tag back to a previous image. """
|
||||||
|
schemas = {
|
||||||
|
'RevertTag': {
|
||||||
|
'id': 'RevertTag',
|
||||||
|
'type': 'object',
|
||||||
|
'description': 'Reverts a tag to a specific image',
|
||||||
|
'required': [
|
||||||
|
'image',
|
||||||
|
],
|
||||||
|
'properties': {
|
||||||
|
'image': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'Image identifier to which the tag should point',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@require_repo_write
|
||||||
|
@nickname('revertTag')
|
||||||
|
@validate_json_request('RevertTag')
|
||||||
|
def post(self, namespace, repository, tag):
|
||||||
|
""" Reverts a repository tag back to a previous image in the repository. """
|
||||||
|
try:
|
||||||
|
tag_image = model.get_tag_image(namespace, repository, tag)
|
||||||
|
except model.DataModelException:
|
||||||
|
raise NotFound()
|
||||||
|
|
||||||
|
# Revert the tag back to the previous image.
|
||||||
|
image_id = request.get_json()['image']
|
||||||
|
model.revert_tag(tag_image.repository, tag, image_id)
|
||||||
|
model.garbage_collect_repository(namespace, repository)
|
||||||
|
|
||||||
|
# Log the reversion.
|
||||||
|
username = get_authenticated_user().username
|
||||||
|
log_action('revert_tag', namespace,
|
||||||
|
{'username': username, 'repo': repository, 'tag': tag,
|
||||||
|
'image': image_id, 'original_image': tag_image.docker_image_id},
|
||||||
|
repo=model.get_repository(namespace, repository))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'image_id': image_id,
|
||||||
|
'original_image_id': tag_image.docker_image_id
|
||||||
|
}
|
||||||
|
|
|
@ -16,6 +16,8 @@ from data import model
|
||||||
from data.model import oauth
|
from data.model import oauth
|
||||||
from app import app, storage as store
|
from app import app, storage as store
|
||||||
|
|
||||||
|
from workers import repositoryactioncounter
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -220,6 +222,7 @@ def initialize_database():
|
||||||
LogEntryKind.create(name='create_tag')
|
LogEntryKind.create(name='create_tag')
|
||||||
LogEntryKind.create(name='move_tag')
|
LogEntryKind.create(name='move_tag')
|
||||||
LogEntryKind.create(name='delete_tag')
|
LogEntryKind.create(name='delete_tag')
|
||||||
|
LogEntryKind.create(name='revert_tag')
|
||||||
LogEntryKind.create(name='add_repo_permission')
|
LogEntryKind.create(name='add_repo_permission')
|
||||||
LogEntryKind.create(name='change_repo_permission')
|
LogEntryKind.create(name='change_repo_permission')
|
||||||
LogEntryKind.create(name='delete_repo_permission')
|
LogEntryKind.create(name='delete_repo_permission')
|
||||||
|
@ -588,6 +591,9 @@ def populate_database():
|
||||||
'trigger_id': trigger.uuid, 'config': json.loads(trigger.config),
|
'trigger_id': trigger.uuid, 'config': json.loads(trigger.config),
|
||||||
'service': trigger.service.name})
|
'service': trigger.service.name})
|
||||||
|
|
||||||
|
while repositoryactioncounter.count_repository_actions():
|
||||||
|
pass
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
log_level = getattr(logging, app.config['LOGGING_LEVEL'])
|
log_level = getattr(logging, app.config['LOGGING_LEVEL'])
|
||||||
logging.basicConfig(level=log_level)
|
logging.basicConfig(level=log_level)
|
||||||
|
|
|
@ -65,134 +65,22 @@
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-panel-tags-element .options-col .fa-download {
|
.repo-panel-tags-element .options-col .fa-download, .repo-panel-tags-element .options-col .fa-history {
|
||||||
color: #999;
|
color: #999;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-panel-tags-element .history-list {
|
.repo-panel-tags-element .tag-image-history-item .image-id {
|
||||||
margin: 10px;
|
font-family: Consolas, "Lucida Console", Monaco, monospace;
|
||||||
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;
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-tags-element .tag-image-history-item .image-apply-time {
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
|
font-size: 11px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-tags-element .tag-image-history-item .fa-circle-o {
|
||||||
|
margin-right: 2px;
|
||||||
}
|
}
|
17
static/css/directives/ui/filter-box.css
Normal file
17
static/css/directives/ui/filter-box.css
Normal file
|
@ -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;
|
||||||
|
}
|
141
static/css/directives/ui/repo-tag-history.css
Normal file
141
static/css/directives/ui/repo-tag-history.css
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
.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: "\f073";
|
||||||
|
font-family: FontAwesome;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
top: 1px;
|
||||||
|
left: -9px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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.revert .history-icon:before {
|
||||||
|
content: "\f0e2";
|
||||||
|
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.revert .history-icon {
|
||||||
|
background-color: #F0C577;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
4
static/css/directives/ui/tag-operations-dialog.css
Normal file
4
static/css/directives/ui/tag-operations-dialog.css
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.tag-operations-dialog .image-id {
|
||||||
|
font-family: Consolas, "Lucida Console", Monaco, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
6
static/directives/filter-box.html
Normal file
6
static/directives/filter-box.html
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<div class="filter-box-element" ng-show="collection.length">
|
||||||
|
<span class="filter-message" ng-if="filterModel">
|
||||||
|
Showing {{ (collection|filter:filterModel).length }} of {{ collection.length }} {{ filterName }}
|
||||||
|
</span>
|
||||||
|
<input class="form-control" type="text" ng-model="filterModel" placeholder="Filter {{ filterName }}...">
|
||||||
|
</div>
|
|
@ -185,7 +185,8 @@
|
||||||
<span class="avatar" data="result.namespace.avatar" size="16"></span>
|
<span class="avatar" data="result.namespace.avatar" size="16"></span>
|
||||||
<span class="result-name">{{ result.namespace.name }}/{{ result.name }}</span>
|
<span class="result-name">{{ result.namespace.name }}/{{ result.name }}</span>
|
||||||
<div class="description" ng-if="result.description">
|
<div class="description" ng-if="result.description">
|
||||||
{{ result.description }}
|
<div class="description markdown-view" content="result.description"
|
||||||
|
first-line-only="true" placeholder-needed="false"></div>
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
51
static/directives/repo-tag-history.html
Normal file
51
static/directives/repo-tag-history.html
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<div class="repo-tag-history-element">
|
||||||
|
<div class="cor-loader" ng-if="!historyEntries"></div>
|
||||||
|
|
||||||
|
<span class="co-filter-box" style="float:right">
|
||||||
|
<input class="form-control" type="text" ng-model="filter" placeholder="Filter History...">
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="history-list">
|
||||||
|
<div class="empty" ng-if="!historyEntries.length">
|
||||||
|
<div class="empty-primary-msg">No recent tag activity.</div>
|
||||||
|
<div class="empty-secondary-msg">There has not been any recent tag activity on this repository.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="history-entry" ng-repeat="entry in historyEntries"
|
||||||
|
ng-class="getEntryClasses(entry, filter)">
|
||||||
|
<div class="history-date-break" ng-if="entry.date_break">
|
||||||
|
{{ entry.date | amDateFormat:'dddd, MMMM Do YYYY' }}
|
||||||
|
</div>
|
||||||
|
<div ng-if="!entry.date_break">
|
||||||
|
<div class="history-icon-container"><div class="history-icon"></div></div>
|
||||||
|
<div class="history-text">
|
||||||
|
<div class="history-description">
|
||||||
|
<span class="tag-span"
|
||||||
|
ng-click="showHistory(true, entry.tag_name)">{{ entry.tag_name }}</span>
|
||||||
|
<span ng-switch on="entry.action">
|
||||||
|
<span ng-switch-when="create">
|
||||||
|
was created pointing to image <span class="image-link" repository="repository" image-id="entry.docker_image_id"></span>
|
||||||
|
</span>
|
||||||
|
<span ng-switch-when="delete">
|
||||||
|
was deleted
|
||||||
|
</span>
|
||||||
|
<span ng-switch-when="move">
|
||||||
|
was moved to image
|
||||||
|
<span class="image-link" repository="repository" image-id="entry.docker_image_id"></span>
|
||||||
|
from image
|
||||||
|
<span class="image-link" repository="repository" image-id="entry.old_docker_image_id"></span>
|
||||||
|
</span>
|
||||||
|
<span ng-switch-when="revert">
|
||||||
|
was reverted to image
|
||||||
|
<span class="image-link" repository="repository" image-id="entry.docker_image_id"></span>
|
||||||
|
from image
|
||||||
|
<span class="image-link" repository="repository" image-id="entry.old_docker_image_id"></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="history-datetime">{{ entry.time | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,5 +1,5 @@
|
||||||
<div class="repo-panel-tags-element">
|
<div class="repo-panel-tags-element">
|
||||||
<div class="tab-header-controls">
|
<div class="tab-header-controls" ng-show="images">
|
||||||
<div class="btn-group btn-group-sm" ng-show="repository.can_write">
|
<div class="btn-group btn-group-sm" ng-show="repository.can_write">
|
||||||
<button class="btn" ng-class="!showingHistory ? 'btn-primary active' : 'btn-default'" ng-click="showHistory(false)">
|
<button class="btn" ng-class="!showingHistory ? 'btn-primary active' : 'btn-default'" ng-click="showHistory(false)">
|
||||||
<i class="fa fa-tags"></i>Current Tags
|
<i class="fa fa-tags"></i>Current Tags
|
||||||
|
@ -13,56 +13,8 @@
|
||||||
<h3 class="tab-header">Repository Tags</h3>
|
<h3 class="tab-header">Repository Tags</h3>
|
||||||
|
|
||||||
<!-- History view -->
|
<!-- History view -->
|
||||||
<div ng-show="showingHistory">
|
<div class="repo-tag-history" repository="repository" filter="options.historyFilter"
|
||||||
<div ng-show="showingHistory">
|
is-enabled="showingHistory" ng-show="showingHistory"></div>
|
||||||
<div class="cor-loader" ng-if="!tagHistoryData"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 40px;">
|
|
||||||
<span class="co-filter-box" style="float:right">
|
|
||||||
<input class="form-control" type="text" ng-model="options.historyFilter" placeholder="Filter History...">
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="history-list">
|
|
||||||
<div class="empty" ng-if="!tagHistoryData.length">
|
|
||||||
<div class="empty-primary-msg">This repository is empty.</div>
|
|
||||||
<div class="empty-secondary-msg">Push a tag or initiate a build to populate this repository.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="history-entry" ng-repeat="entry in tagHistoryData"
|
|
||||||
ng-class="getEntryClasses(entry, options.historyFilter)">
|
|
||||||
<div class="history-date-break" ng-if="entry.date_break">
|
|
||||||
{{ entry.date | amDateFormat:'dddd, MMMM Do YYYY' }}
|
|
||||||
</div>
|
|
||||||
<div ng-if="!entry.date_break">
|
|
||||||
<div class="history-icon"></div>
|
|
||||||
<div class="history-text">
|
|
||||||
<div class="history-description">
|
|
||||||
<span class="tag-span"
|
|
||||||
ng-class="isChecked(entry.tag_name, checkedTags.checked) ? 'checked' : ''"
|
|
||||||
ng-click="showHistory(true, entry.tag_name)">{{ entry.tag_name }}</span>
|
|
||||||
<span ng-switch on="entry.action">
|
|
||||||
<span ng-switch-when="create">
|
|
||||||
was created pointing to image <span class="image-link" repository="repository" image-id="entry.docker_image_id"></span>
|
|
||||||
</span>
|
|
||||||
<span ng-switch-when="delete">
|
|
||||||
was deleted
|
|
||||||
</span>
|
|
||||||
<span ng-switch-when="move">
|
|
||||||
was moved to image
|
|
||||||
<span class="image-link" repository="repository" image-id="entry.docker_image_id"></span>
|
|
||||||
from image
|
|
||||||
<span class="image-link" repository="repository" image-id="entry.old_docker_image_id"></span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="history-datetime">{{ entry.time | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Normal View -->
|
<!-- Normal View -->
|
||||||
<div class="resource-view" resource="imagesResource" error-message="'Could not load images'"
|
<div class="resource-view" resource="imagesResource" error-message="'Could not load images'"
|
||||||
|
@ -125,6 +77,7 @@
|
||||||
style="min-width: 120px;">
|
style="min-width: 120px;">
|
||||||
<a href="javascript:void(0)" ng-click="orderBy('image_id')">Image</a>
|
<a href="javascript:void(0)" ng-click="orderBy('image_id')">Image</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="options-col" ng-if="repository.can_write"></td>
|
||||||
<td class="options-col"></td>
|
<td class="options-col"></td>
|
||||||
<td class="options-col"></td>
|
<td class="options-col"></td>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -140,7 +93,7 @@
|
||||||
</td>
|
</td>
|
||||||
<td>{{ tag.size | bytes }}</td>
|
<td>{{ tag.size | bytes }}</td>
|
||||||
<td class="image-id-col">
|
<td class="image-id-col">
|
||||||
<span class="image-link" repoository="repository" image-id="tag.image_id"></span>
|
<span class="image-link" repository="repository" image-id="tag.image_id"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="image-track" ng-repeat="it in imageTracks">
|
<td class="image-track" ng-repeat="it in imageTracks">
|
||||||
<span class="image-track-dot" ng-if="it.image_id == tag.image_id"
|
<span class="image-track-dot" ng-if="it.image_id == tag.image_id"
|
||||||
|
@ -153,12 +106,30 @@
|
||||||
ng-click="fetchTagActionHandler.askFetchTag(tag)">
|
ng-click="fetchTagActionHandler.askFetchTag(tag)">
|
||||||
</i>
|
</i>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="options-col" ng-if="repository.can_write">
|
||||||
|
<div class="dropdown" style="text-align: left;">
|
||||||
|
<i class="fa fa-history dropdown-toggle" data-toggle="dropdown" data-title="Tag History"
|
||||||
|
ng-click="loadTagHistory(tag)"
|
||||||
|
bs-tooltip></i>
|
||||||
|
<ul class="dropdown-menu pull-right">
|
||||||
|
<li ng-if="!tagHistory[tag.name]"><div class="cor-loader"></div></li>
|
||||||
|
<li class="tag-image-history-item" ng-repeat="entry in tagHistory[tag.name]">
|
||||||
|
<a href="javascript:void(0)" ng-click="askRevertTag(tag, entry.docker_image_id)">
|
||||||
|
<div class="image-id">
|
||||||
|
<i class="fa fa-circle-o"
|
||||||
|
ng-style="{'color': imageMap[entry.docker_image_id].color || '#eee'}"></i>
|
||||||
|
{{ entry.docker_image_id.substr(0, 12) }}
|
||||||
|
</div>
|
||||||
|
<div class="image-apply-time">
|
||||||
|
{{ entry.start_ts * 1000 | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td class="options-col">
|
<td class="options-col">
|
||||||
<span class="cor-options-menu" ng-if="repository.can_write">
|
<span class="cor-options-menu" ng-if="repository.can_write">
|
||||||
<span class="cor-option" option-click="showHistory(true, tag.name)"
|
|
||||||
ng-if="tag.last_modified">
|
|
||||||
<i class="fa fa-history"></i> View Tag History
|
|
||||||
</span>
|
|
||||||
<span class="cor-option" option-click="askDeleteTag(tag.name)">
|
<span class="cor-option" option-click="askDeleteTag(tag.name)">
|
||||||
<i class="fa fa-times"></i> Delete Tag
|
<i class="fa fa-times"></i> Delete Tag
|
||||||
</span>
|
</span>
|
||||||
|
@ -185,5 +156,4 @@
|
||||||
<div class="tag-operations-dialog" repository="repository" images="images"
|
<div class="tag-operations-dialog" repository="repository" images="images"
|
||||||
action-handler="tagActionHandler"></div>
|
action-handler="tagActionHandler"></div>
|
||||||
|
|
||||||
<div class="fetch-tag-dialog" repository="repository" action-handler="fetchTagActionHandler">
|
<div class="fetch-tag-dialog" repository="repository" action-handler="fetchTagActionHandler"></div>
|
||||||
</div>
|
|
|
@ -16,9 +16,7 @@
|
||||||
be shared, such as deployment systems.
|
be shared, such as deployment systems.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="manager-filter-box" ng-show="robots.length">
|
<div class="filter-box" collection="robots" filter-model="robotFilter" filter-name="Robot Accounts"></div>
|
||||||
<input class="form-control" type="text" ng-model="robotFilter" placeholder="Filter Robot Accounts...">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="empty" ng-if="!robots.length">
|
<div class="empty" ng-if="!robots.length">
|
||||||
<div class="empty-primary-msg">No robot accounts defined.</div>
|
<div class="empty-primary-msg">No robot accounts defined.</div>
|
||||||
|
@ -39,7 +37,7 @@
|
||||||
<td class="caret-col hidden-xs" ng-if="(user || organization.is_admin) && Config.isNewLayout()"></td>
|
<td class="caret-col hidden-xs" ng-if="(user || organization.is_admin) && Config.isNewLayout()"></td>
|
||||||
<td>Robot Account Name</td>
|
<td>Robot Account Name</td>
|
||||||
<td ng-if="organization && Config.isNewLayout()">Teams</td>
|
<td ng-if="organization && Config.isNewLayout()">Teams</td>
|
||||||
<td ng-if="Config.isNewLayout()">Repository Permissions</td>
|
<td ng-if="Config.isNewLayout()">Direct Repository Permissions</td>
|
||||||
<td class="options-col"></td>
|
<td class="options-col"></td>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
|
@ -73,11 +71,11 @@
|
||||||
</td>
|
</td>
|
||||||
<td ng-if="Config.isNewLayout()">
|
<td ng-if="Config.isNewLayout()">
|
||||||
<span class="empty" ng-if="robotInfo.repositories.length == 0">
|
<span class="empty" ng-if="robotInfo.repositories.length == 0">
|
||||||
(No permissions on any repositories)
|
(No direct permissions on any repositories)
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="member-perm-summary" ng-if="robotInfo.repositories.length > 0">
|
<span class="member-perm-summary" ng-if="robotInfo.repositories.length > 0">
|
||||||
Permissions on
|
Direct Permissions on
|
||||||
<span class="anchor hidden-xs" href="javascript:void(0)" is-text-only="!organization.is_admin"
|
<span class="anchor hidden-xs" href="javascript:void(0)" is-text-only="!organization.is_admin"
|
||||||
ng-click="showPermissions(robotInfo)">{{ robotInfo.repositories.length }}
|
ng-click="showPermissions(robotInfo)">{{ robotInfo.repositories.length }}
|
||||||
<span ng-if="robotInfo.repositories.length == 1">repository</span>
|
<span ng-if="robotInfo.repositories.length == 1">repository</span>
|
||||||
|
|
|
@ -85,3 +85,20 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recert Tag Confirm -->
|
||||||
|
<div class="cor-confirm-dialog"
|
||||||
|
dialog-context="revertTagInfo"
|
||||||
|
dialog-action="revertTag(info.tag, info.image_id, callback)"
|
||||||
|
dialog-title="Revert Tag"
|
||||||
|
dialog-action-title="Revert Tag">
|
||||||
|
|
||||||
|
<div class="co-alert co-alert-warning">
|
||||||
|
This will change the image to which the tag points.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Are you sure you want to revert tag
|
||||||
|
<span class="label label-default tag">{{ revertTagInfo.tag.name }}</span> to image
|
||||||
|
<span class="image-id">{{ revertTagInfo.image_id.substr(0, 12) }}?</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -24,6 +24,7 @@ angular.module('quay').directive('repoPanelTags', function () {
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.iterationState = {};
|
$scope.iterationState = {};
|
||||||
|
$scope.tagHistory = {};
|
||||||
$scope.tagActionHandler = null;
|
$scope.tagActionHandler = null;
|
||||||
$scope.showingHistory = false;
|
$scope.showingHistory = false;
|
||||||
|
|
||||||
|
@ -84,6 +85,9 @@ angular.module('quay').directive('repoPanelTags', function () {
|
||||||
'count': imageMap[image_id].length,
|
'count': imageMap[image_id].length,
|
||||||
'tags': imageMap[image_id]
|
'tags': imageMap[image_id]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
imageMap[image_id]['color'] = colors(index);
|
||||||
|
|
||||||
++index;
|
++index;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -119,136 +123,11 @@ angular.module('quay').directive('repoPanelTags', function () {
|
||||||
|
|
||||||
// Process each of the tags.
|
// Process each of the tags.
|
||||||
setTagState();
|
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) {
|
$scope.showHistory = function(value, opt_tagname) {
|
||||||
if (opt_tagname) {
|
$scope.options.historyFilter = opt_tagname ? opt_tagname : '';
|
||||||
$scope.options.historyFilter = opt_tagname;
|
|
||||||
} else {
|
|
||||||
$scope.options.historyFilter = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($scope.showingHistory == value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.showingHistory = value;
|
$scope.showingHistory = value;
|
||||||
|
|
||||||
if ($scope.showingHistory) {
|
|
||||||
loadTimeline();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.toggleHistory = function() {
|
$scope.toggleHistory = function() {
|
||||||
|
@ -350,12 +229,22 @@ angular.module('quay').directive('repoPanelTags', function () {
|
||||||
return names.join(',');
|
return names.join(',');
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.isChecked = function(tagName, checked) {
|
$scope.loadTagHistory = function(tag) {
|
||||||
return checked.some(function(tag) {
|
delete $scope.tagHistory[tag.name];
|
||||||
if (tag.name == tagName) {
|
|
||||||
return true;
|
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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -159,7 +159,7 @@ angular.module('quay').directive('buildLogsView', function () {
|
||||||
|
|
||||||
// Note: order is important here.
|
// Note: order is important here.
|
||||||
var setup = filter.getSetupHtml();
|
var setup = filter.getSetupHtml();
|
||||||
var stream = filter.addInputToStream(message);
|
var stream = filter.addInputToStream(message || '');
|
||||||
var teardown = filter.getTeardownHtml();
|
var teardown = filter.getTeardownHtml();
|
||||||
return setup + stream + teardown;
|
return setup + stream + teardown;
|
||||||
};
|
};
|
||||||
|
|
21
static/js/directives/ui/filter-box.js
Normal file
21
static/js/directives/ui/filter-box.js
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
/**
|
||||||
|
* An element which displays a right-aligned control bar with an <input> 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;
|
||||||
|
});
|
|
@ -98,6 +98,7 @@ angular.module('quay').directive('logsView', function () {
|
||||||
return 'Remove permission for token {token} from repository {repo}';
|
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}',
|
'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}',
|
'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}',
|
'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',
|
'delete_tag': 'Delete Tag',
|
||||||
'create_tag': 'Create Tag',
|
'create_tag': 'Create Tag',
|
||||||
'move_tag': 'Move Tag',
|
'move_tag': 'Move Tag',
|
||||||
|
'revert_tag':' Revert Tag',
|
||||||
'org_create_team': 'Create team',
|
'org_create_team': 'Create team',
|
||||||
'org_delete_team': 'Delete team',
|
'org_delete_team': 'Delete team',
|
||||||
'org_add_team_member': 'Add team member',
|
'org_add_team_member': 'Add team member',
|
||||||
|
|
157
static/js/directives/ui/repo-tag-history.js
Normal file
157
static/js/directives/ui/repo-tag-history.js
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
/**
|
||||||
|
* An element which displays its contents wrapped in an <a> tag, but only if the href is not null.
|
||||||
|
*/
|
||||||
|
angular.module('quay').directive('repoTagHistory', function () {
|
||||||
|
var directiveDefinitionObject = {
|
||||||
|
priority: 0,
|
||||||
|
templateUrl: '/static/directives/repo-tag-history.html',
|
||||||
|
replace: false,
|
||||||
|
transclude: true,
|
||||||
|
restrict: 'C',
|
||||||
|
scope: {
|
||||||
|
'repository': '=repository',
|
||||||
|
'filter': '=filter',
|
||||||
|
'isEnabled': '=isEnabled'
|
||||||
|
},
|
||||||
|
controller: function($scope, $element, ApiService) {
|
||||||
|
$scope.tagHistoryData = null;
|
||||||
|
$scope.tagHistoryLeaves = {};
|
||||||
|
|
||||||
|
var loadTimeline = function() {
|
||||||
|
if (!$scope.repository || !$scope.isEnabled) { return; }
|
||||||
|
|
||||||
|
var params = {
|
||||||
|
'repository': $scope.repository.namespace + '/' + $scope.repository.name
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiService.listRepoTags(null, params).then(function(resp) {
|
||||||
|
processTags(resp.tags);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.$watch('isEnabled', loadTimeline);
|
||||||
|
$scope.$watch('repository', loadTimeline);
|
||||||
|
|
||||||
|
var processTags = function(tags) {
|
||||||
|
var entries = [];
|
||||||
|
var tagEntries = {};
|
||||||
|
|
||||||
|
// For each tag, turn the tag into create, move, delete, restore, etc entries.
|
||||||
|
tags.forEach(function(tag) {
|
||||||
|
var tagName = tag.name;
|
||||||
|
var dockerImageId = tag.docker_image_id;
|
||||||
|
|
||||||
|
if (!tagEntries[tagName]) {
|
||||||
|
tagEntries[tagName] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var removeEntry = function(entry) {
|
||||||
|
entries.splice(entries.indexOf(entry), 1);
|
||||||
|
tagEntries[entry.tag_name].splice(tagEntries[entry.tag_name].indexOf(entry), 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
var addEntry = function(action, time, opt_docker_id, opt_old_docker_id) {
|
||||||
|
var entry = {
|
||||||
|
'tag_name': tagName,
|
||||||
|
'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 || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
tagEntries[tagName].push(entry);
|
||||||
|
entries.push(entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the tag has an end time, it was either deleted or moved.
|
||||||
|
if (tag.end_ts) {
|
||||||
|
// If a future entry exists with a start time equal to the end time for this tag,
|
||||||
|
// then the action was a move, rather than a delete and a create.
|
||||||
|
var currentEntries = tagEntries[tagName];
|
||||||
|
var futureEntry = currentEntries.length > 0 ? currentEntries[currentEntries.length - 1] : {};
|
||||||
|
if (futureEntry.start_ts == tag.end_ts) {
|
||||||
|
removeEntry(futureEntry);
|
||||||
|
addEntry(futureEntry.reversion ? 'revert': 'move', tag.end_ts,
|
||||||
|
futureEntry.docker_image_id, dockerImageId);
|
||||||
|
} else {
|
||||||
|
addEntry('delete', tag.end_ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the tag has a start time, it was created.
|
||||||
|
if (tag.start_ts) {
|
||||||
|
addEntry('create', tag.start_ts);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort the overall entries by datetime descending.
|
||||||
|
entries.sort(function(a, b) {
|
||||||
|
return b.time - a.time;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort the tag entries by datetime descending.
|
||||||
|
Object.keys(tagEntries).forEach(function(name) {
|
||||||
|
var te = tagEntries[name];
|
||||||
|
te.sort(function(a, b) {
|
||||||
|
return b.time - a.time;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add date dividers in.
|
||||||
|
for (var i = entries.length - 1; i >= 1; --i) {
|
||||||
|
var current = entries[i];
|
||||||
|
var next = entries[i - 1];
|
||||||
|
|
||||||
|
if (new Date(current.time).getDate() != new Date(next.time).getDate()) {
|
||||||
|
entries.splice(i, 0, {
|
||||||
|
'date_break': true,
|
||||||
|
'date': new Date(current.time)
|
||||||
|
});
|
||||||
|
i--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the top-level date divider.
|
||||||
|
if (entries.length > 0) {
|
||||||
|
entries.splice(0, 0, {
|
||||||
|
'date_break': true,
|
||||||
|
'date': new Date(entries[0].time)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.historyEntries = entries;
|
||||||
|
$scope.historyEntryMap = tagEntries;
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.getEntryClasses = function(entry, historyFilter) {
|
||||||
|
if (!entry.action) { return ''; }
|
||||||
|
|
||||||
|
var classes = entry.action + ' ';
|
||||||
|
if ($scope.historyEntryMap[entry.tag_name][0] == entry) {
|
||||||
|
classes += ' current ';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return directiveDefinitionObject;
|
||||||
|
});
|
|
@ -118,7 +118,7 @@ angular.module('quay').directive('robotsManager', function () {
|
||||||
if ($routeParams.showRobot) {
|
if ($routeParams.showRobot) {
|
||||||
var index = $scope.findRobotIndexByName($routeParams.showRobot);
|
var index = $scope.findRobotIndexByName($routeParams.showRobot);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
$scope.showRobot($scope.robots[index]);
|
$scope.robotFilter = $routeParams.showRobot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -121,6 +121,25 @@ angular.module('quay').directive('tagOperationsDialog', function () {
|
||||||
}, errorHandler);
|
}, 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 = {
|
$scope.actionHandler = {
|
||||||
'askDeleteTag': function(tag) {
|
'askDeleteTag': function(tag) {
|
||||||
$scope.deleteTagInfo = {
|
$scope.deleteTagInfo = {
|
||||||
|
@ -140,6 +159,20 @@ angular.module('quay').directive('tagOperationsDialog', function () {
|
||||||
$scope.addingTag = false;
|
$scope.addingTag = false;
|
||||||
$scope.addTagForm.$setPristine();
|
$scope.addTagForm.$setPristine();
|
||||||
$element.find('#createOrMoveTagModal').modal('show');
|
$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');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -148,11 +148,11 @@
|
||||||
|
|
||||||
// Watch for changes to the repository.
|
// Watch for changes to the repository.
|
||||||
$scope.$watch('repo', function() {
|
$scope.$watch('repo', function() {
|
||||||
if ($scope.tree) {
|
$timeout(function() {
|
||||||
$timeout(function() {
|
if ($scope.tree) {
|
||||||
$scope.tree.notifyResized();
|
$scope.tree.notifyResized();
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watch for changes to the tag parameter.
|
// Watch for changes to the tag parameter.
|
||||||
|
|
Binary file not shown.
|
@ -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 import api_bp, api
|
||||||
|
|
||||||
from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam, TeamMemberInvite
|
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.search import FindRepositories, EntitySearch
|
||||||
from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList
|
from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList
|
||||||
from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, RepositoryBuildLogs,
|
from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, RepositoryBuildLogs,
|
||||||
|
@ -2481,6 +2481,61 @@ class TestRepositoryImage5avqBuynlargeOrgrepo(ApiTestCase):
|
||||||
self._run_test('GET', 404, 'devtable', None)
|
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):
|
class TestRepositoryTagHp8rPublicPublicrepo(ApiTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
ApiTestCase.setUp(self)
|
ApiTestCase.setUp(self)
|
||||||
|
|
|
@ -14,7 +14,7 @@ from initdb import setup_database_for_testing, finished_database_for_testing
|
||||||
from data import model, database
|
from data import model, database
|
||||||
|
|
||||||
from endpoints.api.team import TeamMember, TeamMemberList, TeamMemberInvite, OrganizationTeam
|
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.search import FindRepositories, EntitySearch, ConductSearch
|
||||||
from endpoints.api.image import RepositoryImage, RepositoryImageList
|
from endpoints.api.image import RepositoryImage, RepositoryImageList
|
||||||
from endpoints.api.build import (RepositoryBuildStatus, RepositoryBuildLogs, RepositoryBuildList,
|
from endpoints.api.build import (RepositoryBuildStatus, RepositoryBuildLogs, RepositoryBuildList,
|
||||||
|
@ -1746,6 +1746,45 @@ class TestGetImageChanges(ApiTestCase):
|
||||||
# image_id=image_id))
|
# 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):
|
class TestListAndDeleteTag(ApiTestCase):
|
||||||
def test_listdeletecreateandmovetag(self):
|
def test_listdeletecreateandmovetag(self):
|
||||||
self.login(ADMIN_ACCESS_USER)
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
52
workers/repositoryactioncounter.py
Normal file
52
workers/repositoryactioncounter.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
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()
|
Reference in a new issue