Get the basic tutorial working completely, including reacting to server-side events
This commit is contained in:
parent
b7afc83204
commit
fa1bf94af1
20 changed files with 431 additions and 99 deletions
15
config.py
15
config.py
|
@ -7,6 +7,7 @@ from storage.s3 import S3Storage
|
||||||
from storage.local import LocalStorage
|
from storage.local import LocalStorage
|
||||||
from data.userfiles import UserRequestFiles
|
from data.userfiles import UserRequestFiles
|
||||||
from data.buildlogs import BuildLogs
|
from data.buildlogs import BuildLogs
|
||||||
|
from data.userevent import UserEventBuilder
|
||||||
from util import analytics
|
from util import analytics
|
||||||
|
|
||||||
from test.teststorage import FakeStorage, FakeUserfiles
|
from test.teststorage import FakeStorage, FakeUserfiles
|
||||||
|
@ -91,6 +92,10 @@ class RedisBuildLogs(object):
|
||||||
BUILDLOGS = BuildLogs('logs.quay.io')
|
BUILDLOGS = BuildLogs('logs.quay.io')
|
||||||
|
|
||||||
|
|
||||||
|
class UserEventConfig(object):
|
||||||
|
USER_EVENTS = UserEventBuilder('logs.quay.io')
|
||||||
|
|
||||||
|
|
||||||
class StripeTestConfig(object):
|
class StripeTestConfig(object):
|
||||||
STRIPE_SECRET_KEY = 'sk_test_PEbmJCYrLXPW0VRLSnWUiZ7Y'
|
STRIPE_SECRET_KEY = 'sk_test_PEbmJCYrLXPW0VRLSnWUiZ7Y'
|
||||||
STRIPE_PUBLISHABLE_KEY = 'pk_test_uEDHANKm9CHCvVa2DLcipGRh'
|
STRIPE_PUBLISHABLE_KEY = 'pk_test_uEDHANKm9CHCvVa2DLcipGRh'
|
||||||
|
@ -154,7 +159,8 @@ def logs_init_builder(level=logging.DEBUG):
|
||||||
|
|
||||||
|
|
||||||
class TestConfig(FlaskConfig, FakeStorage, EphemeralDB, FakeUserfiles,
|
class TestConfig(FlaskConfig, FakeStorage, EphemeralDB, FakeUserfiles,
|
||||||
FakeAnalytics, StripeTestConfig, RedisBuildLogs):
|
FakeAnalytics, StripeTestConfig, RedisBuildLogs,
|
||||||
|
UserEventConfig):
|
||||||
LOGGING_CONFIG = logs_init_builder(logging.WARN)
|
LOGGING_CONFIG = logs_init_builder(logging.WARN)
|
||||||
POPULATE_DB_TEST_DATA = True
|
POPULATE_DB_TEST_DATA = True
|
||||||
TESTING = True
|
TESTING = True
|
||||||
|
@ -164,7 +170,7 @@ class TestConfig(FlaskConfig, FakeStorage, EphemeralDB, FakeUserfiles,
|
||||||
class DebugConfig(FlaskConfig, MailConfig, LocalStorage, SQLiteDB,
|
class DebugConfig(FlaskConfig, MailConfig, LocalStorage, SQLiteDB,
|
||||||
StripeTestConfig, MixpanelTestConfig, GitHubTestConfig,
|
StripeTestConfig, MixpanelTestConfig, GitHubTestConfig,
|
||||||
DigitalOceanConfig, BuildNodeConfig, S3Userfiles,
|
DigitalOceanConfig, BuildNodeConfig, S3Userfiles,
|
||||||
RedisBuildLogs):
|
RedisBuildLogs, UserEventConfig):
|
||||||
LOGGING_CONFIG = logs_init_builder()
|
LOGGING_CONFIG = logs_init_builder()
|
||||||
SEND_FILE_MAX_AGE_DEFAULT = 0
|
SEND_FILE_MAX_AGE_DEFAULT = 0
|
||||||
POPULATE_DB_TEST_DATA = True
|
POPULATE_DB_TEST_DATA = True
|
||||||
|
@ -174,7 +180,8 @@ class DebugConfig(FlaskConfig, MailConfig, LocalStorage, SQLiteDB,
|
||||||
class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL,
|
class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL,
|
||||||
StripeLiveConfig, MixpanelTestConfig,
|
StripeLiveConfig, MixpanelTestConfig,
|
||||||
GitHubProdConfig, DigitalOceanConfig,
|
GitHubProdConfig, DigitalOceanConfig,
|
||||||
BuildNodeConfig, S3Userfiles, RedisBuildLogs):
|
BuildNodeConfig, S3Userfiles, RedisBuildLogs,
|
||||||
|
UserEventConfig):
|
||||||
LOGGING_CONFIG = logs_init_builder()
|
LOGGING_CONFIG = logs_init_builder()
|
||||||
SEND_FILE_MAX_AGE_DEFAULT = 0
|
SEND_FILE_MAX_AGE_DEFAULT = 0
|
||||||
|
|
||||||
|
@ -182,7 +189,7 @@ class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL,
|
||||||
class ProductionConfig(FlaskProdConfig, MailConfig, S3Storage, RDSMySQL,
|
class ProductionConfig(FlaskProdConfig, MailConfig, S3Storage, RDSMySQL,
|
||||||
StripeLiveConfig, MixpanelProdConfig,
|
StripeLiveConfig, MixpanelProdConfig,
|
||||||
GitHubProdConfig, DigitalOceanConfig, BuildNodeConfig,
|
GitHubProdConfig, DigitalOceanConfig, BuildNodeConfig,
|
||||||
S3Userfiles, RedisBuildLogs):
|
S3Userfiles, RedisBuildLogs, UserEventConfig):
|
||||||
|
|
||||||
LOGGING_CONFIG = logs_init_builder()
|
LOGGING_CONFIG = logs_init_builder()
|
||||||
SEND_FILE_MAX_AGE_DEFAULT = 0
|
SEND_FILE_MAX_AGE_DEFAULT = 0
|
||||||
|
|
90
data/userevent.py
Normal file
90
data/userevent.py
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import redis
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
|
||||||
|
class UserEventBuilder(object):
|
||||||
|
"""
|
||||||
|
Defines a helper class for constructing UserEvent and UserEventListener
|
||||||
|
instances.
|
||||||
|
"""
|
||||||
|
def __init__(self, redis_host):
|
||||||
|
self._redis_host = redis_host
|
||||||
|
|
||||||
|
def get_event(self, username):
|
||||||
|
return UserEvent(self._redis_host, username)
|
||||||
|
|
||||||
|
def get_listener(self, username, events):
|
||||||
|
return UserEventListener(self._redis_host, username, events)
|
||||||
|
|
||||||
|
|
||||||
|
class UserEvent(object):
|
||||||
|
"""
|
||||||
|
Defines a helper class for publishing to realtime user events
|
||||||
|
as backed by Redis.
|
||||||
|
"""
|
||||||
|
def __init__(self, redis_host, username):
|
||||||
|
self._redis = redis.StrictRedis(host=redis_host)
|
||||||
|
self._username = username
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _user_event_key(username, event_id):
|
||||||
|
return 'user/%s/events/%s' % (username, event_id)
|
||||||
|
|
||||||
|
def publish_event_data_sync(self, event_id, data_obj):
|
||||||
|
return self._redis.publish(self._user_event_key(self._username, event_id), json.dumps(data_obj))
|
||||||
|
|
||||||
|
def publish_event_data(self, event_id, data_obj):
|
||||||
|
"""
|
||||||
|
Publishes the serialized form of the data object for the given event. Note that this occurs
|
||||||
|
in a thread to prevent blocking.
|
||||||
|
"""
|
||||||
|
def conduct():
|
||||||
|
try:
|
||||||
|
self.publish_event_data_sync(event_id, data_obj)
|
||||||
|
except Exception as e:
|
||||||
|
print e
|
||||||
|
|
||||||
|
thread = threading.Thread(target=conduct)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
class UserEventListener(object):
|
||||||
|
"""
|
||||||
|
Defines a helper class for subscribing to realtime user events as
|
||||||
|
backed by Redis.
|
||||||
|
"""
|
||||||
|
def __init__(self, redis_host, username, events=set([])):
|
||||||
|
channels = [self._user_event_key(username, e) for e in events]
|
||||||
|
|
||||||
|
self._redis = redis.StrictRedis(host=redis_host)
|
||||||
|
self._pubsub = self._redis.pubsub()
|
||||||
|
self._pubsub.subscribe(channels)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _user_event_key(username, event_id):
|
||||||
|
return 'user/%s/events/%s' % (username, event_id)
|
||||||
|
|
||||||
|
def event_stream(self):
|
||||||
|
"""
|
||||||
|
Starts listening for events on the channel(s), yielding for each event
|
||||||
|
found.
|
||||||
|
"""
|
||||||
|
for item in self._pubsub.listen():
|
||||||
|
channel = item['channel']
|
||||||
|
event_id = channel.split('/')[3] # user/{username}/{events}/{id}
|
||||||
|
data = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(item['data'] or '{}')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if data:
|
||||||
|
yield event_id, data
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""
|
||||||
|
Unsubscribes from the channel(s). Should be called once the connection
|
||||||
|
has terminated.
|
||||||
|
"""
|
||||||
|
self._pubsub.unsubscribe()
|
|
@ -5,9 +5,9 @@ import urlparse
|
||||||
from flask import request, make_response, jsonify, session, Blueprint
|
from flask import request, make_response, jsonify, session, Blueprint
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from data import model
|
from data import model, userevent
|
||||||
from data.queue import webhook_queue
|
from data.queue import webhook_queue
|
||||||
from app import mixpanel
|
from app import mixpanel, app
|
||||||
from auth.auth import (process_auth, get_authenticated_user,
|
from auth.auth import (process_auth, get_authenticated_user,
|
||||||
get_validated_token)
|
get_validated_token)
|
||||||
from util.names import parse_repository_name
|
from util.names import parse_repository_name
|
||||||
|
@ -80,8 +80,16 @@ def create_user():
|
||||||
if existing_user:
|
if existing_user:
|
||||||
verified = model.verify_user(username, password)
|
verified = model.verify_user(username, password)
|
||||||
if verified:
|
if verified:
|
||||||
|
# Mark that the user was logged in.
|
||||||
|
event = app.config['USER_EVENTS'].get_event(username)
|
||||||
|
event.publish_event_data('docker-cli', {'action': 'login'})
|
||||||
|
|
||||||
return make_response('Verified', 201)
|
return make_response('Verified', 201)
|
||||||
else:
|
else:
|
||||||
|
# Mark that the login failed.
|
||||||
|
event = app.config['USER_EVENTS'].get_event(username)
|
||||||
|
event.publish_event_data('docker-cli', {'action': 'loginfailure'})
|
||||||
|
|
||||||
abort(400, 'Invalid password.', issue='login-failure')
|
abort(400, 'Invalid password.', issue='login-failure')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
@ -186,9 +194,21 @@ def create_repository(namespace, repository):
|
||||||
}
|
}
|
||||||
|
|
||||||
if get_authenticated_user():
|
if get_authenticated_user():
|
||||||
mixpanel.track(get_authenticated_user().username, 'push_repo',
|
username = get_authenticated_user().username
|
||||||
extra_params)
|
|
||||||
metadata['username'] = get_authenticated_user().username
|
mixpanel.track(username, 'push_repo', extra_params)
|
||||||
|
metadata['username'] = username
|
||||||
|
|
||||||
|
# Mark that the user has started pushing the repo.
|
||||||
|
user_data = {
|
||||||
|
'action': 'push_repo',
|
||||||
|
'repository': repository,
|
||||||
|
'namespace': namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
event = app.config['USER_EVENTS'].get_event(username)
|
||||||
|
event.publish_event_data('docker-cli', user_data)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
mixpanel.track(get_validated_token().code, 'push_repo', extra_params)
|
mixpanel.track(get_validated_token().code, 'push_repo', extra_params)
|
||||||
metadata['token'] = get_validated_token().friendly_name
|
metadata['token'] = get_validated_token().friendly_name
|
||||||
|
@ -222,6 +242,19 @@ def update_images(namespace, repository):
|
||||||
updated_tags[image['Tag']] = image['id']
|
updated_tags[image['Tag']] = image['id']
|
||||||
model.set_image_checksum(image['id'], repo, image['checksum'])
|
model.set_image_checksum(image['id'], repo, image['checksum'])
|
||||||
|
|
||||||
|
if get_authenticated_user():
|
||||||
|
username = get_authenticated_user().username
|
||||||
|
|
||||||
|
# Mark that the user has pushed the repo.
|
||||||
|
user_data = {
|
||||||
|
'action': 'pushed_repo',
|
||||||
|
'repository': repository,
|
||||||
|
'namespace': namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
event = app.config['USER_EVENTS'].get_event(username)
|
||||||
|
event.publish_event_data('docker-cli', user_data)
|
||||||
|
|
||||||
# Generate a job for each webhook that has been added to this repo
|
# Generate a job for each webhook that has been added to this repo
|
||||||
webhooks = model.list_webhooks(namespace, repository)
|
webhooks = model.list_webhooks(namespace, repository)
|
||||||
for webhook in webhooks:
|
for webhook in webhooks:
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import logging
|
import logging
|
||||||
import redis
|
import redis
|
||||||
|
import json
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from flask import request, make_response, Blueprint, abort, Response
|
from flask import request, make_response, Blueprint, abort, Response
|
||||||
from flask.ext.login import current_user, logout_user
|
from flask.ext.login import current_user, logout_user
|
||||||
from data import model
|
from data import model, userevent
|
||||||
from app import app
|
from app import app
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -29,31 +30,8 @@ def api_login_required(f):
|
||||||
return decorated_view
|
return decorated_view
|
||||||
|
|
||||||
|
|
||||||
# Based off of the SSE flask snippet here: http://flask.pocoo.org/snippets/116/
|
@realtime.route("/user/")
|
||||||
|
@api_login_required
|
||||||
class ServerSentEvent(object):
|
|
||||||
def __init__(self, data):
|
|
||||||
self.data = data
|
|
||||||
self.event = None
|
|
||||||
self.id = None
|
|
||||||
self.desc_map = {
|
|
||||||
self.data : "data",
|
|
||||||
self.event : "event",
|
|
||||||
self.id : "id"
|
|
||||||
}
|
|
||||||
|
|
||||||
def encode(self):
|
|
||||||
if not self.data:
|
|
||||||
return ""
|
|
||||||
lines = ["%s: %s" % (v, k)
|
|
||||||
for k, v in self.desc_map.iteritems() if k]
|
|
||||||
|
|
||||||
return "%s\n\n" % "\n".join(lines)
|
|
||||||
|
|
||||||
# The current subscriptions
|
|
||||||
subscriptions = []
|
|
||||||
|
|
||||||
@realtime.route("/")
|
|
||||||
def index():
|
def index():
|
||||||
debug_template = """
|
debug_template = """
|
||||||
<html>
|
<html>
|
||||||
|
@ -65,7 +43,7 @@ def index():
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
|
||||||
var eventOutputContainer = document.getElementById("event");
|
var eventOutputContainer = document.getElementById("event");
|
||||||
var evtSrc = new EventSource("/realtime/subscribe");
|
var evtSrc = new EventSource("/realtime/user/subscribe?events=docker-cli");
|
||||||
|
|
||||||
evtSrc.onmessage = function(e) {
|
evtSrc.onmessage = function(e) {
|
||||||
console.log(e.data);
|
console.log(e.data);
|
||||||
|
@ -79,18 +57,25 @@ def index():
|
||||||
return(debug_template)
|
return(debug_template)
|
||||||
|
|
||||||
|
|
||||||
@realtime.route("/subscribe")
|
@realtime.route("/user/test")
|
||||||
@api_login_required
|
@api_login_required
|
||||||
def subscribe():
|
def user_test():
|
||||||
def gen():
|
evt = userevent.UserEvent('logs.quay.io', current_user.db_user().username)
|
||||||
q = Queue()
|
evt.publish_event_data('test', {'foo': 2})
|
||||||
subscriptions.append(q)
|
return 'OK'
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
result = q.get()
|
|
||||||
ev = ServerSentEvent(str(result))
|
|
||||||
yield ev.encode()
|
|
||||||
except GeneratorExit:
|
|
||||||
subscriptions.remove(q)
|
|
||||||
|
|
||||||
return Response(gen(), mimetype="text/event-stream")
|
@realtime.route("/user/subscribe")
|
||||||
|
@api_login_required
|
||||||
|
def user_subscribe():
|
||||||
|
def wrapper(listener):
|
||||||
|
for event_id, data in listener.event_stream():
|
||||||
|
message = {'event': event_id, 'data': data}
|
||||||
|
json_string = json.dumps(message)
|
||||||
|
yield 'data: %s\n\n' % json_string
|
||||||
|
|
||||||
|
events = request.args.get('events', '').split(',')
|
||||||
|
if not events:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
listener = userevent.UserEventListener('logs.quay.io', current_user.db_user().username, events)
|
||||||
|
return Response(wrapper(listener), mimetype="text/event-stream")
|
||||||
|
|
1
run-local.sh
Executable file
1
run-local.sh
Executable file
|
@ -0,0 +1 @@
|
||||||
|
gunicorn -c conf/gunicorn_local.py application:application
|
|
@ -2836,7 +2836,7 @@ p.editable:hover i {
|
||||||
|
|
||||||
.angular-tour-ui-element.inline .step-title {
|
.angular-tour-ui-element.inline .step-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.angular-tour-ui-element.inline .step-content {
|
.angular-tour-ui-element.inline .step-content {
|
||||||
|
@ -2850,7 +2850,7 @@ p.editable:hover i {
|
||||||
}
|
}
|
||||||
|
|
||||||
.angular-tour-ui-element p {
|
.angular-tour-ui-element p {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.angular-tour-ui-element .wait-message {
|
.angular-tour-ui-element .wait-message {
|
||||||
|
@ -2869,6 +2869,13 @@ p.editable:hover i {
|
||||||
border-left-color: #999;
|
border-left-color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.angular-tour-ui-element .note {
|
||||||
|
margin: 10px;
|
||||||
|
padding: 6px;
|
||||||
|
background: #eee;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
pre.command {
|
pre.command {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
@ -2876,13 +2883,19 @@ pre.command {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: solid 1px #ccc;
|
border: solid 1px #ccc;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 20px;
|
margin-top: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre.command:before {
|
pre.command:before {
|
||||||
content: "\f120";
|
content: "\f120";
|
||||||
font-family: "FontAwesome";
|
font-family: "FontAwesome";
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-inline {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 10px;
|
||||||
}
|
}
|
|
@ -48,6 +48,10 @@ function GuideCtrl($scope) {
|
||||||
function TutorialCtrl($scope, AngularTour, AngularTourSignals, UserService) {
|
function TutorialCtrl($scope, AngularTour, AngularTourSignals, UserService) {
|
||||||
$scope.tour = {
|
$scope.tour = {
|
||||||
'title': 'Quay.io Tutorial',
|
'title': 'Quay.io Tutorial',
|
||||||
|
'initialScope': {
|
||||||
|
'repoName': 'myfirstrepo',
|
||||||
|
'containerId': 'containerId'
|
||||||
|
},
|
||||||
'steps': [
|
'steps': [
|
||||||
{
|
{
|
||||||
'title': 'Welcome to the Quay.io tutorial!',
|
'title': 'Welcome to the Quay.io tutorial!',
|
||||||
|
@ -65,22 +69,47 @@ function TutorialCtrl($scope, AngularTour, AngularTourSignals, UserService) {
|
||||||
{
|
{
|
||||||
'title': 'Step 1: Login to Quay.io',
|
'title': 'Step 1: Login to Quay.io',
|
||||||
'templateUrl': '/static/tutorial/docker-login.html',
|
'templateUrl': '/static/tutorial/docker-login.html',
|
||||||
'signal': AngularTourSignals.matchesLocation('/repository/'),
|
'signal': AngularTourSignals.serverEvent('/realtime/user/subscribe?events=docker-cli',
|
||||||
'waitMessage': "Waiting for login"
|
function(message) {
|
||||||
|
return message['data']['action'] == 'login';
|
||||||
|
}),
|
||||||
|
'waitMessage': "Waiting for docker login"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Step 2: Create a new image',
|
'title': 'Step 2: Create a new container',
|
||||||
|
'templateUrl': '/static/tutorial/create-container.html'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Step 3: Create a new image',
|
||||||
'templateUrl': '/static/tutorial/create-image.html'
|
'templateUrl': '/static/tutorial/create-image.html'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Step 3: Push the image to Quay.io',
|
'title': 'Step 4: Push the image to Quay.io',
|
||||||
'templateUrl': '/static/tutorial/push-image.html'
|
'templateUrl': '/static/tutorial/push-image.html',
|
||||||
|
'signal': AngularTourSignals.serverEvent('/realtime/user/subscribe?events=docker-cli',
|
||||||
|
function(message, tourScope) {
|
||||||
|
var pushing = message['data']['action'] == 'push_repo';
|
||||||
|
if (pushing) {
|
||||||
|
tourScope.repoName = message['data']['repository'];
|
||||||
|
}
|
||||||
|
return pushing;
|
||||||
|
}),
|
||||||
|
'waitMessage': "Waiting for repository push to begin"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Step 4: View the repository on Quay.io',
|
'title': 'Push in progress',
|
||||||
|
'templateUrl': '/static/tutorial/pushing.html',
|
||||||
|
'signal': AngularTourSignals.serverEvent('/realtime/user/subscribe?events=docker-cli',
|
||||||
|
function(message, tourScope) {
|
||||||
|
return message['data']['action'] == 'pushed_repo';
|
||||||
|
}),
|
||||||
|
'waitMessage': "Waiting for repository push to complete"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Step 5: View the repository on Quay.io',
|
||||||
'templateUrl': '/static/tutorial/view-repo.html',
|
'templateUrl': '/static/tutorial/view-repo.html',
|
||||||
'signal': AngularTourSignals.matchesLocation('/repository/'),
|
'signal': AngularTourSignals.matchesLocation('/repository/'),
|
||||||
'waitMessage': "Waiting for image push to complete"
|
'overlayable': true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'content': 'Waiting for the page to load',
|
'content': 'Waiting for the page to load',
|
||||||
|
@ -88,13 +117,61 @@ function TutorialCtrl($scope, AngularTour, AngularTourSignals, UserService) {
|
||||||
'overlayable': true
|
'overlayable': true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'content': 'Select your new repository from the list',
|
'templateUrl': '/static/tutorial/repo-list.html',
|
||||||
'signal': AngularTourSignals.matchesLocation('/repository/{{username}}/{{repoName}}'),
|
'signal': AngularTourSignals.matchesLocation('/repository/{{username}}/{{repoName}}'),
|
||||||
'element': '*[data-repo="{{username}}/{{repoName}}"]',
|
'element': '*[data-repo="{{username}}/{{repoName}}"]',
|
||||||
'overlayable': true
|
'overlayable': true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'content': 'And done!',
|
'title': 'Repository View',
|
||||||
|
'content': 'This is the repository view page. It displays all the primary information about your repository.',
|
||||||
|
'overlayable': true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Image History',
|
||||||
|
'content': 'The tree displays the full history of your repository, including all its tag. ' +
|
||||||
|
'You can click on a tag or image to see its information.',
|
||||||
|
'element': '#image-history-container',
|
||||||
|
'overlayable': true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Tag/Image Information',
|
||||||
|
'content': 'This panel displays information about the currently selected tag or image',
|
||||||
|
'element': '#side-panel',
|
||||||
|
'overlayable': true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Select tag or image',
|
||||||
|
'content': 'You can select a tag or image by clicking on this dropdown',
|
||||||
|
'element': '#side-panel-dropdown',
|
||||||
|
'overlayable': true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'content': 'To view the admin settings for the repository, click on the gear',
|
||||||
|
'element': '#admin-cog',
|
||||||
|
'signal': AngularTourSignals.matchesLocation('/repository/{{username}}/{{repoName}}/admin'),
|
||||||
|
'overlayable': true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Repository Admin',
|
||||||
|
'content': "The repository admin panel allows for modification of a repository's permissions, webhooks, visibility and other settings",
|
||||||
|
'overlayable': true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Permissions',
|
||||||
|
'content': "The permissions tab displays all the users, robot accounts and tokens that have access to the repository",
|
||||||
|
'overlayable': true,
|
||||||
|
'element': '#permissions'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Adding a permission',
|
||||||
|
'content': 'To add a permission, enter a username or robot account name into the autocomplete ' +
|
||||||
|
'or hit the dropdown arrow to manage robot accounts',
|
||||||
|
'overlayable': true,
|
||||||
|
'element': '#add-entity-permission'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'templateUrl': '/static/tutorial/done.html',
|
||||||
'overlayable': true
|
'overlayable': true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -679,9 +756,15 @@ function RepoAdminCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
|
||||||
$scope.permissions = {'team': [], 'user': []};
|
$scope.permissions = {'team': [], 'user': []};
|
||||||
$scope.logsShown = 0;
|
$scope.logsShown = 0;
|
||||||
$scope.deleting = false;
|
$scope.deleting = false;
|
||||||
|
$scope.permissionCache = {};
|
||||||
|
|
||||||
$scope.buildEntityForPermission = function(name, permission, kind) {
|
$scope.buildEntityForPermission = function(name, permission, kind) {
|
||||||
return {
|
var key = name + ':' + kind;
|
||||||
|
if ($scope.permissionCache[key]) {
|
||||||
|
return $scope.permissionCache[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scope.permissionCache[key] = {
|
||||||
'kind': kind,
|
'kind': kind,
|
||||||
'name': name,
|
'name': name,
|
||||||
'is_robot': permission.is_robot,
|
'is_robot': permission.is_robot,
|
||||||
|
@ -702,7 +785,7 @@ function RepoAdminCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
|
||||||
|
|
||||||
$scope.addNewPermission = function(entity) {
|
$scope.addNewPermission = function(entity) {
|
||||||
// Don't allow duplicates.
|
// Don't allow duplicates.
|
||||||
if ($scope.permissions[entity.kind][entity.name]) { return; }
|
if (!entity || !entity.kind || $scope.permissions[entity.kind][entity.name]) { return; }
|
||||||
|
|
||||||
if (entity.is_org_member === false) {
|
if (entity.is_org_member === false) {
|
||||||
$scope.currentAddEntity = entity;
|
$scope.currentAddEntity = entity;
|
||||||
|
@ -1605,7 +1688,7 @@ function TeamViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams)
|
||||||
$rootScope.title = 'Loading...';
|
$rootScope.title = 'Loading...';
|
||||||
|
|
||||||
$scope.addNewMember = function(member) {
|
$scope.addNewMember = function(member) {
|
||||||
if ($scope.members[member.name]) { return; }
|
if (!member || $scope.members[member.name]) { return; }
|
||||||
|
|
||||||
var params = {
|
var params = {
|
||||||
'orgname': orgname,
|
'orgname': orgname,
|
||||||
|
|
|
@ -32,21 +32,20 @@ angular.module("angular-tour", [])
|
||||||
'inline': '=inline',
|
'inline': '=inline',
|
||||||
},
|
},
|
||||||
controller: function($rootScope, $scope, $element, $location, $interval, AngularTour) {
|
controller: function($rootScope, $scope, $element, $location, $interval, AngularTour) {
|
||||||
var createNewScope = function() {
|
var createNewScope = function(initialScope) {
|
||||||
var tourScope = {
|
var tourScope = jQuery.extend({}, initialScope || {});
|
||||||
'_replaceData': function(s) {
|
tourScope['_replaceData'] = function(s) {
|
||||||
if (typeof s != 'string') {
|
if (typeof s != 'string') {
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var key in tourScope) {
|
|
||||||
if (key[0] == '_') { continue; }
|
|
||||||
if (tourScope.hasOwnProperty(key)) {
|
|
||||||
s = s.replace('{{' + key + '}}', tourScope[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (var key in tourScope) {
|
||||||
|
if (key[0] == '_') { continue; }
|
||||||
|
if (tourScope.hasOwnProperty(key)) {
|
||||||
|
s = s.replace('{{' + key + '}}', tourScope[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
};
|
};
|
||||||
|
|
||||||
return tourScope;
|
return tourScope;
|
||||||
|
@ -55,7 +54,7 @@ angular.module("angular-tour", [])
|
||||||
$scope.stepIndex = 0;
|
$scope.stepIndex = 0;
|
||||||
$scope.step = null;
|
$scope.step = null;
|
||||||
$scope.interval = null;
|
$scope.interval = null;
|
||||||
$scope.tourScope = createNewScope();
|
$scope.tourScope = null;
|
||||||
|
|
||||||
var getElement = function() {
|
var getElement = function() {
|
||||||
if (typeof $scope.step['element'] == 'function') {
|
if (typeof $scope.step['element'] == 'function') {
|
||||||
|
@ -65,14 +64,35 @@ angular.module("angular-tour", [])
|
||||||
return $($scope.tourScope._replaceData($scope.step['element']));
|
return $($scope.tourScope._replaceData($scope.step['element']));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var checkSignal = function() {
|
||||||
|
return $scope.step['signal'] && $scope.step['signal']($scope.tourScope);
|
||||||
|
};
|
||||||
|
|
||||||
|
var teardownSignal = function() {
|
||||||
|
if (!$scope.step) { return; }
|
||||||
|
|
||||||
|
var signal = $scope.step['signal'];
|
||||||
|
if (signal && signal.$teardown) {
|
||||||
|
signal.$teardown($scope.tourScope);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var setupSignal = function() {
|
||||||
|
if (!$scope.step) { return; }
|
||||||
|
|
||||||
|
var signal = $scope.step['signal'];
|
||||||
|
if (signal && signal.$setup) {
|
||||||
|
signal.$setup($scope.tourScope);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
var checkSignalTimer = function() {
|
var checkSignalTimer = function() {
|
||||||
if (!$scope.step) {
|
if (!$scope.step || !$scope.tourScope) {
|
||||||
stopSignalTimer();
|
stopSignalTimer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var signal = $scope.step['signal'];
|
if (checkSignal()) {
|
||||||
if (signal($scope.tourScope)) {
|
|
||||||
$scope.next();
|
$scope.next();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -116,6 +136,7 @@ angular.module("angular-tour", [])
|
||||||
|
|
||||||
$scope.setStepIndex = function(stepIndex) {
|
$scope.setStepIndex = function(stepIndex) {
|
||||||
// Close existing spotlight and signal timer.
|
// Close existing spotlight and signal timer.
|
||||||
|
teardownSignal();
|
||||||
closeDomHighlight();
|
closeDomHighlight();
|
||||||
stopSignalTimer();
|
stopSignalTimer();
|
||||||
|
|
||||||
|
@ -129,7 +150,8 @@ angular.module("angular-tour", [])
|
||||||
$scope.step = $scope.tour.steps[stepIndex];
|
$scope.step = $scope.tour.steps[stepIndex];
|
||||||
|
|
||||||
// If the signal is already true, then skip this step entirely.
|
// If the signal is already true, then skip this step entirely.
|
||||||
if ($scope.step['signal'] && $scope.step['signal']($scope.tourScope)) {
|
setupSignal();
|
||||||
|
if (checkSignal()) {
|
||||||
$scope.setStepIndex(stepIndex + 1);
|
$scope.setStepIndex(stepIndex + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -138,10 +160,11 @@ angular.module("angular-tour", [])
|
||||||
$scope.hasNextStep = stepIndex < $scope.tour.steps.length - 1;
|
$scope.hasNextStep = stepIndex < $scope.tour.steps.length - 1;
|
||||||
|
|
||||||
// Need the timeout here to ensure the click event does not
|
// Need the timeout here to ensure the click event does not
|
||||||
// hide the spotlight.
|
// hide the spotlight, and it has to be longer than the hide
|
||||||
|
// spotlight animation timing.
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
updateDomHighlight();
|
updateDomHighlight();
|
||||||
}, 1);
|
}, 500);
|
||||||
|
|
||||||
// Start listening for signals to move the tour forward.
|
// Start listening for signals to move the tour forward.
|
||||||
if ($scope.step.signal) {
|
if ($scope.step.signal) {
|
||||||
|
@ -162,9 +185,15 @@ angular.module("angular-tour", [])
|
||||||
$scope.$watch('tour', function(tour) {
|
$scope.$watch('tour', function(tour) {
|
||||||
stopSignalTimer();
|
stopSignalTimer();
|
||||||
if (tour) {
|
if (tour) {
|
||||||
|
// Set the tour scope.
|
||||||
|
if (tour.tourScope) {
|
||||||
|
$scope.tourScope = tour.tourScope;
|
||||||
|
} else {
|
||||||
|
$scope.tourScope = $scope.tour.tourScope = createNewScope(tour.initialScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the initial step.
|
||||||
$scope.setStepIndex(tour.initialStep || 0);
|
$scope.setStepIndex(tour.initialStep || 0);
|
||||||
$scope.tourScope = tour.tourScope || createNewScope();
|
|
||||||
$scope.tour.tourScope = $scope.tourScope;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -181,6 +210,9 @@ angular.module("angular-tour", [])
|
||||||
// Unbind the listener.
|
// Unbind the listener.
|
||||||
unbind();
|
unbind();
|
||||||
|
|
||||||
|
// Teardown any existing signal listener.
|
||||||
|
teardownSignal();
|
||||||
|
|
||||||
// If there is an active tour, transition it over to the overlay.
|
// If there is an active tour, transition it over to the overlay.
|
||||||
if ($scope.tour && $scope.step && $scope.step['overlayable']) {
|
if ($scope.tour && $scope.step && $scope.step['overlayable']) {
|
||||||
AngularTour.start($scope.tour, $scope.stepIndex, $scope.tourScope);
|
AngularTour.start($scope.tour, $scope.stepIndex, $scope.tourScope);
|
||||||
|
@ -212,5 +244,30 @@ angular.module("angular-tour", [])
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Signal: When a server-side event matches the predicate.
|
||||||
|
signals.serverEvent = function(url, matcher) {
|
||||||
|
var checker = function(tourScope) {
|
||||||
|
return checker.$message && matcher(checker.$message, tourScope);
|
||||||
|
};
|
||||||
|
|
||||||
|
checker.$message = null;
|
||||||
|
|
||||||
|
checker.$setup = function(tourScope) {
|
||||||
|
var fullUrl = tourScope._replaceData(url);
|
||||||
|
checker.$source = new EventSource(fullUrl);
|
||||||
|
checker.$source.onmessage = function(e) {
|
||||||
|
checker.$message = JSON.parse(e.data);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
checker.$teardown = function() {
|
||||||
|
if (checker.$source) {
|
||||||
|
checker.$source.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return checker;
|
||||||
|
};
|
||||||
|
|
||||||
return signals;
|
return signals;
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
<div class="plan-manager" organization="orgname" plan-changed="planChanged(plan)"></div>
|
<div class="plan-manager" organization="orgname" plan-changed="planChanged(plan)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Organiaztion settings tab -->
|
<!-- Organization settings tab -->
|
||||||
<div id="settings" class="tab-pane">
|
<div id="settings" class="tab-pane">
|
||||||
<div class="quay-spinner" ng-show="changingOrganization"></div>
|
<div class="quay-spinner" ng-show="changingOrganization"></div>
|
||||||
|
|
||||||
|
|
|
@ -89,7 +89,10 @@
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" class="admin-search">
|
<td colspan="2" class="admin-search">
|
||||||
<span class="entity-search" namespace="repo.namespace" include-teams="true" input-title="'Add a ' + (repo.is_organization ? 'team or ' : '') + 'user...'" entity-selected="addNewPermission" is-organization="repo.is_organization"></span>
|
<span id="add-entity-permission" class="entity-search" namespace="repo.namespace" include-teams="true"
|
||||||
|
input-title="'Add a ' + (repo.is_organization ? 'team or ' : '') + 'user...'"
|
||||||
|
entity-selected="addNewPermission" is-organization="repo.is_organization"
|
||||||
|
current-entity="selectedEntity"></span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -28,7 +28,10 @@
|
||||||
<div ng-show="user_repositories.value.length > 0">
|
<div ng-show="user_repositories.value.length > 0">
|
||||||
<div class="repo-listing" ng-repeat="repository in user_repositories.value">
|
<div class="repo-listing" ng-repeat="repository in user_repositories.value">
|
||||||
<span class="repo-circle no-background" repo="repository"></span>
|
<span class="repo-circle no-background" repo="repository"></span>
|
||||||
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
|
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}"
|
||||||
|
data-repo="{{repository.namespace}}/{{ repository.name }}">
|
||||||
|
{{repository.namespace}}/{{repository.name}}
|
||||||
|
</a>
|
||||||
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
|
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -27,7 +27,8 @@
|
||||||
<tr ng-show="canEditMembers">
|
<tr ng-show="canEditMembers">
|
||||||
<td colspan="2">
|
<td colspan="2">
|
||||||
<span class="entity-search" namespace="orgname" include-teams="false" input-title="'Add a user...'"
|
<span class="entity-search" namespace="orgname" include-teams="false" input-title="'Add a user...'"
|
||||||
entity-selected="addNewMember" is-organization="true"></span>
|
entity-selected="addNewMember" is-organization="true"
|
||||||
|
current-entity="selectedMember"></span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
<span class="repo-circle" repo="repo"></span>
|
<span class="repo-circle" repo="repo"></span>
|
||||||
<span class="repo-breadcrumb" repo="repo"></span>
|
<span class="repo-breadcrumb" repo="repo"></span>
|
||||||
<span class="settings-cog" ng-show="repo.can_admin" title="Repository Settings" bs-tooltip="tooltip.title" data-placement="bottom">
|
<span class="settings-cog" ng-show="repo.can_admin" title="Repository Settings" bs-tooltip="tooltip.title" data-placement="bottom">
|
||||||
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name + '/admin' }}">
|
<a id="admin-cog" href="{{ '/repository/' + repo.namespace + '/' + repo.name + '/admin' }}">
|
||||||
<i class="fa fa-cog fa-lg"></i>
|
<i class="fa fa-cog fa-lg"></i>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
@ -88,10 +88,10 @@ sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}</pre>
|
||||||
|
|
||||||
<!-- Side Panel -->
|
<!-- Side Panel -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="panel panel-default">
|
<div id="side-panel" class="panel panel-default">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<!-- Dropdown -->
|
<!-- Dropdown -->
|
||||||
<div class="tag-dropdown dropdown" data-placement="top">
|
<div id="side-panel-dropdown" class="tag-dropdown dropdown" data-placement="top">
|
||||||
<i class="fa fa-tag" ng-show="currentTag"></i>
|
<i class="fa fa-tag" ng-show="currentTag"></i>
|
||||||
<i class="fa fa-archive" ng-show="!currentTag"></i>
|
<i class="fa fa-archive" ng-show="!currentTag"></i>
|
||||||
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">{{currentTag ? currentTag.name : currentImage.id.substr(0, 12)}} <b class="caret"></b></a>
|
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">{{currentTag ? currentTag.name : currentImage.id.substr(0, 12)}} <b class="caret"></b></a>
|
||||||
|
|
24
static/tutorial/create-container.html
Normal file
24
static/tutorial/create-container.html
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<p>The first step to creating an image is to create a container and fill it with some data.</p>
|
||||||
|
|
||||||
|
<p>First we'll create a container with a single new file based off of the <code>ubuntu</code> base image:</p>
|
||||||
|
|
||||||
|
<pre class="command">
|
||||||
|
docker run ubuntu echo "fun" > newfile
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<p>The container will immediately terminate (because its one command is <code>echo</code>), so we'll use <code>docker ps -l</code> to list it:
|
||||||
|
|
||||||
|
<pre class="command">
|
||||||
|
docker ps -l
|
||||||
|
CONTAINER ID IMAGE COMMAND CREATED
|
||||||
|
<var class="var1">07f2065197ef</var> ubuntu:12.04 echo fun 31 seconds ago
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<div class="control-group">
|
||||||
|
<label class="control-label" for="containerId">Enter the command ID:</label>
|
||||||
|
<div class="form-inline">
|
||||||
|
<input type="text" id="containerId" class="form-control" ng-model="tour.tourScope.containerId">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
16
static/tutorial/create-image.html
Normal file
16
static/tutorial/create-image.html
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<div class="control-group">
|
||||||
|
<label class="control-label" for="containerId">Enter a repo name:</label>
|
||||||
|
<div class="form-inline">
|
||||||
|
<input type="text" id="containerId" class="form-control" ng-model="tour.tourScope.repoName">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Once a container has terminated in Docker, the next step is to <i>tag</i> the container to an image, so it can be saved to a repository.</p>
|
||||||
|
|
||||||
|
<p>To do so, we run the <code>docker commit</code> with the container ID from the previous step and tag it to be a repository under <code>quay.io</code>.
|
||||||
|
|
||||||
|
<pre class="command">
|
||||||
|
docker commit <var class="var1">{{ tour.tourScope.containerId }}</var> quay.io/{{ tour.tourScope.username }}/<var class="var2">{{ tour.tourScope.repoName }}</var>
|
||||||
|
</pre>
|
1
static/tutorial/done.html
Normal file
1
static/tutorial/done.html
Normal file
|
@ -0,0 +1 @@
|
||||||
|
That's it for the introduction tutorial! If you have any questions, please check the <a href="http://docs.quay.io" target="_blank">Quay Documentation</a> or <a href="/contact">contact us</a>!
|
8
static/tutorial/push-image.html
Normal file
8
static/tutorial/push-image.html
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<p>Now that we've tagged our image with a repository name, we can <code>push</code> the repository to Quay.io:</p>
|
||||||
|
|
||||||
|
<pre class="command">
|
||||||
|
docker push quay.io/{{ tour.tourScope.username }}/<var class="var2">{{ tour.tourScope.repoName }}</var>
|
||||||
|
The push refers to a repository [quay.io/{{ tour.tourScope.username }}/<var class="var2">{{ tour.tourScope.repoName }}</var>] (len: 1)
|
||||||
|
Sending image list
|
||||||
|
Pushing repository quay.io/{{ tour.tourScope.username }}/<var class="var2">{{ tour.tourScope.repoName }}</var> (1 tags)
|
||||||
|
</pre>
|
1
static/tutorial/pushing.html
Normal file
1
static/tutorial/pushing.html
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Your repository {{ tour.tourScope.username }}/{{ tour.tourScope.repoName }} is currently being pushed to Quay.io...
|
3
static/tutorial/repo-list.html
Normal file
3
static/tutorial/repo-list.html
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<p>This page displays all your personal repositories, as well as select public repositories</p>
|
||||||
|
|
||||||
|
<p>To view your new repository, click on <strong>{{ tour.tourScope.username }}/{{ tour.tourScope.repoName }}</strong></p>
|
3
static/tutorial/view-repo.html
Normal file
3
static/tutorial/view-repo.html
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<p>Your repository {{ tour.tourScope.username }}/{{ tour.tourScope.repoName }} has been pushed to Quay.io!</p>
|
||||||
|
|
||||||
|
<p>To view your repository, first click on the <strong>Repositories</strong> link in the header to continue. This will display the repositories list.</p>
|
Reference in a new issue