Get the basic tutorial working completely, including reacting to server-side events

This commit is contained in:
Joseph Schorr 2014-02-06 20:58:26 -05:00
parent b7afc83204
commit fa1bf94af1
20 changed files with 431 additions and 99 deletions

View file

@ -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
View 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()

View file

@ -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:

View file

@ -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
View file

@ -0,0 +1 @@
gunicorn -c conf/gunicorn_local.py application:application

View file

@ -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;
} }

View file

@ -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,

View file

@ -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;
}]); }]);

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View 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>!

View 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>

View file

@ -0,0 +1 @@
Your repository {{ tour.tourScope.username }}/{{ tour.tourScope.repoName }} is currently being pushed to Quay.io...

View 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>

View 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>