Merge lp:~blake-rouse/maas/new-images-page into lp:~maas-committers/maas/trunk

Proposed by Blake Rouse
Status: Merged
Approved by: Blake Rouse
Approved revision: no longer in the source branch.
Merged at revision: 5352
Proposed branch: lp:~blake-rouse/maas/new-images-page
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 3897 lines (+3284/-108)
15 files modified
src/maasserver/bootresources.py (+48/-14)
src/maasserver/static/js/angular/controllers/images.js (+11/-0)
src/maasserver/static/js/angular/directives/boot_images.js (+744/-0)
src/maasserver/static/js/angular/directives/tests/test_boot_images.js (+1454/-0)
src/maasserver/static/js/angular/factories/bootresources.js (+179/-0)
src/maasserver/static/js/angular/factories/tests/test_bootresources.js (+195/-0)
src/maasserver/static/js/angular/maas.js (+5/-0)
src/maasserver/static/partials/boot-images.html (+265/-0)
src/maasserver/static/partials/images.html (+7/-0)
src/maasserver/templates/maasserver/base.html (+1/-1)
src/maasserver/templates/maasserver/index.html (+1/-1)
src/maasserver/tests/test_bootresources.py (+46/-4)
src/maasserver/views/combo.py (+3/-0)
src/maasserver/websockets/handlers/bootresource.py (+226/-66)
src/maasserver/websockets/handlers/tests/test_bootresource.py (+99/-22)
To merge this branch: bzr merge lp:~blake-rouse/maas/new-images-page
Reviewer Review Type Date Requested Status
Andres Rodriguez (community) Approve
Review via email: mp+305612@code.launchpad.net

Commit message

Add new maas-boot-images directive and new angular based images page.

The maas-boot-images directive allows the same code to be used both on the images page as well as the first user journey.

Description of the change

This is a whole re-implementation and the new design has a lot more interaction than the previous design, so that is why it is so large. Its also javascript, ;-)

To post a comment you must log in.
Revision history for this message
Lee Trager (ltrager) wrote :

Looks really good so far. I was able to merge lp:~ltrager/maas/use_bootloaders into your branch to test it with the bootloaders.

Couple of things I noticed
1. The bootloaders still show up. In my branch I filter them with BootSourceCache.objects.filter(bootloader_type=None)
2. The other images section lists the images twice, onces with checkboxes, onces without. See http://i.imgur.com/EJOjhpg.png
3. I started with MAAS using the V3 test stream. If I click on the maas.io source the boot source metadata is immediately reloaded. It looks like the region then tries to download the maas.io images(they're at an older version than mine). If I switch back to my custom stream 16.10 is marked 'Queued for deletion.' I see there is a 'connect' button. I think the user should have to click connect before things change. This should prevent downloading the image again if the source is accidentally changed.
4. Sometimes when the page loads its blank or I only see other images. Refreshing fixes this.
5. When I click connect with a custom stream I briefly(only shows for about a second) get an error message saying 'Select atleast one 14.04+ LTS release and architecture' even though I already have 16.04 installed.

Revision history for this message
Blake Rouse (blake-rouse) wrote :

> Looks really good so far. I was able to merge lp:~ltrager/maas/use_bootloaders
> into your branch to test it with the bootloaders.

Thanks for looking at it.

>
> Couple of things I noticed
> 1. The bootloaders still show up. In my branch I filter them with
> BootSourceCache.objects.filter(bootloader_type=None)

Ah thats much better. I cannot do that until your branches make it into trunk so I will leave them as so.

> 2. The other images section lists the images twice, onces with checkboxes,
> onces without. See http://i.imgur.com/EJOjhpg.png

This is correct. You select the images you want then save that selection and they will be imported. At the moment yours look wierd and is confusing since you see the bootloaders. With the change above it will make more since.

> 3. I started with MAAS using the V3 test stream. If I click on the maas.io
> source the boot source metadata is immediately reloaded. It looks like the
> region then tries to download the maas.io images(they're at an older version
> than mine). If I switch back to my custom stream 16.10 is marked 'Queued for
> deletion.' I see there is a 'connect' button. I think the user should have to
> click connect before things change. This should prevent downloading the image
> again if the source is accidentally changed.

I think I understand what you are describing and I have cleaned this up. It doesn't actually start downloading. You have to click save selection, for it to be saved.

> 4. Sometimes when the page loads its blank or I only see other images.
> Refreshing fixes this.

Yeah I have fixed this issue.

> 5. When I click connect with a custom stream I briefly(only shows for about a
> second) get an error message saying 'Select atleast one 14.04+ LTS release and
> architecture' even though I already have 16.04 installed.

What you have installed doesn't matter since your on a new stream. You have to make selections for that new source.

Revision history for this message
Andres Rodriguez (andreserl) wrote :

I haven't done a full reivew but just have a couple of comments inlien.

review: Abstain
Revision history for this message
Andres Rodriguez (andreserl) :
review: Needs Fixing
Revision history for this message
Blake Rouse (blake-rouse) :
Revision history for this message
Blake Rouse (blake-rouse) wrote :

Fixed and pushed.

Revision history for this message
Andres Rodriguez (andreserl) wrote :

I've done testing and filed a couple bugs that cannot be fixed right now but bugs are filed for them. Other than that it looks good to me.

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (31.6 KiB)

The attempt to merge lp:~blake-rouse/maas/new-images-page into lp:maas failed. Below is the output from the failed tests.

Get:1 http://security.ubuntu.com/ubuntu xenial-security InRelease [94.5 kB]
Hit:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Get:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease [95.7 kB]
Hit:4 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease
Get:5 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates/main amd64 Packages [385 kB]
Get:6 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates/universe amd64 Packages [326 kB]
Fetched 901 kB in 0s (2,077 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind avahi-utils bash bind9 bind9utils build-essential bzr bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common isc-dhcp-server libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libpq-dev make nodejs-legacy npm postgresql pxelinux python3-all python3-apt python3-attr python3-bson python3-convoy python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-netaddr python3-netifaces python3-novaclient python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-simplejson python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
Reading package lists...
Building dependency tree...
Reading state information...
archdetect-deb is already the newest version (1.117ubuntu2).
authbind is already the newest version (2.1.1+nmu1).
avahi-utils is already the newest version (0.6.32~rc+dfsg-1ubuntu2).
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubuntu3).
distro-info is already the newest version (0.14build1).
freeipmi-tools is already the newest version (1.4.11-1ubuntu1).
git is already the newest version (1:2.7.4-0ubuntu1).
libjs-angularjs is already the newest version (1.2.28-1ubuntu2).
libjs-jquery is already the newest version (1.11.3+dfsg-4).
libjs-yui3-full is already the newest version (3.5.1-1ubuntu3).
libjs-yui3-min is already the newest version (3.5.1-1ubuntu3).
make is already the newest version (4.1-6).
postgresql is already the newest version (9.5+173).
...

Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (1.5 MiB)

The attempt to merge lp:~blake-rouse/maas/new-images-page into lp:maas failed. Below is the output from the failed tests.

Hit:1 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Hit:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease
Hit:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease
Get:4 http://security.ubuntu.com/ubuntu xenial-security InRelease [94.5 kB]
Fetched 94.5 kB in 0s (208 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind avahi-utils bash bind9 bind9utils build-essential bzr bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common isc-dhcp-server libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libpq-dev make nodejs-legacy npm postgresql pxelinux python3-all python3-apt python3-attr python3-bson python3-convoy python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-netaddr python3-netifaces python3-novaclient python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-simplejson python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
Reading package lists...
Building dependency tree...
Reading state information...
archdetect-deb is already the newest version (1.117ubuntu2).
authbind is already the newest version (2.1.1+nmu1).
avahi-utils is already the newest version (0.6.32~rc+dfsg-1ubuntu2).
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubuntu3).
distro-info is already the newest version (0.14build1).
freeipmi-tools is already the newest version (1.4.11-1ubuntu1).
git is already the newest version (1:2.7.4-0ubuntu1).
libjs-angularjs is already the newest version (1.2.28-1ubuntu2).
libjs-jquery is already the newest version (1.11.3+dfsg-4).
libjs-yui3-full is already the newest version (3.5.1-1ubuntu3).
libjs-yui3-min is already the newest version (3.5.1-1ubuntu3).
make is already the newest version (4.1-6).
postgresql is already the newest version (9.5+173).
pxelinux is already the newest version (3:6.03+dfsg-11ubuntu1).
python-formencode is already the newest version (1.3.0-0ubuntu5).
python-lxml is already the newest version (3.5.0-1build1).
python-netaddr is already the newest ver...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/bootresources.py'
2--- src/maasserver/bootresources.py 2016-08-31 06:26:21 +0000
3+++ src/maasserver/bootresources.py 2016-09-14 21:01:21 +0000
4@@ -102,11 +102,13 @@
5 from twisted.application.internet import TimerService
6 from twisted.internet import reactor
7 from twisted.internet.defer import (
8+ Deferred,
9 DeferredList,
10 inlineCallbacks,
11 )
12 from twisted.protocols.amp import UnhandledCommand
13 from twisted.python import log
14+from twisted.python.failure import Failure
15
16
17 maaslog = get_maas_logger("bootresources")
18@@ -799,11 +801,15 @@
19 "Deleting empty resource %s.", resource)
20 resource.delete()
21
22- def finalize(self):
23+ def finalize(self, notify=None):
24 """Perform the finalization of data into the database.
25
26 This will remove the un-needed `BootResource`'s and write the
27 file data into the large object store.
28+
29+ :param notify: Instance of `Deferred` that is called when all the
30+ metadata has been downloaded and the image data download has been
31+ started.
32 """
33 # XXX blake_r 2014-10-30 bug=1387133: A scenario can occur where insert
34 # never gets called by the writer, causing this method to delete all
35@@ -818,12 +824,20 @@
36 len(self._content_to_finalize))
37 if (self._resources_to_delete == self._init_resources_to_delete and
38 len(self._content_to_finalize) == 0):
39- maaslog.error(
40+ error_msg = (
41 "Finalization of imported images skipped, "
42- "or all %s synced images would be deleted.",
43- self._resources_to_delete)
44+ "or all %s synced images would be deleted." % (
45+ self._resources_to_delete))
46+ maaslog.error(error_msg)
47+ if notify is not None:
48+ failure = Failure(Exception(error_msg))
49+ reactor.callLater(0, notify.errback, failure)
50 return
51 self.resource_cleaner()
52+ # Callback the notify before starting the download of the actual data
53+ # for the images.
54+ if notify is not None:
55+ reactor.callLater(0, notify.callback, None)
56 self.perform_write()
57 self.resource_set_cleaner()
58
59@@ -905,7 +919,8 @@
60 writer.sync(reader, rpath)
61
62
63-def download_all_boot_resources(sources, product_mapping, store=None):
64+def download_all_boot_resources(
65+ sources, product_mapping, store=None, notify=None):
66 """Downloads all of the boot resources from the sources.
67
68 Boot resources are stored into the `BootResource` model.
69@@ -914,6 +929,8 @@
70 which we should download.
71 :param product_mapping: A `ProductMapping` describing the resources to be
72 downloaded.
73+ :param notify: Instance of `Deferred` that is called when all the metadata
74+ has been downloaded and the image data download has been started.
75 """
76 maaslog.debug("Initializing BootResourceStore.")
77 if store is None:
78@@ -925,7 +942,7 @@
79 source['url'], store, product_mapping,
80 keyring_file=source.get('keyring'))
81 maaslog.debug("Finalizing BootResourceStore.")
82- store.finalize()
83+ store.finalize(notify=notify)
84
85
86 def set_global_default_releases():
87@@ -959,19 +976,22 @@
88
89
90 @asynchronous(timeout=FOREVER)
91-def _import_resources():
92+def _import_resources(notify=None):
93 """Import boot resources.
94
95 Pulls the sources from `BootSource`. This only starts the process if
96 some SYNCED `BootResource` already exist.
97
98 This MUST be called from outside of a database transaction.
99+
100+ :param notify: Instance of `Deferred` that is called when all the metadata
101+ has been downloaded and the image data download has been started.
102 """
103 # Avoid circular import.
104 from maasserver.clusterrpc.boot_images import RackControllersImporter
105
106 # Sync boot resources into the region.
107- d = deferToDatabase(_import_resources_with_lock)
108+ d = deferToDatabase(_import_resources_with_lock, notify=notify)
109
110 def cb_import(_):
111 d = deferToDatabase(RackControllersImporter.new)
112@@ -988,7 +1008,7 @@
113 @synchronous
114 @with_connection
115 @synchronised(locks.import_images.TRY) # TRY is important; read docstring.
116-def _import_resources_with_lock():
117+def _import_resources_with_lock(notify=None):
118 """Import boot resources once the `import_images` lock is held.
119
120 This should *not* be called in a transaction; it will manage transactions
121@@ -999,11 +1019,19 @@
122 :raise DatabaseLockNotHeld: If the `import_images` lock cannot be acquired
123 at the beginning of this method. This happens quickly because the lock
124 is acquired using a TRY variant; see `dblocks`.
125+
126+ :param notify: Instance of `Deferred` that is called when all the metadata
127+ has been downloaded and the image data download has been started.
128 """
129 assert not in_transaction(), (
130 "_import_resources_with_lock() must not be called within a "
131 "preexisting transaction; it manages its own.")
132
133+ # Make sure that notify is a `Deferred` that has not beed called.
134+ if notify is not None:
135+ assert isinstance(notify, Deferred), "Notify should be a `Deferred`"
136+ assert not notify.called, "Notify should not have already been called."
137+
138 # Make sure that maas user's GNUPG home directory exists. This is
139 # needed for importing of boot resources, which occurs on the region
140 # as well as the clusters.
141@@ -1033,20 +1061,23 @@
142 return
143 product_mapping = map_products(image_descriptions)
144
145- download_all_boot_resources(sources, product_mapping)
146+ download_all_boot_resources(sources, product_mapping, notify=notify)
147 set_global_default_releases()
148 maaslog.info(
149 "Finished importing of boot images from %d source(s).",
150 len(sources))
151
152
153-def _import_resources_in_thread():
154+def _import_resources_in_thread(notify=None):
155 """Import boot resources in a thread managed by Twisted.
156
157 Errors are logged. The returned `Deferred` will never errback so it's safe
158 to use in a `TimerService`, for example.
159+
160+ :param notify: Instance of `Deferred` that is called when all the metadata
161+ has been downloaded and the image data download has been started.
162 """
163- d = deferToDatabase(_import_resources)
164+ d = deferToDatabase(_import_resources, notify=notify)
165 d.addErrback(_handle_import_failures)
166 return d
167
168@@ -1062,14 +1093,17 @@
169 log.err(failure, "Importing boot resources failed.")
170
171
172-def import_resources():
173+def import_resources(notify=None):
174 """Starts the importing of boot resources.
175
176 Note: This function returns immediately. It only starts the process, it
177 doesn't wait for it to be finished, as it can take several minutes to
178 complete.
179+
180+ :param notify: Instance of `Deferred` that is called when all the metadata
181+ has been downloaded and the image data download has been started.
182 """
183- reactor.callFromThread(_import_resources_in_thread)
184+ reactor.callFromThread(_import_resources_in_thread, notify=notify)
185
186
187 def is_import_resources_running():
188
189=== added file 'src/maasserver/static/js/angular/controllers/images.js'
190--- src/maasserver/static/js/angular/controllers/images.js 1970-01-01 00:00:00 +0000
191+++ src/maasserver/static/js/angular/controllers/images.js 2016-09-14 21:01:21 +0000
192@@ -0,0 +1,11 @@
193+/* Copyright 2016 Canonical Ltd. This software is licensed under the
194+ * GNU Affero General Public License version 3 (see the file LICENSE).
195+ *
196+ * MAAS Images Controller
197+ */
198+
199+angular.module('MAAS').controller('ImagesController', [
200+ '$rootScope', function($rootScope) {
201+ $rootScope.page = "images";
202+ $rootScope.title = "Boot Images";
203+ }]);
204
205=== added file 'src/maasserver/static/js/angular/directives/boot_images.js'
206--- src/maasserver/static/js/angular/directives/boot_images.js 1970-01-01 00:00:00 +0000
207+++ src/maasserver/static/js/angular/directives/boot_images.js 2016-09-14 21:01:21 +0000
208@@ -0,0 +1,744 @@
209+/* Copyright 2016 Canonical Ltd. This software is licensed under the
210+ * GNU Affero General Public License version 3 (see the file LICENSE).
211+ *
212+ * Boot images directive.
213+*/
214+
215+angular.module('MAAS').directive('maasBootImagesStatus', [
216+ 'BootResourcesManager',
217+ function(BootResourcesManager) {
218+ return {
219+ restrict: "E",
220+ scope: {},
221+ template: [
222+ '<p class="page-header__status" ',
223+ 'data-ng-if="data.region_import_running">',
224+ '<span class="u-text--loading u-margin--left-small">',
225+ '<i class="icon icon--loading u-animation--spin ',
226+ 'u-margin--right-tiny"></i>',
227+ 'Step 1/2: Region controller importing',
228+ '</span>',
229+ '</p>',
230+ '<p class="page-header__status" ',
231+ 'data-ng-if="data.rack_import_running">',
232+ '<span class="u-text--loading u-margin--left-small">',
233+ '<i class="icon icon--loading u-animation--spin ',
234+ 'u-margin--right-tiny"></i>',
235+ 'Step 2/2: Rack controller(s) importing',
236+ '</span>',
237+ '</p>'
238+ ].join(''),
239+ controller: function($scope, $rootScope, $element, $document) {
240+ // This controller doesn't start the polling. The
241+ // `maasBootImages` controller should be used on the page to
242+ // start the polling. This just presents a nice status spinner
243+ // when the polling is enabled.
244+ $scope.data = BootResourcesManager.getData();
245+ }
246+ };
247+ }]);
248+
249+angular.module('MAAS').directive('maasBootImages', [
250+ '$filter', 'BootResourcesManager', 'UsersManager', 'ManagerHelperService',
251+ function($filter, BootResourcesManager,
252+ UsersManager, ManagerHelperService) {
253+ return {
254+ restrict: "E",
255+ scope: {
256+ design: "=?"
257+ },
258+ templateUrl: (
259+ 'static/partials/boot-images.html?v=' + (
260+ MAAS_config.files_version)),
261+ controller: function($scope, $rootScope, $element, $document) {
262+ $scope.loading = true;
263+ $scope.saving = false;
264+ $scope.design = $scope.design || 'page';
265+ $scope.bootResources = BootResourcesManager.getData();
266+ $scope.ubuntuImages = [];
267+ $scope.source = {
268+ isNew: false,
269+ tooMany: false,
270+ showingAdvanced: false,
271+ connecting: false,
272+ errorMessage: "",
273+ source_type: 'maas.io',
274+ url: '',
275+ keyring_filename: '',
276+ keyring_data: '',
277+ releases: [],
278+ arches: [],
279+ selections: {
280+ changed: false,
281+ releases: [],
282+ arches: []
283+ }
284+ };
285+ $scope.otherImages = [];
286+ $scope.other = {
287+ changed: false,
288+ images: []
289+ };
290+ $scope.generatedImages = [];
291+ $scope.customImages = [];
292+
293+ // Return true if the authenticated user is super user.
294+ $scope.isSuperUser = function() {
295+ return UsersManager.isSuperUser();
296+ };
297+
298+ // Return the overall title icon.
299+ $scope.getTitleIcon = function() {
300+ if($scope.bootResources.resources.length === 0) {
301+ return 'icon--error';
302+ } else {
303+ return 'icon--success';
304+ }
305+ };
306+
307+ // Return true if the mirror path section should be shown.
308+ $scope.showMirrorPath = function() {
309+ if($scope.source.source_type === 'custom') {
310+ return true;
311+ } else {
312+ return false;
313+ }
314+ };
315+
316+ // Return true if the advanced options are shown.
317+ $scope.isShowingAdvancedOptions = function() {
318+ return $scope.source.showingAdvanced;
319+ };
320+
321+ // Toggle showing the advanced options.
322+ $scope.toggleAdvancedOptions = function() {
323+ $scope.source.showingAdvanced = (
324+ !$scope.source.showingAdvanced);
325+ };
326+
327+ // Return true if both keyring options are set.
328+ $scope.bothKeyringOptionsSet = function() {
329+ return (
330+ $scope.source.keyring_filename.length > 0 &&
331+ $scope.source.keyring_data.length > 0);
332+ };
333+
334+ // Return true when the connect button for the mirror path
335+ // should be shown.
336+ $scope.showConnectButton = function() {
337+ return $scope.source.isNew;
338+ };
339+
340+ // Called when the source radio chanaged.
341+ $scope.sourceChanged = function() {
342+ var currentSources = $scope.bootResources.ubuntu.sources;
343+ if(currentSources.length === 0) {
344+ $scope.source.isNew = true;
345+ } else {
346+ var prevNew = $scope.source.isNew;
347+ $scope.source.isNew = (
348+ $scope.source.source_type !==
349+ currentSources[0].source_type);
350+ if($scope.source.source_type === 'custom') {
351+ $scope.source.isNew = $scope.source.isNew || (
352+ $scope.source.url !==
353+ currentSources[0].url
354+ );
355+ }
356+ if(prevNew && !$scope.source.isNew) {
357+ // No longer a new source set url and keyring to
358+ // original.
359+ $scope.source.url = currentSources[0].url;
360+ $scope.source.keyring_filename = (
361+ currentSources[0].keyring_filename);
362+ $scope.source.keyring_data = (
363+ currentSources[0].keyring_data);
364+ }
365+ $scope.source.releases = [];
366+ $scope.source.arches = [];
367+ $scope.source.selections = {
368+ changed: false,
369+ releases: [],
370+ arches: []
371+ };
372+ }
373+ $scope.updateSource();
374+ $scope.regenerateUbuntuImages();
375+
376+ // When the source is new and its maas.io automatically
377+ // fetch the source information.
378+ if($scope.source.isNew &&
379+ $scope.source.source_type === 'maas.io') {
380+ $scope.connect();
381+ }
382+ };
383+
384+ // Return true when the connect button should be disabled.
385+ $scope.isConnectButtonDisabled = function() {
386+ if($scope.source.source_type === 'maas.io') {
387+ return false;
388+ } else {
389+ return ($scope.bothKeyringOptionsSet() ||
390+ $scope.source.url.length === 0 ||
391+ $scope.source.connecting);
392+ }
393+ };
394+
395+ // Return the source parameters.
396+ $scope.getSourceParams = function() {
397+ if($scope.source.source_type === 'maas.io') {
398+ return {
399+ source_type: 'maas.io'
400+ };
401+ } else {
402+ return {
403+ source_type: $scope.source.source_type,
404+ url: $scope.source.url,
405+ keyring_filename: $scope.source.keyring_filename,
406+ keyring_data: $scope.source.keyring_data
407+ };
408+ }
409+ };
410+
411+ // Select the default images that should be selected. Current
412+ // defaults are '16.04 LTS' and 'amd64'.
413+ $scope.selectDefaults = function() {
414+ angular.forEach($scope.source.releases, function(release) {
415+ if(release.name === "xenial") {
416+ $scope.source.selections.releases.push(release);
417+ }
418+ });
419+ angular.forEach($scope.source.arches, function(arch) {
420+ if(arch.name === "amd64") {
421+ $scope.source.selections.arches.push(arch);
422+ }
423+ });
424+ };
425+
426+ // Connected to the simplestreams endpoint. This only gets the
427+ // information from the endpoint it does not save it into the
428+ // database.
429+ $scope.connect = function() {
430+ if($scope.isConnectButtonDisabled()) {
431+ return;
432+ }
433+
434+ var source = $scope.getSourceParams();
435+ $scope.source.connecting = true;
436+ $scope.source.releases = [];
437+ $scope.source.arches = [];
438+ $scope.source.selections.changed = true;
439+ $scope.source.selections.releases = [];
440+ $scope.source.selections.arches = [];
441+ $scope.regenerateUbuntuImages();
442+ BootResourcesManager.fetch(source).then(function(data) {
443+ $scope.source.connecting = false;
444+ data = angular.fromJson(data);
445+ $scope.source.releases = data.releases;
446+ $scope.source.arches = data.arches;
447+ $scope.selectDefaults();
448+ $scope.regenerateUbuntuImages();
449+ }, function(error) {
450+ $scope.source.connecting = false;
451+ $scope.source.errorMessage = error;
452+ });
453+ };
454+
455+ // Return true if the connect block should be shown.
456+ $scope.showConnectBlock = function() {
457+ return $scope.source.tooMany || (
458+ $scope.source.isNew && !$scope.showSelections());
459+ };
460+
461+ // Return true if the release and architecture selection
462+ // should be shown.
463+ $scope.showSelections = function() {
464+ return (
465+ $scope.source.releases.length > 0 &&
466+ $scope.source.arches.length > 0);
467+ };
468+
469+ // Return the Ubuntu LTS releases.
470+ $scope.getUbuntuLTSReleases = function() {
471+ var releases = $scope.bootResources.ubuntu.releases;
472+ if($scope.source.isNew) {
473+ releases = $scope.source.releases;
474+ }
475+ var filtered = [];
476+ angular.forEach(releases, function(release) {
477+ if(release.title.indexOf('LTS') !== -1) {
478+ filtered.push(release);
479+ }
480+ });
481+ return filtered;
482+ };
483+
484+ // Return the Ubuntu non-LTS releases.
485+ $scope.getUbuntuNonLTSReleases = function() {
486+ var releases = $scope.bootResources.ubuntu.releases;
487+ if($scope.source.isNew) {
488+ releases = $scope.source.releases;
489+ }
490+ var filtered = [];
491+ angular.forEach(releases, function(release) {
492+ if(release.title.indexOf('LTS') === -1) {
493+ filtered.push(release);
494+ }
495+ });
496+ return filtered;
497+ };
498+
499+ // Return the available architectures.
500+ $scope.getArchitectures = function() {
501+ var arches = $scope.bootResources.ubuntu.arches;
502+ if($scope.source.isNew) {
503+ arches = $scope.source.arches;
504+ }
505+ return arches;
506+ };
507+
508+ // Return true if the source has this selected.
509+ $scope.isSelected = function(type, obj) {
510+ return $scope.source.selections[type].indexOf(obj) >= 0;
511+ };
512+
513+ // Toggle the selection of the release or the architecture.
514+ $scope.toggleSelection = function(type, obj) {
515+ var idx = $scope.source.selections[type].indexOf(obj);
516+ if(idx === -1) {
517+ $scope.source.selections[type].push(obj);
518+ } else {
519+ $scope.source.selections[type].splice(idx, 1);
520+ }
521+ $scope.source.selections.changed = true;
522+ $scope.regenerateUbuntuImages();
523+ };
524+
525+ // Return true if the images table should be shown.
526+ $scope.showImagesTable = function() {
527+ if($scope.ubuntuImages.length > 0) {
528+ return true;
529+ } else {
530+ // Show the images table source has
531+ // releases and architectures.
532+ return (
533+ $scope.source.arches.length > 0 &&
534+ $scope.source.releases.length > 0);
535+ }
536+ };
537+
538+ // Regenerates the Ubuntu images list for the directive.
539+ $scope.regenerateUbuntuImages = function() {
540+ var getResource = function() { return null; };
541+ var resources = $scope.bootResources.resources.filter(
542+ function(resource) {
543+ var name_split = resource.name.split('/');
544+ var resource_os = name_split[0];
545+ return (
546+ resource.rtype === 0 &&
547+ resource_os === 'ubuntu');
548+ });
549+ if(!$scope.source.isNew) {
550+ getResource = function(release, arch) {
551+ var i;
552+ for(i = 0; i < resources.length; i++) {
553+ // Only care about Ubuntu images.
554+ var resource = (resources[i]);
555+ var name_split = resource.name.split('/');
556+ var resource_release = name_split[1];
557+ if(resource_release === release &&
558+ resource.arch === arch) {
559+ resources.splice(i, 1);
560+ return resource;
561+ }
562+ }
563+ return null;
564+ };
565+ }
566+
567+ // Create the images based on the selections.
568+ $scope.ubuntuImages.length = 0;
569+ angular.forEach($scope.source.selections.releases,
570+ function(release) {
571+ angular.forEach($scope.source.selections.arches,
572+ function(arch) {
573+ var image = {
574+ icon: 'icon--status-queued',
575+ title: release.title,
576+ arch: arch.title,
577+ size: '-',
578+ status: 'Queued for download',
579+ beingDeleted: false
580+ };
581+ var resource = getResource(
582+ release.name, arch.name);
583+ if(angular.isObject(resource)) {
584+ image.icon = (
585+ 'icon--status-' + resource.icon);
586+ image.size = resource.size;
587+ image.status = resource.status;
588+ if(resource.downloading) {
589+ image.icon += ' u-animation--pulse';
590+ }
591+ }
592+ $scope.ubuntuImages.push(image);
593+ });
594+ });
595+
596+ // If not a new source and images remain in resources, then
597+ // those are set to be deleted.
598+ if(!$scope.source.isNew) {
599+ angular.forEach(resources, function(resource) {
600+ var image = {
601+ icon: 'icon--status-failed',
602+ title: resource.title,
603+ arch: resource.arch,
604+ size: resource.size,
605+ status: 'Queued for deletion',
606+ beingDeleted: true
607+ };
608+ $scope.ubuntuImages.push(image);
609+ });
610+ }
611+ };
612+
613+ // Regenerates the other images list for the directive.
614+ $scope.regenerateOtherImages = function() {
615+ var isOther = function(resource) {
616+ var name_split = resource.name.split('/');
617+ var resource_os = name_split[0];
618+ return (
619+ resource.rtype === 0 &&
620+ resource_os !== 'ubuntu' &&
621+ resource_os !== 'custom');
622+ };
623+ var resources = (
624+ $scope.bootResources.resources.filter(isOther));
625+ var getResource = function(release, arch) {
626+ var i;
627+ for(i = 0; i < resources.length; i++) {
628+ // Only care about other images. Removing custom,
629+ // bootloaders, and Ubuntu images.
630+ var resource = (resources[i]);
631+ var name_split = resource.name.split('/');
632+ var resource_release = name_split[1];
633+ if(resource_release === release &&
634+ resource.arch === arch) {
635+ resources.splice(i, 1);
636+ return resource;
637+ }
638+ }
639+ return null;
640+ };
641+
642+ // Create the images based on the selections.
643+ $scope.otherImages.length = 0;
644+ angular.forEach($scope.other.images,
645+ function(otherImage) {
646+ if(otherImage.checked) {
647+ var name_split = otherImage.name.split('/');
648+ var image = {
649+ icon: 'icon--status-queued',
650+ title: otherImage.title,
651+ arch: name_split[1],
652+ size: '-',
653+ status: 'Queued for download',
654+ beingDeleted: false
655+ };
656+ var resource = getResource(
657+ name_split[3], name_split[1]);
658+ if(angular.isObject(resource)) {
659+ image.icon = (
660+ 'icon--status-' + resource.icon);
661+ image.size = resource.size;
662+ image.status = resource.status;
663+ if(resource.downloading) {
664+ image.icon += ' u-animation--pulse';
665+ }
666+ }
667+ $scope.otherImages.push(image);
668+ }
669+ });
670+
671+ // If not a new source and images remain in resources, then
672+ // those are set to be deleted.
673+ angular.forEach(resources, function(resource) {
674+ var image = {
675+ icon: 'icon--status-failed',
676+ title: resource.title,
677+ arch: resource.arch,
678+ size: resource.size,
679+ status: 'Queued for deletion',
680+ beingDeleted: true
681+ };
682+ $scope.otherImages.push(image);
683+ });
684+ };
685+
686+ // Helper for basic image generation.
687+ $scope._regenerateImages = function(rtype, images) {
688+ // Create the generated images list.
689+ images.length = 0;
690+ angular.forEach($scope.bootResources.resources,
691+ function(resource) {
692+ if(resource.rtype === rtype) {
693+ var image = {
694+ icon: 'icon--status-' + resource.icon,
695+ title: resource.title,
696+ arch: resource.arch,
697+ size: resource.size,
698+ status: resource.status
699+ };
700+ if(resource.downloading) {
701+ image.icon += ' u-animation--pulse';
702+ }
703+ images.push(image);
704+ }
705+ });
706+ };
707+
708+ // Regenerates the generated images list for the directive.
709+ $scope.regenerateGeneratedImages = function() {
710+ $scope._regenerateImages(1, $scope.generatedImages);
711+ };
712+
713+ // Regenerates the custom images list for the directive.
714+ $scope.regenerateCustomImages = function() {
715+ $scope._regenerateImages(2, $scope.customImages);
716+ };
717+
718+ // Returns true if at least one LTS is selected.
719+ $scope.ltsIsSelected = function() {
720+ var i;
721+ for(i = 0; i < $scope.ubuntuImages.length; i++) {
722+ // Must have LTS in the title and not being deleted.
723+ if(!$scope.ubuntuImages[i].beingDeleted &&
724+ $scope.ubuntuImages[i].title.indexOf('LTS') >= 0) {
725+ // Must be greater than Ubuntu series 14.
726+ var series = parseInt(
727+ $scope.ubuntuImages[i].title.split('.')[0], 10);
728+ if(series >= 14) {
729+ return true;
730+ }
731+ }
732+ }
733+ return false;
734+ };
735+
736+ // Return true if the stop import button should be shown.
737+ $scope.showStopImportButton = function() {
738+ return $scope.bootResources.region_import_running;
739+ };
740+
741+ // Return true if should show save selection button, this
742+ // doesn't mean it can actually be clicked.
743+ $scope.showSaveSelection = function() {
744+ return $scope.showImagesTable();
745+ };
746+
747+ // Return true if can save the current selection.
748+ $scope.canSaveSelection = function() {
749+ return !$scope.saving && $scope.ltsIsSelected();
750+ };
751+
752+ // Return the text for the save selection button.
753+ $scope.getSaveSelectionText = function() {
754+ if($scope.saving) {
755+ return "Saving...";
756+ } else {
757+ return "Save selection";
758+ }
759+ };
760+
761+ // Save the selections into boot selections.
762+ $scope.saveSelection = function() {
763+ if(!$scope.canSaveSelection()) {
764+ return;
765+ }
766+
767+ var params = $scope.getSourceParams();
768+ params.releases = (
769+ $scope.source.selections.releases.map(function(obj) {
770+ return obj.name;
771+ }));
772+ params.arches = (
773+ $scope.source.selections.arches.map(function(obj) {
774+ return obj.name;
775+ }));
776+ $scope.saving = true;
777+ BootResourcesManager.saveUbuntu(params).then(function() {
778+ $scope.saving = false;
779+ $scope.source.isNew = false;
780+ $scope.source.selections.changed = false;
781+ $scope.updateSource();
782+ });
783+ };
784+
785+ // Re-create an array with the new objects using the old
786+ // selected array.
787+ $scope.getNewSelections = function(newObjs, oldSelections) {
788+ var newSelections = [];
789+ angular.forEach(newObjs, function(obj) {
790+ angular.forEach(oldSelections, function(selection) {
791+ if(obj.name === selection.name) {
792+ newSelections.push(obj);
793+ }
794+ });
795+ });
796+ return newSelections;
797+ };
798+
799+ // Update the source information.
800+ $scope.updateSource = function() {
801+ // Do not update if the source is new.
802+ if($scope.source.isNew) {
803+ return;
804+ }
805+
806+ var source_len = $scope.bootResources.ubuntu.sources.length;
807+ if(source_len === 0) {
808+ $scope.source.isNew = true;
809+ $scope.source.source_type = 'custom';
810+ $scope.source.errorMessage = (
811+ "Currently no source exists.");
812+ } else if(source_len === 1) {
813+ var source = $scope.bootResources.ubuntu.sources[0];
814+ $scope.source.source_type = source.source_type;
815+ if(source.source_type === "maas.io") {
816+ $scope.source.url = "";
817+ $scope.source.keyring_filename = "";
818+ $scope.source.keyring_data = "";
819+ } else {
820+ $scope.source.url = source.url;
821+ $scope.source.keyring_filename = (
822+ source.keyring_filename);
823+ $scope.source.keyring_data = source.keyring_data;
824+ }
825+ $scope.source.releases = (
826+ $scope.bootResources.ubuntu.releases);
827+ $scope.source.arches = (
828+ $scope.bootResources.ubuntu.arches);
829+ if(!$scope.source.selections.changed) {
830+ // User didn't make a change update to the
831+ // current selections server side.
832+ $scope.source.selections.releases = (
833+ $scope.source.releases.filter(function(obj) {
834+ return obj.checked;
835+ }));
836+ $scope.source.selections.arches = (
837+ $scope.source.arches.filter(function(obj) {
838+ return obj.checked;
839+ }));
840+ } else {
841+ // Update the objects to be the new objects, with
842+ // the same selections.
843+ $scope.source.selections.releases = (
844+ $scope.getNewSelections(
845+ $scope.source.releases,
846+ $scope.source.selections.releases));
847+ $scope.source.selections.arches = (
848+ $scope.getNewSelections(
849+ $scope.source.arches,
850+ $scope.source.selections.arches));
851+ }
852+ $scope.regenerateUbuntuImages();
853+ } else {
854+ // Having more than one source prevents modification
855+ // of the sources.
856+ $scope.source.tooMany = true;
857+ $scope.source.releases = (
858+ $scope.bootResources.ubuntu.releases);
859+ $scope.source.arches = (
860+ $scope.bootResources.ubuntu.arches);
861+ $scope.source.selections.releases = (
862+ $scope.source.releases.filter(function(obj) {
863+ return obj.checked;
864+ }));
865+ $scope.source.selections.arches = (
866+ $scope.source.arches.filter(function(obj) {
867+ return obj.checked;
868+ }));
869+ $scope.source.errorMessage = (
870+ "More than one image source exists. UI does not " +
871+ "support modification of sources when more than " +
872+ "one has been defined. Used the API to adjust " +
873+ "your sources.");
874+ $scope.regenerateUbuntuImages();
875+ }
876+ };
877+
878+ // Toggle the selection of other images.
879+ $scope.toggleOtherSelection = function(image) {
880+ $scope.other.changed = true;
881+ image.checked = !image.checked;
882+ $scope.regenerateOtherImages();
883+ };
884+
885+ // Save the other image selections into boot selections.
886+ $scope.saveOtherSelection = function() {
887+ var params = {
888+ images: $scope.other.images.filter(function(image) {
889+ return image.checked;
890+ }).map(function(image) {
891+ return image.name;
892+ })
893+ };
894+ $scope.saving = true;
895+ BootResourcesManager.saveOther(params).then(function() {
896+ $scope.saving = false;
897+ });
898+ };
899+
900+ // Start polling now that the directive is viewable and ensure
901+ // the UserManager is loaded.
902+ var ready = 2;
903+ BootResourcesManager.startPolling().then(function() {
904+ ready -= 1;
905+ if(ready === 0) {
906+ $scope.loading = false;
907+ }
908+ });
909+ ManagerHelperService.loadManager(UsersManager).then(function() {
910+ ready -= 1;
911+ if(ready === 0) {
912+ $scope.loading = false;
913+ }
914+ });
915+
916+ // Update the source information with the ubuntu source
917+ // information changes.
918+ $scope.$watch("bootResources.ubuntu", function() {
919+ if(!angular.isObject($scope.bootResources.ubuntu)) {
920+ return;
921+ }
922+ $scope.updateSource();
923+ });
924+
925+ // Regenerate the images array when the resources change.
926+ $scope.$watch("bootResources.resources", function() {
927+ if(!angular.isArray($scope.bootResources.resources)) {
928+ return;
929+ }
930+ $scope.regenerateUbuntuImages();
931+ $scope.regenerateOtherImages();
932+ $scope.regenerateGeneratedImages();
933+ $scope.regenerateCustomImages();
934+ });
935+
936+ $scope.$watch("bootResources.other_images", function() {
937+ if(!angular.isArray($scope.bootResources.other_images)) {
938+ return;
939+ }
940+ if(!$scope.other.changed) {
941+ $scope.other.images = $scope.bootResources.other_images;
942+ }
943+ $scope.regenerateOtherImages();
944+ });
945+
946+ // Stop polling when the directive is destroyed.
947+ $scope.$on('$destroy', function() {
948+ BootResourcesManager.stopPolling();
949+ });
950+ }
951+ };
952+ }]);
953
954=== added file 'src/maasserver/static/js/angular/directives/tests/test_boot_images.js'
955--- src/maasserver/static/js/angular/directives/tests/test_boot_images.js 1970-01-01 00:00:00 +0000
956+++ src/maasserver/static/js/angular/directives/tests/test_boot_images.js 2016-09-14 21:01:21 +0000
957@@ -0,0 +1,1454 @@
958+/* Copyright 2016 Canonical Ltd. This software is licensed under the
959+ * GNU Affero General Public License version 3 (see the file LICENSE).
960+ *
961+ * Unit tests for boot images directive.
962+ */
963+
964+describe("maasBootImages", function() {
965+
966+ // Load the MAAS module.
967+ beforeEach(module("MAAS"));
968+
969+ // Preload the $templateCache with empty contents. We only test the
970+ // controller of the directive, not the template.
971+ var $q, $templateCache;
972+ beforeEach(inject(function($injector) {
973+ $q = $injector.get('$q');
974+ $templateCache = $injector.get('$templateCache');
975+ $templateCache.put("static/partials/boot-images.html?v=undefined", '');
976+ }));
977+
978+ // Load the required managers.
979+ var BootResourcesManager, UsersManager, ManagerHelperService;
980+ beforeEach(inject(function($injector) {
981+ BootResourcesManager = $injector.get('BootResourcesManager');
982+ UsersManager = $injector.get('UsersManager');
983+ ManagerHelperService = $injector.get('ManagerHelperService');
984+ }));
985+
986+ // Create a new scope before each test.
987+ var $scope;
988+ beforeEach(inject(function($rootScope) {
989+ $scope = $rootScope.$new();
990+ }));
991+
992+ // Return the compiled directive with the items from the scope.
993+ function compileDirective(design) {
994+ if(angular.isUndefined(design)) {
995+ design = "";
996+ }
997+ var directive;
998+ var html = [
999+ '<div>',
1000+ '<maas-boot-images design="' + design + '"></maas-boot-images>',
1001+ '</div>'
1002+ ].join('');
1003+
1004+ // Compile the directive.
1005+ inject(function($compile) {
1006+ directive = $compile(html)($scope);
1007+ });
1008+
1009+ // Perform the digest cycle to finish the compile.
1010+ $scope.$digest();
1011+ return directive.find("maas-boot-images");
1012+ }
1013+
1014+ it("sets initial variables", function() {
1015+ var directive = compileDirective();
1016+ var scope = directive.isolateScope();
1017+ expect(scope.loading).toBe(true);
1018+ expect(scope.saving).toBe(false);
1019+ expect(scope.design).toBe('page');
1020+ expect(scope.bootResources).toBe(BootResourcesManager.getData());
1021+ expect(scope.ubuntuImages).toEqual([]);
1022+ expect(scope.source).toEqual({
1023+ isNew: false,
1024+ tooMany: false,
1025+ showingAdvanced: false,
1026+ connecting: false,
1027+ errorMessage: "",
1028+ source_type: "maas.io",
1029+ url: '',
1030+ keyring_filename: '',
1031+ keyring_data: '',
1032+ releases: [],
1033+ arches: [],
1034+ selections: {
1035+ changed: false,
1036+ releases: [],
1037+ arches: []
1038+ }
1039+ });
1040+ expect(scope.otherImages).toEqual([]);
1041+ expect(scope.other).toEqual({
1042+ changed: false,
1043+ images: []
1044+ });
1045+ expect(scope.generatedImages).toEqual([]);
1046+ expect(scope.customImages).toEqual([]);
1047+ });
1048+
1049+ it("clears loading once polling and user manager loaded", function() {
1050+ var pollingDefer = $q.defer();
1051+ spyOn(BootResourcesManager, "startPolling").and.returnValue(
1052+ pollingDefer.promise);
1053+ var managerDefer = $q.defer();
1054+ spyOn(ManagerHelperService, "loadManager").and.returnValue(
1055+ managerDefer.promise);
1056+
1057+ var directive = compileDirective();
1058+ var scope = directive.isolateScope();
1059+
1060+ pollingDefer.resolve();
1061+ $scope.$digest();
1062+ managerDefer.resolve();
1063+ $scope.$digest();
1064+ expect(scope.loading).toBe(false);
1065+ });
1066+
1067+ it("calls updateSource when ubuntu changes", function() {
1068+ var directive = compileDirective();
1069+ var scope = directive.isolateScope();
1070+ spyOn(scope, "updateSource");
1071+ scope.bootResources.ubuntu = {};
1072+ $scope.$digest();
1073+
1074+ expect(scope.updateSource).toHaveBeenCalled();
1075+ });
1076+
1077+ it("calls all regenerates when resources changes", function() {
1078+ var directive = compileDirective();
1079+ var scope = directive.isolateScope();
1080+ spyOn(scope, "regenerateUbuntuImages");
1081+ spyOn(scope, "regenerateOtherImages");
1082+ spyOn(scope, "regenerateGeneratedImages");
1083+ spyOn(scope, "regenerateCustomImages");
1084+ scope.bootResources.resources = [];
1085+ $scope.$digest();
1086+
1087+ expect(scope.regenerateUbuntuImages).toHaveBeenCalled();
1088+ expect(scope.regenerateOtherImages).toHaveBeenCalled();
1089+ expect(scope.regenerateGeneratedImages).toHaveBeenCalled();
1090+ expect(scope.regenerateCustomImages).toHaveBeenCalled();
1091+ });
1092+
1093+ it("sets other.images when other_images change", function() {
1094+ var directive = compileDirective();
1095+ var scope = directive.isolateScope();
1096+ spyOn(scope, "regenerateOtherImages");
1097+ var sentinel = [];
1098+ scope.bootResources.other_images = sentinel;
1099+ $scope.$digest();
1100+
1101+ expect(scope.other.images).toBe(sentinel);
1102+ expect(scope.regenerateOtherImages).toHaveBeenCalled();
1103+ });
1104+
1105+ it("doesnt sets other.images when other changed", function() {
1106+ var directive = compileDirective();
1107+ var scope = directive.isolateScope();
1108+ spyOn(scope, "regenerateOtherImages");
1109+ var sentinel = [];
1110+ scope.bootResources.other_images = sentinel;
1111+ scope.other.changed = true;
1112+ $scope.$digest();
1113+
1114+ expect(scope.other.images).not.toBe(sentinel);
1115+ expect(scope.regenerateOtherImages).toHaveBeenCalled();
1116+ });
1117+
1118+ it("stops polling when scope is destroyed", function() {
1119+ var directive = compileDirective();
1120+ spyOn(BootResourcesManager, "stopPolling");
1121+ directive.scope().$destroy();
1122+ expect(BootResourcesManager.stopPolling).toHaveBeenCalled();
1123+ });
1124+
1125+ describe("isSuperUser", function() {
1126+
1127+ it("returns UsersManager.isSuperUser", function() {
1128+ var directive = compileDirective();
1129+ var scope = directive.isolateScope();
1130+
1131+ var sentinel = {};
1132+ spyOn(UsersManager, "isSuperUser").and.returnValue(sentinel);
1133+ expect(scope.isSuperUser()).toBe(sentinel);
1134+ });
1135+ });
1136+
1137+ describe("getTitleIcon", function() {
1138+
1139+ it("returns error when no resources", function() {
1140+ var directive = compileDirective();
1141+ var scope = directive.isolateScope();
1142+ BootResourcesManager._data.resources = [];
1143+ expect(scope.getTitleIcon()).toBe('icon--error');
1144+ });
1145+
1146+ it("returns success when resources", function() {
1147+ var directive = compileDirective();
1148+ var scope = directive.isolateScope();
1149+ BootResourcesManager._data.resources = [{}];
1150+ expect(scope.getTitleIcon()).toBe('icon--success');
1151+ });
1152+ });
1153+
1154+ describe("showMirrorPath", function() {
1155+
1156+ it("returns true when custom", function() {
1157+ var directive = compileDirective();
1158+ var scope = directive.isolateScope();
1159+ scope.source.source_type = 'custom';
1160+ expect(scope.showMirrorPath()).toBe(true);
1161+ });
1162+
1163+ it("returns false when maas.io", function() {
1164+ var directive = compileDirective();
1165+ var scope = directive.isolateScope();
1166+ scope.source.source_type = 'maas.io';
1167+ expect(scope.showMirrorPath()).toBe(false);
1168+ });
1169+ });
1170+
1171+ describe("isShowingAdvancedOptions", function() {
1172+
1173+ it("returns showingAdvanced", function() {
1174+ var directive = compileDirective();
1175+ var scope = directive.isolateScope();
1176+ var sentinel = {};
1177+ scope.source.showingAdvanced = sentinel;
1178+ expect(scope.isShowingAdvancedOptions()).toBe(sentinel);
1179+ });
1180+ });
1181+
1182+ describe("toggleAdvancedOptions", function() {
1183+
1184+ it("inverts showingAdvanced", function() {
1185+ var directive = compileDirective();
1186+ var scope = directive.isolateScope();
1187+ scope.source.showingAdvanced = false;
1188+ scope.toggleAdvancedOptions();
1189+ expect(scope.source.showingAdvanced).toBe(true);
1190+ scope.toggleAdvancedOptions();
1191+ expect(scope.source.showingAdvanced).toBe(false);
1192+ });
1193+ });
1194+
1195+ describe("bothKeyringOptionsSet", function() {
1196+
1197+ it("returns false if neither set", function() {
1198+ var directive = compileDirective();
1199+ var scope = directive.isolateScope();
1200+ expect(scope.bothKeyringOptionsSet()).toBe(false);
1201+ });
1202+
1203+ it("returns false if keyring_filename set", function() {
1204+ var directive = compileDirective();
1205+ var scope = directive.isolateScope();
1206+ scope.source.keyring_filename = makeName("file");
1207+ expect(scope.bothKeyringOptionsSet()).toBe(false);
1208+ });
1209+
1210+ it("returns false if keyring_data set", function() {
1211+ var directive = compileDirective();
1212+ var scope = directive.isolateScope();
1213+ scope.source.keyring_data = makeName("data");
1214+ expect(scope.bothKeyringOptionsSet()).toBe(false);
1215+ });
1216+
1217+ it("returns true if both set set", function() {
1218+ var directive = compileDirective();
1219+ var scope = directive.isolateScope();
1220+ scope.source.keyring_filename = makeName("file");
1221+ scope.source.keyring_data = makeName("data");
1222+ expect(scope.bothKeyringOptionsSet()).toBe(true);
1223+ });
1224+ });
1225+
1226+ describe("showConnectButton", function() {
1227+
1228+ it("returns isNew", function() {
1229+ var directive = compileDirective();
1230+ var scope = directive.isolateScope();
1231+ var sentinel = {};
1232+ scope.source.isNew = sentinel;
1233+ expect(scope.showConnectButton()).toBe(sentinel);
1234+ });
1235+ });
1236+
1237+ describe("sourceChanged", function() {
1238+
1239+ it("sets isNew if no sources", function() {
1240+ var directive = compileDirective();
1241+ var scope = directive.isolateScope();
1242+ scope.bootResources = {
1243+ resources: [],
1244+ ubuntu: {
1245+ sources: []
1246+ }
1247+ };
1248+ spyOn(scope, "connect");
1249+ scope.sourceChanged();
1250+ expect(scope.source.isNew).toBe(true);
1251+ });
1252+
1253+ it("calls connect when no sources", function() {
1254+ var directive = compileDirective();
1255+ var scope = directive.isolateScope();
1256+ scope.bootResources = {
1257+ resources: [],
1258+ ubuntu: {
1259+ sources: []
1260+ }
1261+ };
1262+ spyOn(scope, "connect");
1263+ scope.sourceChanged();
1264+ expect(scope.connect).toHaveBeenCalled();
1265+ });
1266+
1267+ it("calls connect when no sources", function() {
1268+ var directive = compileDirective();
1269+ var scope = directive.isolateScope();
1270+ scope.bootResources = {
1271+ resources: [],
1272+ ubuntu: {
1273+ sources: []
1274+ }
1275+ };
1276+ spyOn(scope, "connect");
1277+ scope.sourceChanged();
1278+ expect(scope.connect).toHaveBeenCalled();
1279+ });
1280+
1281+ it("calls updateSource and regenerateUbuntuImages", function() {
1282+ var directive = compileDirective();
1283+ var scope = directive.isolateScope();
1284+ scope.bootResources = {
1285+ resources: [],
1286+ ubuntu: {
1287+ sources: []
1288+ }
1289+ };
1290+ spyOn(scope, "connect");
1291+ spyOn(scope, "updateSource");
1292+ spyOn(scope, "regenerateUbuntuImages");
1293+ scope.sourceChanged();
1294+ expect(scope.updateSource).toHaveBeenCalled();
1295+ expect(scope.regenerateUbuntuImages).toHaveBeenCalled();
1296+ });
1297+
1298+ it("changing to maas.io clears options and selections", function() {
1299+ var directive = compileDirective();
1300+ var scope = directive.isolateScope();
1301+ scope.bootResources = {
1302+ resources: [],
1303+ ubuntu: {
1304+ sources: [{
1305+ source_type: 'custom'
1306+ }]
1307+ }
1308+ };
1309+ scope.source.isNew = false;
1310+ scope.source.source_type = 'maas.io';
1311+ scope.source.releases = [{}, {}];
1312+ scope.source.arches = [{}, {}];
1313+ scope.source.selections = {
1314+ changed: true,
1315+ releases: [{}],
1316+ arches: [{}]
1317+ };
1318+ spyOn(scope, "connect");
1319+ scope.sourceChanged();
1320+ expect(scope.source.releases).toEqual([]);
1321+ expect(scope.source.arches).toEqual([]);
1322+ expect(scope.source.selections).toEqual({
1323+ changed: false,
1324+ releases: [],
1325+ arches: []
1326+ });
1327+ });
1328+
1329+ it("changing to custom clears options and selections", function() {
1330+ var directive = compileDirective();
1331+ var scope = directive.isolateScope();
1332+ scope.bootResources = {
1333+ resources: [],
1334+ ubuntu: {
1335+ sources: [{
1336+ source_type: 'maas.io'
1337+ }]
1338+ }
1339+ };
1340+ scope.source.isNew = false;
1341+ scope.source.source_type = 'custom';
1342+ scope.source.releases = [{}, {}];
1343+ scope.source.arches = [{}, {}];
1344+ scope.source.selections = {
1345+ changed: true,
1346+ releases: [{}],
1347+ arches: [{}]
1348+ };
1349+ spyOn(scope, "connect");
1350+ scope.sourceChanged();
1351+ expect(scope.source.releases).toEqual([]);
1352+ expect(scope.source.arches).toEqual([]);
1353+ expect(scope.source.selections).toEqual({
1354+ changed: false,
1355+ releases: [],
1356+ arches: []
1357+ });
1358+ });
1359+
1360+ it("changing to custom url clears options and selections", function() {
1361+ var directive = compileDirective();
1362+ var scope = directive.isolateScope();
1363+ scope.bootResources = {
1364+ resources: [],
1365+ ubuntu: {
1366+ sources: [{
1367+ source_type: 'custom',
1368+ url: ''
1369+ }]
1370+ }
1371+ };
1372+ scope.source.isNew = false;
1373+ scope.source.source_type = 'custom';
1374+ scope.source.url = makeName('url');
1375+ scope.source.releases = [{}, {}];
1376+ scope.source.arches = [{}, {}];
1377+ scope.source.selections = {
1378+ changed: true,
1379+ releases: [{}],
1380+ arches: [{}]
1381+ };
1382+ spyOn(scope, "connect");
1383+ scope.sourceChanged();
1384+ expect(scope.source.releases).toEqual([]);
1385+ expect(scope.source.arches).toEqual([]);
1386+ expect(scope.source.selections).toEqual({
1387+ changed: false,
1388+ releases: [],
1389+ arches: []
1390+ });
1391+ });
1392+ });
1393+
1394+ describe("isConnectButtonDisabled", function() {
1395+
1396+ it("never disabled when maas.io", function() {
1397+ var directive = compileDirective();
1398+ var scope = directive.isolateScope();
1399+ scope.source.source_type = 'maas.io';
1400+ expect(scope.isConnectButtonDisabled()).toBe(false);
1401+ });
1402+
1403+ it("disabled when custom and both keyrings are set", function() {
1404+ var directive = compileDirective();
1405+ var scope = directive.isolateScope();
1406+ scope.source.source_type = 'custom';
1407+ spyOn(scope, "bothKeyringOptionsSet").and.returnValue(true);
1408+ expect(scope.isConnectButtonDisabled()).toBe(true);
1409+ });
1410+
1411+ it("disabled when custom and no url", function() {
1412+ var directive = compileDirective();
1413+ var scope = directive.isolateScope();
1414+ scope.source.source_type = 'custom';
1415+ scope.source.url = "";
1416+ expect(scope.isConnectButtonDisabled()).toBe(true);
1417+ });
1418+
1419+ it("disabled when custom connecting", function() {
1420+ var directive = compileDirective();
1421+ var scope = directive.isolateScope();
1422+ scope.source.source_type = 'custom';
1423+ scope.source.url = makeName("url");
1424+ scope.source.connecting = true;
1425+ expect(scope.isConnectButtonDisabled()).toBe(true);
1426+ });
1427+
1428+ it("enabled when custom and not connecting", function() {
1429+ var directive = compileDirective();
1430+ var scope = directive.isolateScope();
1431+ scope.source.source_type = 'custom';
1432+ scope.source.url = makeName("url");
1433+ scope.source.connecting = false;
1434+ expect(scope.isConnectButtonDisabled()).toBe(false);
1435+ });
1436+ });
1437+
1438+ describe("getSourceParams", function() {
1439+
1440+ it("maas.io only returns source_type", function() {
1441+ var directive = compileDirective();
1442+ var scope = directive.isolateScope();
1443+ scope.source.source_type = 'maas.io';
1444+ expect(scope.getSourceParams()).toEqual({
1445+ source_type: 'maas.io'
1446+ });
1447+ });
1448+
1449+ it("custom returns all fields", function() {
1450+ var directive = compileDirective();
1451+ var scope = directive.isolateScope();
1452+ scope.source.source_type = 'custom';
1453+ scope.source.url = makeName("url");
1454+ scope.source.keyring_filename = makeName("keyring_filename");
1455+ scope.source.keyring_data = makeName("keyring_data");
1456+ expect(scope.getSourceParams()).toEqual({
1457+ source_type: 'custom',
1458+ url: scope.source.url,
1459+ keyring_filename: scope.source.keyring_filename,
1460+ keyring_data: scope.source.keyring_data
1461+ });
1462+ });
1463+ });
1464+
1465+ describe("selectDefaults", function() {
1466+
1467+ it("selects xenial and amd64", function() {
1468+ var directive = compileDirective();
1469+ var scope = directive.isolateScope();
1470+ var xenial = {
1471+ name: 'xenial'
1472+ };
1473+ var amd64 = {
1474+ name: 'amd64'
1475+ };
1476+ scope.source.releases = [xenial];
1477+ scope.source.arches = [amd64];
1478+ scope.selectDefaults();
1479+
1480+ expect(scope.source.selections.releases).toEqual([xenial]);
1481+ expect(scope.source.selections.arches).toEqual([amd64]);
1482+ });
1483+ });
1484+
1485+ describe("connect", function() {
1486+
1487+ it("does nothing if disabled", function() {
1488+ var directive = compileDirective();
1489+ var scope = directive.isolateScope();
1490+ spyOn(scope, "isConnectButtonDisabled").and.returnValue(true);
1491+ spyOn(BootResourcesManager, "fetch");
1492+ scope.connect();
1493+ expect(BootResourcesManager.fetch).not.toHaveBeenCalled();
1494+ });
1495+
1496+ it("toggles connecting and sets options and defaults", function() {
1497+ var directive = compileDirective();
1498+ var scope = directive.isolateScope();
1499+ spyOn(scope, "isConnectButtonDisabled").and.returnValue(false);
1500+
1501+ // Mock the fetch call.
1502+ var defer = $q.defer();
1503+ spyOn(BootResourcesManager, "fetch").and.returnValue(defer.promise);
1504+ spyOn(scope, "regenerateUbuntuImages");
1505+ scope.connect();
1506+ expect(scope.source.connecting).toBe(true);
1507+
1508+ // Call connect and fake the resolve with mock data.
1509+ spyOn(scope, "selectDefaults");
1510+ var release = makeName("release");
1511+ var arch = makeName("arch");
1512+ var data = angular.toJson({
1513+ releases: [{
1514+ name: release
1515+ }],
1516+ arches: [{
1517+ name: arch
1518+ }]
1519+ });
1520+ defer.resolve(data);
1521+ $scope.$digest();
1522+
1523+ expect(scope.source.connecting).toBe(false);
1524+ expect(scope.source.releases).toEqual([{
1525+ name: release
1526+ }]);
1527+ expect(scope.source.arches).toEqual([{
1528+ name: arch
1529+ }]);
1530+ expect(scope.selectDefaults).toHaveBeenCalled();
1531+ expect(scope.regenerateUbuntuImages).toHaveBeenCalled();
1532+ });
1533+
1534+ it("toggles connecting and sets error", function() {
1535+ var directive = compileDirective();
1536+ var scope = directive.isolateScope();
1537+ spyOn(scope, "isConnectButtonDisabled").and.returnValue(false);
1538+
1539+ // Mock the fetch call.
1540+ var defer = $q.defer();
1541+ spyOn(BootResourcesManager, "fetch").and.returnValue(defer.promise);
1542+ spyOn(scope, "regenerateUbuntuImages");
1543+ scope.connect();
1544+ expect(scope.source.connecting).toBe(true);
1545+
1546+ // Call connect and fake the reject.
1547+ var error = makeName("error");
1548+ defer.reject(error);
1549+ $scope.$digest();
1550+
1551+ expect(scope.source.connecting).toBe(false);
1552+ expect(scope.source.errorMessage).toBe(error);
1553+ });
1554+ });
1555+
1556+ describe("showConnectBlock", function() {
1557+
1558+ it("true if tooMany sources", function() {
1559+ var directive = compileDirective();
1560+ var scope = directive.isolateScope();
1561+ scope.source.tooMany = true;
1562+ expect(scope.showConnectBlock()).toBe(true);
1563+ });
1564+
1565+ it("true if new and not showing selections", function() {
1566+ var directive = compileDirective();
1567+ var scope = directive.isolateScope();
1568+ scope.source.isNew = true;
1569+ spyOn(scope, "showSelections").and.returnValue(false);
1570+ expect(scope.showConnectBlock()).toBe(true);
1571+ });
1572+
1573+ it("false if new and showing selections", function() {
1574+ var directive = compileDirective();
1575+ var scope = directive.isolateScope();
1576+ scope.source.isNew = true;
1577+ spyOn(scope, "showSelections").and.returnValue(true);
1578+ expect(scope.showConnectBlock()).toBe(false);
1579+ });
1580+
1581+ it("false if not new", function() {
1582+ var directive = compileDirective();
1583+ var scope = directive.isolateScope();
1584+ scope.source.isNew = false;
1585+ spyOn(scope, "showSelections").and.returnValue(false);
1586+ expect(scope.showConnectBlock()).toBe(false);
1587+ });
1588+ });
1589+
1590+ describe("showSelections", function() {
1591+
1592+ it("true releases and arches exist", function() {
1593+ var directive = compileDirective();
1594+ var scope = directive.isolateScope();
1595+ scope.source.releases = [{}];
1596+ scope.source.arches = [{}];
1597+ expect(scope.showSelections()).toBe(true);
1598+ });
1599+
1600+ it("false if only releases", function() {
1601+ var directive = compileDirective();
1602+ var scope = directive.isolateScope();
1603+ scope.source.releases = [{}];
1604+ scope.source.arches = [];
1605+ expect(scope.showSelections()).toBe(false);
1606+ });
1607+
1608+ it("false if only arches", function() {
1609+ var directive = compileDirective();
1610+ var scope = directive.isolateScope();
1611+ scope.source.releases = [];
1612+ scope.source.arches = [{}];
1613+ expect(scope.showSelections()).toBe(false);
1614+ });
1615+ });
1616+
1617+ describe("getUbuntuLTSReleases", function() {
1618+
1619+ it("filters bootResources to LTS", function() {
1620+ var directive = compileDirective();
1621+ var scope = directive.isolateScope();
1622+ var lts = {
1623+ title: '16.04 LTS'
1624+ };
1625+ var nonLTS = {
1626+ title: '16.10'
1627+ };
1628+ scope.bootResources = {
1629+ ubuntu: {
1630+ releases: [lts, nonLTS]
1631+ }
1632+ };
1633+ expect(scope.getUbuntuLTSReleases()).toEqual([lts]);
1634+ });
1635+
1636+ it("filters new sources to LTS", function() {
1637+ var directive = compileDirective();
1638+ var scope = directive.isolateScope();
1639+ var lts = {
1640+ title: '16.04 LTS'
1641+ };
1642+ var nonLTS = {
1643+ title: '16.10'
1644+ };
1645+ scope.bootResources = {
1646+ ubuntu: {
1647+ releases: []
1648+ }
1649+ };
1650+ scope.source.isNew = true;
1651+ scope.source.releases = [lts, nonLTS];
1652+ expect(scope.getUbuntuLTSReleases()).toEqual([lts]);
1653+ });
1654+ });
1655+
1656+ describe("getUbuntuNonLTSReleases", function() {
1657+
1658+ it("filters bootResources to non-LTS", function() {
1659+ var directive = compileDirective();
1660+ var scope = directive.isolateScope();
1661+ var lts = {
1662+ title: '16.04 LTS'
1663+ };
1664+ var nonLTS = {
1665+ title: '16.10'
1666+ };
1667+ scope.bootResources = {
1668+ ubuntu: {
1669+ releases: [lts, nonLTS]
1670+ }
1671+ };
1672+ expect(scope.getUbuntuNonLTSReleases()).toEqual([nonLTS]);
1673+ });
1674+
1675+ it("filters new sources to non-LTS", function() {
1676+ var directive = compileDirective();
1677+ var scope = directive.isolateScope();
1678+ var lts = {
1679+ title: '16.04 LTS'
1680+ };
1681+ var nonLTS = {
1682+ title: '16.10'
1683+ };
1684+ scope.bootResources = {
1685+ ubuntu: {
1686+ releases: []
1687+ }
1688+ };
1689+ scope.source.isNew = true;
1690+ scope.source.releases = [lts, nonLTS];
1691+ expect(scope.getUbuntuNonLTSReleases()).toEqual([nonLTS]);
1692+ });
1693+ });
1694+
1695+ describe("getArchitectures", function() {
1696+
1697+ it("returns bootResources arches", function() {
1698+ var directive = compileDirective();
1699+ var scope = directive.isolateScope();
1700+ var arch1 = {};
1701+ var arch2 = {};
1702+ scope.bootResources = {
1703+ ubuntu: {
1704+ arches: [arch1, arch2]
1705+ }
1706+ };
1707+ expect(scope.getArchitectures()).toEqual([arch1, arch2]);
1708+ });
1709+
1710+ it("returns new sources arches", function() {
1711+ var directive = compileDirective();
1712+ var scope = directive.isolateScope();
1713+ var arch1 = {};
1714+ var arch2 = {};
1715+ scope.bootResources = {
1716+ ubuntu: {
1717+ arches: []
1718+ }
1719+ };
1720+ scope.source.isNew = true;
1721+ scope.source.arches = [arch1, arch2];
1722+ expect(scope.getArchitectures()).toEqual([arch1, arch2]);
1723+ });
1724+ });
1725+
1726+ describe("isSelected", function() {
1727+
1728+ it("returns true if in release selections", function() {
1729+ var directive = compileDirective();
1730+ var scope = directive.isolateScope();
1731+ var obj = {};
1732+ scope.source.selections.releases = [obj];
1733+ expect(scope.isSelected('releases', obj)).toBe(true);
1734+ });
1735+
1736+ it("returns false if not in release selections", function() {
1737+ var directive = compileDirective();
1738+ var scope = directive.isolateScope();
1739+ var obj = {};
1740+ scope.source.selections.releases = [obj];
1741+ expect(scope.isSelected('releases', {})).toBe(false);
1742+ });
1743+
1744+ it("returns true if in arch selections", function() {
1745+ var directive = compileDirective();
1746+ var scope = directive.isolateScope();
1747+ var obj = {};
1748+ scope.source.selections.arches = [obj];
1749+ expect(scope.isSelected('arches', obj)).toBe(true);
1750+ });
1751+
1752+ it("returns false if not in arch selections", function() {
1753+ var directive = compileDirective();
1754+ var scope = directive.isolateScope();
1755+ var obj = {};
1756+ scope.source.selections.arches = [obj];
1757+ expect(scope.isSelected('arches', {})).toBe(false);
1758+ });
1759+ });
1760+
1761+ describe("toggleSelection", function() {
1762+
1763+ it("selects the obj for releases", function() {
1764+ var directive = compileDirective();
1765+ var scope = directive.isolateScope();
1766+ var obj = {};
1767+ spyOn(scope, "regenerateUbuntuImages");
1768+ scope.toggleSelection('releases', obj);
1769+ expect(scope.source.selections.changed).toBe(true);
1770+ expect(scope.source.selections.releases).toEqual([obj]);
1771+ expect(scope.regenerateUbuntuImages).toHaveBeenCalled();
1772+ });
1773+
1774+ it("deselects the obj for releases", function() {
1775+ var directive = compileDirective();
1776+ var scope = directive.isolateScope();
1777+ var obj = {};
1778+ scope.source.selections.releases = [obj];
1779+ spyOn(scope, "regenerateUbuntuImages");
1780+ scope.toggleSelection('releases', obj);
1781+ expect(scope.source.selections.changed).toBe(true);
1782+ expect(scope.source.selections.releases).toEqual([]);
1783+ expect(scope.regenerateUbuntuImages).toHaveBeenCalled();
1784+ });
1785+
1786+ it("selects the obj for arches", function() {
1787+ var directive = compileDirective();
1788+ var scope = directive.isolateScope();
1789+ var obj = {};
1790+ spyOn(scope, "regenerateUbuntuImages");
1791+ scope.toggleSelection('arches', obj);
1792+ expect(scope.source.selections.changed).toBe(true);
1793+ expect(scope.source.selections.arches).toEqual([obj]);
1794+ expect(scope.regenerateUbuntuImages).toHaveBeenCalled();
1795+ });
1796+
1797+ it("deselects the obj for arches", function() {
1798+ var directive = compileDirective();
1799+ var scope = directive.isolateScope();
1800+ var obj = {};
1801+ scope.source.selections.arches = [obj];
1802+ spyOn(scope, "regenerateUbuntuImages");
1803+ scope.toggleSelection('arches', obj);
1804+ expect(scope.source.selections.changed).toBe(true);
1805+ expect(scope.source.selections.arches).toEqual([]);
1806+ expect(scope.regenerateUbuntuImages).toHaveBeenCalled();
1807+ });
1808+ });
1809+
1810+ describe("showImagesTable", function() {
1811+
1812+ it("returns true if ubuntuImages exist", function() {
1813+ var directive = compileDirective();
1814+ var scope = directive.isolateScope();
1815+ scope.ubuntuImages = [{}];
1816+ expect(scope.showImagesTable()).toBe(true);
1817+ });
1818+
1819+ it("returns true source has arches and releases", function() {
1820+ var directive = compileDirective();
1821+ var scope = directive.isolateScope();
1822+ scope.ubuntuImages = [];
1823+ scope.source.arches = [{}];
1824+ scope.source.releases = [{}];
1825+ expect(scope.showImagesTable()).toBe(true);
1826+ });
1827+
1828+ it("returns false no images and no source info", function() {
1829+ var directive = compileDirective();
1830+ var scope = directive.isolateScope();
1831+ scope.ubuntuImages = [];
1832+ scope.source.arches = [];
1833+ scope.source.releases = [];
1834+ expect(scope.showImagesTable()).toBe(false);
1835+ });
1836+ });
1837+
1838+ describe("regenerateUbuntuImages", function() {
1839+
1840+ it("builds images based on selections", function() {
1841+ var directive = compileDirective();
1842+ var scope = directive.isolateScope();
1843+ scope.bootResources.resources = [];
1844+ var release = {
1845+ name: makeName("release"),
1846+ title: makeName("releaseTitle")
1847+ };
1848+ var arch = {
1849+ name: makeName("arch"),
1850+ title: makeName("archTitle")
1851+ };
1852+ scope.source.selections.releases = [release];
1853+ scope.source.selections.arches = [arch];
1854+ scope.regenerateUbuntuImages();
1855+ expect(scope.ubuntuImages).toEqual([{
1856+ icon: 'icon--status-queued',
1857+ title: release.title,
1858+ arch: arch.title,
1859+ size: '-',
1860+ status: 'Queued for download',
1861+ beingDeleted: false
1862+ }]);
1863+ });
1864+
1865+ it("builds images based on selection and resource", function() {
1866+ var directive = compileDirective();
1867+ var scope = directive.isolateScope();
1868+ var release = {
1869+ name: makeName("release"),
1870+ title: makeName("releaseTitle")
1871+ };
1872+ var arch = {
1873+ name: makeName("arch"),
1874+ title: makeName("archTitle")
1875+ };
1876+ var icon = makeName("icon");
1877+ var size = makeName("size");
1878+ var status = makeName("status");
1879+ scope.bootResources.resources = [{
1880+ rtype: 0,
1881+ name: 'ubuntu/' + release.name,
1882+ arch: arch.name,
1883+ icon: icon,
1884+ size: size,
1885+ status: status,
1886+ downloading: true
1887+ }];
1888+ scope.source.selections.releases = [release];
1889+ scope.source.selections.arches = [arch];
1890+ scope.regenerateUbuntuImages();
1891+ expect(scope.ubuntuImages).toEqual([{
1892+ icon: 'icon--status-' + icon + ' u-animation--pulse',
1893+ title: release.title,
1894+ arch: arch.title,
1895+ size: size,
1896+ status: status,
1897+ beingDeleted: false
1898+ }]);
1899+ });
1900+
1901+ it("marks resource as being deleted", function() {
1902+ var directive = compileDirective();
1903+ var scope = directive.isolateScope();
1904+ var release = {
1905+ name: makeName("release"),
1906+ title: makeName("releaseTitle")
1907+ };
1908+ var arch = {
1909+ name: makeName("arch"),
1910+ title: makeName("archTitle")
1911+ };
1912+ var icon = makeName("icon");
1913+ var size = makeName("size");
1914+ var status = makeName("status");
1915+ scope.bootResources.resources = [{
1916+ rtype: 0,
1917+ name: 'ubuntu/' + release.name,
1918+ title: release.title,
1919+ arch: arch.name,
1920+ icon: icon,
1921+ size: size,
1922+ status: status,
1923+ downloading: true
1924+ }];
1925+ scope.source.selections.releases = [];
1926+ scope.source.selections.arches = [];
1927+ scope.regenerateUbuntuImages();
1928+ expect(scope.ubuntuImages).toEqual([{
1929+ icon: 'icon--status-failed',
1930+ title: release.title,
1931+ arch: arch.name,
1932+ size: size,
1933+ status: 'Queued for deletion',
1934+ beingDeleted: true
1935+ }]);
1936+ });
1937+ });
1938+
1939+ describe("regenerateOtherImages", function() {
1940+
1941+ it("builds images based on selections", function() {
1942+ var directive = compileDirective();
1943+ var scope = directive.isolateScope();
1944+ scope.bootResources.resources = [];
1945+ var os = makeName("os");
1946+ var release = makeName("release");
1947+ var arch = makeName("arch");
1948+ var name = os + '/' + arch + '/generic/' + release;
1949+ var image = {
1950+ name: name,
1951+ title: makeName("title"),
1952+ checked: true
1953+ };
1954+ scope.other.images = [image];
1955+ scope.regenerateOtherImages();
1956+ expect(scope.otherImages).toEqual([{
1957+ icon: 'icon--status-queued',
1958+ title: image.title,
1959+ arch: arch,
1960+ size: '-',
1961+ status: 'Queued for download',
1962+ beingDeleted: false
1963+ }]);
1964+ });
1965+
1966+ it("builds images based on selection and resource", function() {
1967+ var directive = compileDirective();
1968+ var scope = directive.isolateScope();
1969+ var os = makeName("os");
1970+ var release = makeName("release");
1971+ var arch = makeName("arch");
1972+ var name = os + '/' + arch + '/generic/' + release;
1973+ var image = {
1974+ name: name,
1975+ title: makeName("title"),
1976+ checked: true
1977+ };
1978+ var icon = makeName("icon");
1979+ var size = makeName("size");
1980+ var status = makeName("status");
1981+ scope.bootResources.resources = [{
1982+ rtype: 0,
1983+ name: os + '/' + release,
1984+ arch: arch,
1985+ icon: icon,
1986+ size: size,
1987+ status: status,
1988+ downloading: true
1989+ }];
1990+ scope.other.images = [image];
1991+ scope.regenerateOtherImages();
1992+ expect(scope.otherImages).toEqual([{
1993+ icon: 'icon--status-' + icon + ' u-animation--pulse',
1994+ title: image.title,
1995+ arch: arch,
1996+ size: size,
1997+ status: status,
1998+ beingDeleted: false
1999+ }]);
2000+ });
2001+
2002+ it("marks resource as being deleted", function() {
2003+ var directive = compileDirective();
2004+ var scope = directive.isolateScope();
2005+ var os = makeName("os");
2006+ var release = makeName("release");
2007+ var arch = makeName("arch");
2008+ var name = os + '/' + arch + '/generic/' + release;
2009+ var image = {
2010+ name: name,
2011+ title: makeName("title"),
2012+ checked: false
2013+ };
2014+ var icon = makeName("icon");
2015+ var size = makeName("size");
2016+ var status = makeName("status");
2017+ scope.bootResources.resources = [{
2018+ rtype: 0,
2019+ name: os + '/' + release,
2020+ title: image.title,
2021+ arch: arch,
2022+ icon: icon,
2023+ size: size,
2024+ status: status,
2025+ downloading: true
2026+ }];
2027+ scope.other.images = [image];
2028+ scope.regenerateOtherImages();
2029+ expect(scope.otherImages).toEqual([{
2030+ icon: 'icon--status-failed',
2031+ title: image.title,
2032+ arch: arch,
2033+ size: size,
2034+ status: 'Queued for deletion',
2035+ beingDeleted: true
2036+ }]);
2037+ });
2038+ });
2039+
2040+ describe("regenerateGeneratedImages", function() {
2041+
2042+ it("builds images based on resource", function() {
2043+ var directive = compileDirective();
2044+ var scope = directive.isolateScope();
2045+ var icon = makeName("icon");
2046+ var title = makeName("title");
2047+ var arch = makeName("arch");
2048+ var size = makeName("size");
2049+ var status = makeName("status");
2050+ scope.bootResources.resources = [{
2051+ rtype: 1,
2052+ icon: icon,
2053+ title: title,
2054+ arch: arch,
2055+ size: size,
2056+ status: status,
2057+ downloading: true
2058+ }];
2059+ scope.regenerateGeneratedImages();
2060+ expect(scope.generatedImages).toEqual([{
2061+ icon: 'icon--status-' + icon + ' u-animation--pulse',
2062+ title: title,
2063+ arch: arch,
2064+ size: size,
2065+ status: status
2066+ }]);
2067+ });
2068+ });
2069+
2070+ describe("regenerateCustomImages", function() {
2071+
2072+ it("builds images based on resource", function() {
2073+ var directive = compileDirective();
2074+ var scope = directive.isolateScope();
2075+ var icon = makeName("icon");
2076+ var title = makeName("title");
2077+ var arch = makeName("arch");
2078+ var size = makeName("size");
2079+ var status = makeName("status");
2080+ scope.bootResources.resources = [{
2081+ rtype: 2,
2082+ icon: icon,
2083+ title: title,
2084+ arch: arch,
2085+ size: size,
2086+ status: status,
2087+ downloading: true
2088+ }];
2089+ scope.regenerateCustomImages();
2090+ expect(scope.customImages).toEqual([{
2091+ icon: 'icon--status-' + icon + ' u-animation--pulse',
2092+ title: title,
2093+ arch: arch,
2094+ size: size,
2095+ status: status
2096+ }]);
2097+ });
2098+ });
2099+
2100+ describe("ltsIsSelected", function() {
2101+
2102+ it("returns true if LTS is selected", function() {
2103+ var directive = compileDirective();
2104+ var scope = directive.isolateScope();
2105+ var image = {
2106+ title: '16.04 LTS',
2107+ beingDeleted: false
2108+ };
2109+ scope.ubuntuImages = [image];
2110+ expect(scope.ltsIsSelected()).toBe(true);
2111+ });
2112+
2113+ it("returns true if 14 series LTS is selected", function() {
2114+ var directive = compileDirective();
2115+ var scope = directive.isolateScope();
2116+ var image = {
2117+ title: '14.04 LTS',
2118+ beingDeleted: false
2119+ };
2120+ scope.ubuntuImages = [image];
2121+ expect(scope.ltsIsSelected()).toBe(true);
2122+ });
2123+
2124+ it("returns false if LTS is being deleted", function() {
2125+ var directive = compileDirective();
2126+ var scope = directive.isolateScope();
2127+ var image = {
2128+ title: '16.04 LTS',
2129+ beingDeleted: true
2130+ };
2131+ scope.ubuntuImages = [image];
2132+ expect(scope.ltsIsSelected()).toBe(false);
2133+ });
2134+
2135+ it("returns false if less than 14 series", function() {
2136+ var directive = compileDirective();
2137+ var scope = directive.isolateScope();
2138+ var image = {
2139+ title: '12.04 LTS',
2140+ beingDeleted: false
2141+ };
2142+ scope.ubuntuImages = [image];
2143+ expect(scope.ltsIsSelected()).toBe(false);
2144+ });
2145+ });
2146+
2147+ describe("showStopImportButton", function() {
2148+
2149+ it("returns region_import_running", function() {
2150+ var directive = compileDirective();
2151+ var scope = directive.isolateScope();
2152+ var sentinel = {};
2153+ scope.bootResources.region_import_running = sentinel;
2154+ expect(scope.showStopImportButton()).toBe(sentinel);
2155+ });
2156+ });
2157+
2158+ describe("showSaveSelection", function() {
2159+
2160+ it("returns showImagesTable", function() {
2161+ var directive = compileDirective();
2162+ var scope = directive.isolateScope();
2163+ var sentinel = {};
2164+ spyOn(scope, "showImagesTable").and.returnValue(sentinel);
2165+ expect(scope.showSaveSelection()).toBe(sentinel);
2166+ });
2167+ });
2168+
2169+ describe("canSaveSelection", function() {
2170+
2171+ it("returns false if saving", function() {
2172+ var directive = compileDirective();
2173+ var scope = directive.isolateScope();
2174+ scope.saving = true;
2175+ expect(scope.canSaveSelection()).toBe(false);
2176+ });
2177+
2178+ it("returns false if not lts selected", function() {
2179+ var directive = compileDirective();
2180+ var scope = directive.isolateScope();
2181+ scope.saving = false;
2182+ spyOn(scope, "ltsIsSelected").and.returnValue(false);
2183+ expect(scope.canSaveSelection()).toBe(false);
2184+ });
2185+
2186+ it("returns true if lts selected and not saving", function() {
2187+ var directive = compileDirective();
2188+ var scope = directive.isolateScope();
2189+ scope.saving = false;
2190+ spyOn(scope, "ltsIsSelected").and.returnValue(true);
2191+ expect(scope.canSaveSelection()).toBe(true);
2192+ });
2193+ });
2194+
2195+ describe("getSaveSelectionText", function() {
2196+
2197+ it("returns 'Saving...' when saving", function() {
2198+ var directive = compileDirective();
2199+ var scope = directive.isolateScope();
2200+ scope.saving = true;
2201+ expect(scope.getSaveSelectionText()).toBe('Saving...');
2202+ });
2203+
2204+ it("returns 'Save selection' when not saving", function() {
2205+ var directive = compileDirective();
2206+ var scope = directive.isolateScope();
2207+ scope.saving = false;
2208+ expect(scope.getSaveSelectionText()).toBe('Save selection');
2209+ });
2210+ });
2211+
2212+ describe("saveSelection", function() {
2213+
2214+ it("passes selected releases and arches", function() {
2215+ var directive = compileDirective();
2216+ var scope = directive.isolateScope();
2217+ var defer = $q.defer();
2218+ spyOn(BootResourcesManager, "saveUbuntu").and.returnValue(
2219+ defer.promise);
2220+ spyOn(scope, "canSaveSelection").and.returnValue(true);
2221+
2222+ var release = makeName("release");
2223+ scope.source.selections.releases = [{
2224+ name: release
2225+ }];
2226+ var arch = makeName("arch");
2227+ scope.source.selections.arches = [{
2228+ name: arch
2229+ }];
2230+ scope.saveSelection();
2231+
2232+ expect(scope.saving).toBe(true);
2233+ expect(BootResourcesManager.saveUbuntu).toHaveBeenCalledWith({
2234+ source_type: 'maas.io',
2235+ releases: [release],
2236+ arches: [arch]
2237+ });
2238+ });
2239+
2240+ it("clears saving and calls updateSource", function() {
2241+ var directive = compileDirective();
2242+ var scope = directive.isolateScope();
2243+ var defer = $q.defer();
2244+ spyOn(BootResourcesManager, "saveUbuntu").and.returnValue(
2245+ defer.promise);
2246+ spyOn(scope, "canSaveSelection").and.returnValue(true);
2247+
2248+ var release = makeName("release");
2249+ scope.source.selections.releases = [{
2250+ name: release
2251+ }];
2252+ var arch = makeName("arch");
2253+ scope.source.selections.arches = [{
2254+ name: arch
2255+ }];
2256+ scope.source.isNew = true;
2257+ scope.source.selections.changed = true;
2258+ spyOn(scope, "updateSource");
2259+ scope.saveSelection();
2260+
2261+ expect(scope.saving).toBe(true);
2262+ defer.resolve();
2263+ $scope.$digest();
2264+ expect(scope.saving).toBe(false);
2265+ expect(scope.source.isNew).toBe(false);
2266+ expect(scope.source.selections.changed).toBe(false);
2267+ expect(scope.updateSource).toHaveBeenCalled();
2268+ });
2269+ });
2270+
2271+ describe("updateSource", function() {
2272+
2273+ it("sets to new and custom when no source", function() {
2274+ var directive = compileDirective();
2275+ var scope = directive.isolateScope();
2276+ scope.bootResources.ubuntu = {
2277+ sources: []
2278+ };
2279+ scope.updateSource();
2280+ expect(scope.source.isNew).toBe(true);
2281+ expect(scope.source.source_type).toBe('custom');
2282+ expect(scope.source.errorMessage).toBe(
2283+ 'Currently no source exists.');
2284+
2285+ });
2286+
2287+ it("sets releases and arches and selections when source", function() {
2288+ var directive = compileDirective();
2289+ var scope = directive.isolateScope();
2290+ var source = {
2291+ source_type: 'custom',
2292+ url: makeName('url'),
2293+ keyring_filename: makeName('keyring_filename'),
2294+ keyring_data: makeName('keyring_data')
2295+ };
2296+ var release = {
2297+ name: makeName("release"),
2298+ checked: false
2299+ };
2300+ var releaseChecked = {
2301+ name: makeName("release"),
2302+ checked: true
2303+ };
2304+ var arch = {
2305+ name: makeName("arch"),
2306+ checked: false
2307+ };
2308+ var archChecked = {
2309+ name: makeName("arch"),
2310+ checked: true
2311+ };
2312+ scope.bootResources.ubuntu = {
2313+ sources: [source],
2314+ releases: [release, releaseChecked],
2315+ arches: [arch, archChecked]
2316+ };
2317+ spyOn(scope, "regenerateUbuntuImages");
2318+ scope.updateSource();
2319+ expect(scope.source.isNew).toBe(false);
2320+ expect(scope.source.source_type).toBe('custom');
2321+ expect(scope.source.url).toBe(source.url);
2322+ expect(scope.source.keyring_filename).toBe(source.keyring_filename);
2323+ expect(scope.source.keyring_data).toBe(source.keyring_data);
2324+ expect(scope.source.releases).toBe(
2325+ scope.bootResources.ubuntu.releases);
2326+ expect(scope.source.arches).toBe(
2327+ scope.bootResources.ubuntu.arches);
2328+ expect(scope.source.selections.releases).toEqual([releaseChecked]);
2329+ expect(scope.source.selections.arches).toEqual([archChecked]);
2330+ expect(scope.regenerateUbuntuImages).toHaveBeenCalled();
2331+ });
2332+
2333+ it("sets tooMany when multiple sources", function() {
2334+ var directive = compileDirective();
2335+ var scope = directive.isolateScope();
2336+ var release = {
2337+ name: makeName("release"),
2338+ checked: false
2339+ };
2340+ var releaseChecked = {
2341+ name: makeName("release"),
2342+ checked: true
2343+ };
2344+ var arch = {
2345+ name: makeName("arch"),
2346+ checked: false
2347+ };
2348+ var archChecked = {
2349+ name: makeName("arch"),
2350+ checked: true
2351+ };
2352+ scope.bootResources.ubuntu = {
2353+ sources: [{}, {}],
2354+ releases: [release, releaseChecked],
2355+ arches: [arch, archChecked]
2356+ };
2357+ spyOn(scope, "regenerateUbuntuImages");
2358+ scope.updateSource();
2359+ expect(scope.source.isNew).toBe(false);
2360+ expect(scope.source.tooMany).toBe(true);
2361+ expect(scope.source.releases).toBe(
2362+ scope.bootResources.ubuntu.releases);
2363+ expect(scope.source.arches).toBe(
2364+ scope.bootResources.ubuntu.arches);
2365+ expect(scope.source.selections.releases).toEqual([releaseChecked]);
2366+ expect(scope.source.selections.arches).toEqual([archChecked]);
2367+ expect(scope.regenerateUbuntuImages).toHaveBeenCalled();
2368+ });
2369+ });
2370+
2371+ describe("toggleOtherSelection", function() {
2372+
2373+ it("toggles checked and sets changed", function() {
2374+ var directive = compileDirective();
2375+ var scope = directive.isolateScope();
2376+ var image = {
2377+ checked: true
2378+ };
2379+ spyOn(scope, "regenerateOtherImages");
2380+ scope.toggleOtherSelection(image);
2381+ expect(scope.other.changed).toBe(true);
2382+ expect(image.checked).toBe(false);
2383+ expect(scope.regenerateOtherImages).toHaveBeenCalled();
2384+ });
2385+ });
2386+
2387+ describe("saveOtherSelection", function() {
2388+
2389+ it("passes correct params and toggles saving", function() {
2390+ var directive = compileDirective();
2391+ var scope = directive.isolateScope();
2392+ var image = {
2393+ name: makeName("name"),
2394+ checked: true
2395+ };
2396+ scope.other.images = [image];
2397+ var defer = $q.defer();
2398+ spyOn(BootResourcesManager, "saveOther").and.returnValue(
2399+ defer.promise);
2400+ scope.saveOtherSelection();
2401+
2402+ expect(scope.saving).toBe(true);
2403+ expect(BootResourcesManager.saveOther).toHaveBeenCalledWith({
2404+ images: [image.name]
2405+ });
2406+ defer.resolve();
2407+ $scope.$digest();
2408+ expect(scope.saving).toBe(false);
2409+ });
2410+ });
2411+});
2412
2413=== added file 'src/maasserver/static/js/angular/factories/bootresources.js'
2414--- src/maasserver/static/js/angular/factories/bootresources.js 1970-01-01 00:00:00 +0000
2415+++ src/maasserver/static/js/angular/factories/bootresources.js 2016-09-14 21:01:21 +0000
2416@@ -0,0 +1,179 @@
2417+/* Copyright 2016 Canonical Ltd. This software is licensed under the
2418+ * GNU Affero General Public License version 3 (see the file LICENSE).
2419+ *
2420+ * MAAS BootResource Manager
2421+ *
2422+ * Manager for the boot resources. This manager is unique from all the other
2423+ * managers because it uses polling instead of having the region push the
2424+ * information.
2425+ *
2426+ * Why is it polling?
2427+ * The boot resource information is split between the region controller and
2428+ * all rack controllers. The region controller does not cache any information
2429+ * about a rack controllers images it contacts the rack as its source of truth.
2430+ * This means that the client needs to use polling so the region controller
2431+ * can ask each rack controller what is the status of your images.
2432+ */
2433+
2434+angular.module('MAAS').factory(
2435+ 'BootResourcesManager',
2436+ ['$q', '$timeout', 'RegionConnection', 'ErrorService',
2437+ function($q, $timeout, RegionConnection, ErrorService) {
2438+
2439+ // Constructor
2440+ function BootResourcesManager() {
2441+ // Set true once been loaded the first time.
2442+ this._loaded = false;
2443+
2444+ // Holds the data recieved from polling.
2445+ this._data = {};
2446+
2447+ // Set to true when polling has been enabled.
2448+ this._polling = false;
2449+
2450+ // The next promise for the polling interval.
2451+ this._nextPromise = null;
2452+
2453+ // Amount of time in milliseconds the manager should wait to poll
2454+ // for new data.
2455+ this._pollTimeout = 10000;
2456+
2457+ // Amount of time in milliseconds the manager should wait to poll
2458+ // for new data when an error occurs.
2459+ this._pollErrorTimeout = 500;
2460+
2461+ // Amount of time in milliseconds the manager should wait to poll
2462+ // for new data when the retrieved data is empty.
2463+ this._pollEmptyTimeout = 3000;
2464+ }
2465+
2466+ // Return the data.
2467+ BootResourcesManager.prototype.getData = function() {
2468+ return this._data;
2469+ };
2470+
2471+ // Return true when data has been loaded.
2472+ BootResourcesManager.prototype.isLoaded = function() {
2473+ return this._loaded;
2474+ };
2475+
2476+ // Returns true when currently polling.
2477+ BootResourcesManager.prototype.isPolling = function() {
2478+ return this._polling;
2479+ };
2480+
2481+ // Starts the polling for data.
2482+ BootResourcesManager.prototype.startPolling = function() {
2483+ if(!this._polling) {
2484+ this._polling = true;
2485+ return this._poll();
2486+ } else {
2487+ return this._nextPromise;
2488+ }
2489+ };
2490+
2491+ // Stops the polling for data.
2492+ BootResourcesManager.prototype.stopPolling = function() {
2493+ this._polling = false;
2494+ if(angular.isObject(this._nextPromise)) {
2495+ $timeout.cancel(this._nextPromise);
2496+ this._nextPromise = null;
2497+ }
2498+ };
2499+
2500+ // Load the data from the region.
2501+ BootResourcesManager.prototype._loadData = function(raiseError) {
2502+ raiseError = raiseError || false;
2503+ var self = this;
2504+ return RegionConnection.callMethod("bootresource.poll").then(
2505+ function(newData) {
2506+ angular.copy(angular.fromJson(newData), self._data);
2507+ self._loaded = true;
2508+ return self._data;
2509+ }, function(error) {
2510+ if(raiseError) {
2511+ ErrorService.raiseError(error);
2512+ }
2513+ });
2514+ };
2515+
2516+ // Registers the next polling attempt.
2517+ BootResourcesManager.prototype._pollAgain = function(timeout) {
2518+ var self = this;
2519+ this._nextPromise = $timeout(function() {
2520+ self._poll();
2521+ }, timeout);
2522+ return this._nextPromise;
2523+ };
2524+
2525+ // Polls for the data from the region.
2526+ BootResourcesManager.prototype._poll = function() {
2527+ var self = this;
2528+
2529+ // Can only poll if connected.
2530+ if(!RegionConnection.isConnected()) {
2531+ return this._pollAgain(this._pollErrorTimeout);
2532+ }
2533+
2534+ return this._loadData(false).then(function(newData) {
2535+ var pollTimeout = self._pollTimeout;
2536+ if(!angular.isObject(newData) ||
2537+ newData.connection_error ||
2538+ !angular.isArray(newData.resources) ||
2539+ newData.resources.length === 0) {
2540+ pollTimeout = self._pollEmptyTimeout;
2541+ }
2542+ self._pollAgain(pollTimeout);
2543+ return newData;
2544+ }, function(error) {
2545+ // Don't raise the error, just log it and try again.
2546+ console.log(error);
2547+ self._pollAgain(self._pollErrorTimeout);
2548+ });
2549+ };
2550+
2551+ // Loads the resources. This implemented so the ManagerHelperService
2552+ // can work on this manager just like all the rest.
2553+ BootResourcesManager.prototype.loadItems = function() {
2554+ var defer = $q.defer();
2555+ this._loadData(true).then(function() {
2556+ defer.resolve();
2557+ });
2558+ return defer.promise;
2559+ };
2560+
2561+ // Does nothing. This implemented so the ManagerHelperService
2562+ // can work on this manager just like all the rest.
2563+ BootResourcesManager.prototype.enableAutoReload = function() { };
2564+
2565+ // Save the ubuntu options and start the import process.
2566+ BootResourcesManager.prototype.saveUbuntu = function(params) {
2567+ var self = this;
2568+ return RegionConnection.callMethod(
2569+ "bootresource.save_ubuntu", params).then(
2570+ function(newData) {
2571+ angular.copy(angular.fromJson(newData), self._data);
2572+ self._loaded = true;
2573+ return self._data;
2574+ });
2575+ };
2576+
2577+ // Save the other images and start the import process.
2578+ BootResourcesManager.prototype.saveOther = function(params) {
2579+ var self = this;
2580+ return RegionConnection.callMethod(
2581+ "bootresource.save_other", params).then(
2582+ function(newData) {
2583+ angular.copy(angular.fromJson(newData), self._data);
2584+ self._loaded = true;
2585+ return self._data;
2586+ });
2587+ };
2588+
2589+ // Fetch the releases and arches from the provided source.
2590+ BootResourcesManager.prototype.fetch = function(source) {
2591+ return RegionConnection.callMethod("bootresource.fetch", source);
2592+ };
2593+
2594+ return new BootResourcesManager();
2595+ }]);
2596
2597=== added file 'src/maasserver/static/js/angular/factories/tests/test_bootresources.js'
2598--- src/maasserver/static/js/angular/factories/tests/test_bootresources.js 1970-01-01 00:00:00 +0000
2599+++ src/maasserver/static/js/angular/factories/tests/test_bootresources.js 2016-09-14 21:01:21 +0000
2600@@ -0,0 +1,195 @@
2601+/* Copyright 2016 Canonical Ltd. This software is licensed under the
2602+ * GNU Affero General Public License version 3 (see the file LICENSE).
2603+ *
2604+ * Unit tests for BootResourcesManager.
2605+ */
2606+
2607+
2608+describe("BootResourcesManager", function() {
2609+
2610+ // Load the MAAS module.
2611+ beforeEach(module("MAAS"));
2612+
2613+ // Grab the needed angular pieces.
2614+ var $rootScope, $timeout, $q;
2615+ beforeEach(inject(function($injector) {
2616+ $rootScope = $injector.get("$rootScope");
2617+ $timeout = $injector.get("$timeout");
2618+ $q = $injector.get("$q");
2619+ }));
2620+
2621+ // Load the needed services.
2622+ var BootResourcesManager, RegionConnection, ErrorService, webSocket;
2623+ beforeEach(inject(function($injector) {
2624+ BootResourcesManager = $injector.get("BootResourcesManager");
2625+ RegionConnection = $injector.get("RegionConnection");
2626+ ErrorService = $injector.get("ErrorService");
2627+
2628+ // Mock buildSocket so an actual connection is not made.
2629+ webSocket = new MockWebSocket();
2630+ spyOn(RegionConnection, "buildSocket").and.returnValue(webSocket);
2631+ }));
2632+
2633+ it("sets initial values", function() {
2634+ expect(BootResourcesManager._loaded).toBe(false);
2635+ expect(BootResourcesManager._data).toEqual({});
2636+ expect(BootResourcesManager._polling).toBe(false);
2637+ expect(BootResourcesManager._nextPromise).toBeNull();
2638+ expect(BootResourcesManager._pollTimeout).toBe(10000);
2639+ expect(BootResourcesManager._pollErrorTimeout).toBe(500);
2640+ expect(BootResourcesManager._pollEmptyTimeout).toBe(3000);
2641+ });
2642+
2643+ describe("getData", function() {
2644+
2645+ it("returns _data", function() {
2646+ expect(BootResourcesManager.getData()).toBe(
2647+ BootResourcesManager._data);
2648+ });
2649+ });
2650+
2651+ describe("isLoaded", function() {
2652+
2653+ it("returns _loaded", function() {
2654+ var sentinel = {};
2655+ BootResourcesManager._loaded = sentinel;
2656+ expect(BootResourcesManager.isLoaded()).toBe(sentinel);
2657+ });
2658+ });
2659+
2660+ describe("isPolling", function() {
2661+
2662+ it("returns _polling", function() {
2663+ var sentinel = {};
2664+ BootResourcesManager._polling = sentinel;
2665+ expect(BootResourcesManager.isPolling()).toBe(sentinel);
2666+ });
2667+ });
2668+
2669+ describe("startPolling", function() {
2670+
2671+ it("calls _poll and sets polling", function() {
2672+ var sentinel = {};
2673+ spyOn(BootResourcesManager, "_poll").and.returnValue(sentinel);
2674+ expect(BootResourcesManager.startPolling()).toBe(sentinel);
2675+ expect(BootResourcesManager._polling).toBe(true);
2676+ });
2677+
2678+ it("returns _nextPromise if already polling", function() {
2679+ var sentinel = {};
2680+ BootResourcesManager._polling = true;
2681+ BootResourcesManager._nextPromise = sentinel;
2682+ spyOn(BootResourcesManager, "_poll");
2683+ expect(BootResourcesManager.startPolling()).toBe(sentinel);
2684+ expect(BootResourcesManager._poll).not.toHaveBeenCalled();
2685+ });
2686+ });
2687+
2688+ describe("stopPolling", function() {
2689+
2690+ it("clears _polling and cancels _nextPromise", function() {
2691+ var sentinel = {};
2692+ BootResourcesManager._polling = true;
2693+ BootResourcesManager._nextPromise = sentinel;
2694+ spyOn($timeout, "cancel");
2695+ BootResourcesManager.stopPolling();
2696+ expect($timeout.cancel).toHaveBeenCalledWith(sentinel);
2697+ expect(BootResourcesManager._nextPromise).toBeNull();
2698+ expect(BootResourcesManager._polling).toBe(false);
2699+ });
2700+ });
2701+
2702+ describe("_loadData", function() {
2703+
2704+ it("calls bootresource.poll and sets _data", function(done) {
2705+ var data = BootResourcesManager._data;
2706+ var defer = $q.defer();
2707+ spyOn(RegionConnection, "callMethod").and.returnValue(
2708+ defer.promise);
2709+
2710+ var newData = {
2711+ key: makeName("value")
2712+ };
2713+ BootResourcesManager._loadData().then(function(passedData) {
2714+ expect(BootResourcesManager._loaded).toBe(true);
2715+ expect(BootResourcesManager._data).toBe(data);
2716+ expect(BootResourcesManager._data).toBe(passedData);
2717+ expect(BootResourcesManager._data).toEqual(newData);
2718+ done();
2719+ });
2720+
2721+ expect(RegionConnection.callMethod).toHaveBeenCalledWith(
2722+ "bootresource.poll");
2723+ defer.resolve(angular.toJson(newData));
2724+ $rootScope.$digest();
2725+ });
2726+ });
2727+
2728+ describe("saveUbuntu", function() {
2729+
2730+ it("calls bootresource.save_ubuntu and sets _data", function(done) {
2731+ var data = BootResourcesManager._data;
2732+ var defer = $q.defer();
2733+ spyOn(RegionConnection, "callMethod").and.returnValue(
2734+ defer.promise);
2735+
2736+ var newData = {
2737+ key: makeName("value")
2738+ };
2739+ var sentinel = {};
2740+ BootResourcesManager.saveUbuntu(sentinel).then(function(pData) {
2741+ expect(BootResourcesManager._loaded).toBe(true);
2742+ expect(BootResourcesManager._data).toBe(data);
2743+ expect(BootResourcesManager._data).toBe(pData);
2744+ expect(BootResourcesManager._data).toEqual(newData);
2745+ done();
2746+ });
2747+
2748+ expect(RegionConnection.callMethod).toHaveBeenCalledWith(
2749+ "bootresource.save_ubuntu", sentinel);
2750+ defer.resolve(angular.toJson(newData));
2751+ $rootScope.$digest();
2752+ });
2753+ });
2754+
2755+ describe("saveOther", function() {
2756+
2757+ it("calls bootresource.save_other and sets _data", function(done) {
2758+ var data = BootResourcesManager._data;
2759+ var defer = $q.defer();
2760+ spyOn(RegionConnection, "callMethod").and.returnValue(
2761+ defer.promise);
2762+
2763+ var newData = {
2764+ key: makeName("value")
2765+ };
2766+ var sentinel = {};
2767+ BootResourcesManager.saveOther(sentinel).then(function(pData) {
2768+ expect(BootResourcesManager._loaded).toBe(true);
2769+ expect(BootResourcesManager._data).toBe(data);
2770+ expect(BootResourcesManager._data).toBe(pData);
2771+ expect(BootResourcesManager._data).toEqual(newData);
2772+ done();
2773+ });
2774+
2775+ expect(RegionConnection.callMethod).toHaveBeenCalledWith(
2776+ "bootresource.save_other", sentinel);
2777+ defer.resolve(angular.toJson(newData));
2778+ $rootScope.$digest();
2779+ });
2780+ });
2781+
2782+ describe("fetch", function() {
2783+
2784+ it("calls bootresource.fetch", function() {
2785+ var returnSentinel = {};
2786+ var sourceSentinel = {};
2787+ spyOn(RegionConnection, "callMethod").and.returnValue(
2788+ returnSentinel);
2789+ expect(BootResourcesManager.fetch(sourceSentinel)).toBe(
2790+ returnSentinel);
2791+ expect(RegionConnection.callMethod).toHaveBeenCalledWith(
2792+ "bootresource.fetch", sourceSentinel);
2793+ });
2794+ });
2795+});
2796
2797=== modified file 'src/maasserver/static/js/angular/maas.js'
2798--- src/maasserver/static/js/angular/maas.js 2016-07-06 20:09:20 +0000
2799+++ src/maasserver/static/js/angular/maas.js 2016-09-14 21:01:21 +0000
2800@@ -54,6 +54,11 @@
2801 'static/partials/node-details.html'),
2802 controller: 'NodeDetailsController'
2803 }).
2804+ when('/images', {
2805+ templateUrl: versionedPath(
2806+ 'static/partials/images.html'),
2807+ controller: 'ImagesController'
2808+ }).
2809 when('/domains', {
2810 templateUrl: versionedPath(
2811 'static/partials/domains-list.html'),
2812
2813=== added file 'src/maasserver/static/partials/boot-images.html'
2814--- src/maasserver/static/partials/boot-images.html 1970-01-01 00:00:00 +0000
2815+++ src/maasserver/static/partials/boot-images.html 2016-09-14 21:01:21 +0000
2816@@ -0,0 +1,265 @@
2817+
2818+<div data-ng-class="{ 'action-card': design === 'action-card', 'row u-padding--top-large': design === 'page' }" data-ng-if="!loading">
2819+ <div data-ng-class="{ 'wrapper--inner': design === 'page' }">
2820+ <h2 data-ng-class="{ 'action-card__title': design === 'action-card' }">
2821+ <i data-ng-if="design === 'action-card'" class="icon icon--large {$ getTitleIcon() $}"></i> Ubuntu
2822+ </h2>
2823+ <div data-ng-class="{ 'action-card__controls': design === 'action-card' }">
2824+ <p class="u-margin--top-small u-margin--bottom-small" data-ng-if="isSuperUser() && !source.tooMany">
2825+ Select images and architecture to be imported and kept in sync daily. Images will be available for deploying to machines managed by MAAS.
2826+ </p>
2827+ <form class="form">
2828+ <div data-ng-if="isSuperUser() && !source.tooMany">
2829+ <h3>Choose source</h3>
2830+ <div class="form__group">
2831+ <input type="radio" ng-model="source.source_type" id="source_maas" value="maas.io" data-ng-change="sourceChanged()">
2832+ <label for="source_maas" class="u-margin--right">maas.io</label>
2833+ <input type="radio" ng-model="source.source_type" id="source_custom" value="custom" data-ng-change="sourceChanged()">
2834+ <label for="source_custom">Custom</label>
2835+ </div>
2836+ <div data-ng-if="showMirrorPath()">
2837+ <h3>Mirror URL</h3>
2838+ <p>Add the URL you want to use to select your images from.</p>
2839+ <div class="u-width--half u-display--inline-block u-float--left">
2840+ <div class="form__group">
2841+ <input type="text" name="mirrorUrl" placeholder="e.g. http:// or file://"
2842+ data-ng-model="source.url" data-ng-change="sourceChanged()">
2843+ </div>
2844+ <div data-ng-if="isShowingAdvancedOptions()">
2845+ <div class="form__group">
2846+ <label for="keyring_filename" class="form__group--label">Path to the keyring to validate the mirror path.</label>
2847+ <input type="text" name="keyring_filename" placeholder="e.g. /usr/share/keyrings/ubuntu-cloudimage-keyring.gpg"
2848+ data-ng-model="source.keyring_filename"
2849+ data-ng-class="{ 'has-error': bothKeyringOptionsSet() }">
2850+ <ul class="form__error" data-ng-if="bothKeyringOptionsSet()">
2851+ <li class="form__error-item">Cannot set both keyring path and keyring contents.</li>
2852+ </ul>
2853+ </div>
2854+ <div class="form__group">
2855+ <label for="keyring_data" class="form__group--label">Contents of the keyring to validate the mirror path.</label>
2856+ <textarea name="keyring_data" placeholder="Contents of GPG key"
2857+ data-ng-model="source.keyring_data"
2858+ data-ng-class="{ 'has-error': bothKeyringOptionsSet() }"></textarea>
2859+ <ul class="form__error" data-ng-if="bothKeyringOptionsSet()">
2860+ <li class="form__error-item">Cannot set both keyring path and keyring contents.</li>
2861+ </ul>
2862+ </div>
2863+ </div>
2864+ </div>
2865+ <div class="u-width--half u-display--inline-block u-float--left">
2866+ <div class="u-display--inline-block u-float--left u-margin--top-tiny u-margin--left">
2867+ <i class="icon" data-ng-class="{ 'icon--close': !isShowingAdvancedOptions(), 'icon--open': isShowingAdvancedOptions() }"></i>
2868+ <a data-ng-click="toggleAdvancedOptions()" data-ng-if="!isShowingAdvancedOptions()">Show advanced options</a>
2869+ <a data-ng-click="toggleAdvancedOptions()" data-ng-if="isShowingAdvancedOptions()">Hide advanced options</a>
2870+ </div>
2871+ <button class="button--secondary button--inline u-float--right"
2872+ data-ng-if="showConnectButton()"
2873+ data-ng-disabled="isConnectButtonDisabled()"
2874+ data-ng-click="connect()">Connect</button>
2875+ <div class="u-clear"></div>
2876+ </div>
2877+ <div class="u-clear"></div>
2878+ </div>
2879+ </div>
2880+ <div data-ng-if="showConnectBlock()">
2881+ <div class="u-clear"></div>
2882+ <div class="u-margin--top-large u-margin--bottom-large u-padding--top-large u-padding--bottom-large">
2883+ <p class="text-center" data-ng-if="!source.connecting && source.errorMessage">
2884+ <i class="icon icon--warning"></i> {$ source.errorMessage $}
2885+ <a data-ng-if="source.source_type === 'maas.io'" data-ng-click="connect()">Retry</a>
2886+ </p>
2887+ <p class="text-center" data-ng-if="source.connecting">
2888+ <i class="icon icon--loading u-animation--spin"></i> Connecting
2889+ </p>
2890+ </div>
2891+ </div>
2892+ <div data-ng-if="isSuperUser() && !source.tooMany && showSelections()">
2893+ <div class="u-width--half u-display--inline-block u-float--left">
2894+ <h3>Images</h3>
2895+ <div class="u-width--half u-display--inline-block u-float--left">
2896+ <div class="form__group" data-ng-repeat="release in getUbuntuLTSReleases() | orderBy:'-title'">
2897+ <input type="checkbox" id="{$ release.name $}"
2898+ data-ng-checked="isSelected('releases', release)"
2899+ data-ng-click="toggleSelection('releases', release)">
2900+ <label for="{$ release.name $}">{$ release.title $}</label>
2901+ </div>
2902+ </div>
2903+ <div class="u-width--half u-display--inline-block u-float--left">
2904+ <div class="form__group" data-ng-repeat="release in getUbuntuNonLTSReleases() | orderBy:'-title'">
2905+ <input type="checkbox" id="{$ release.name $}"
2906+ data-ng-checked="isSelected('releases', release)"
2907+ data-ng-click="toggleSelection('releases', release)">
2908+ <label for="{$ release.name $}">{$ release.title $}</label>
2909+ </div>
2910+ </div>
2911+ <div class="u-clear"></div>
2912+ </div>
2913+ <div class="u-width--half u-display--inline-block u-float--left">
2914+ <h3>Architectures</h3>
2915+ <div class="form__group" data-ng-repeat="arch in getArchitectures() | orderBy:'title'">
2916+ <input type="checkbox" id="{$ arch.name $}"
2917+ data-ng-checked="isSelected('arches', arch)"
2918+ data-ng-click="toggleSelection('arches', arch)">
2919+ <label for="{$ arch.name $}">{$ arch.title $}</label>
2920+ </div>
2921+ </div>
2922+ <div class="u-clear"></div>
2923+ </div>
2924+ <section class="table u-margin--top" data-ng-if="showImagesTable()">
2925+ <header class="table__head">
2926+ <div class="table__row">
2927+ <div class="table__header table-col--25">Release</div>
2928+ <div class="table__header table-col--15">Architecture</div>
2929+ <div class="table__header table-col--20">Size</div>
2930+ <div class="table__header table-col--30">Importing status</div>
2931+ </div>
2932+ </header>
2933+ <main class="table__body">
2934+ <div class="table__row table--error" data-ng-if="!ltsIsSelected()">
2935+ <div class="table__data table-col--100">
2936+ <i class="icon icon--error"></i> Select at least one 14.04+ LTS release and one architecture.
2937+ </div>
2938+ </div>
2939+ <div class="table__row" data-ng-repeat="image in ubuntuImages | orderBy:['-title', 'arch']">
2940+ <div class="table__data table-col--2">
2941+ <i class="icon {$ image.icon $}"></i>
2942+ </div>
2943+ <div class="table__data table-col--23">
2944+ {$ image.title $}
2945+ </div>
2946+ <div class="table__data table-col--15">{$ image.arch $}</div>
2947+ <div class="table__data table-col--20">{$ image.size $}</div>
2948+ <div class="table__data table-col--30">{$ image.status $}</div>
2949+ </div>
2950+ </main>
2951+ </section>
2952+ <div class="twelve-col" data-ng-if="!source.tooMany">
2953+ <div class="u-float--right">
2954+ <!-- Not supported yet.
2955+ <a class="button--base button--inline" data-ng-if="showStopImportButton()">Stop import</a>
2956+ -->
2957+ <button class="button--secondary button--inline"
2958+ data-ng-if="isSuperUser() && showSaveSelection()"
2959+ data-ng-disabled="!canSaveSelection()"
2960+ data-ng-click="saveSelection()">{$ getSaveSelectionText() $}</button>
2961+ </div>
2962+ </div>
2963+ </form>
2964+ </div>
2965+ </div>
2966+</div>
2967+<div class="row" data-ng-if="design === 'page' && other.images.length && !source.isNew">
2968+ <div class="wrapper--inner">
2969+ <h2>Other Images</h2>
2970+ <div data-ng-if="isSuperUser() && !source.tooMany">
2971+ <span class="u-margin--right" data-ng-repeat="image in other.images | orderBy:['-title']">
2972+ <input type="checkbox" id="{$ image.name $}"
2973+ data-ng-checked="image.checked"
2974+ data-ng-click="toggleOtherSelection(image)">
2975+ <label for="{$ image.name $}">{$ image.title $}</label>
2976+ <span>
2977+ </div>
2978+ <section class="table u-margin--top">
2979+ <header class="table__head">
2980+ <div class="table__row">
2981+ <div class="table__header table-col--25">Release</div>
2982+ <div class="table__header table-col--15">Architecture</div>
2983+ <div class="table__header table-col--20">Size</div>
2984+ <div class="table__header table-col--30">Importing status</div>
2985+ </div>
2986+ </header>
2987+ <main class="table__body">
2988+ <div class="table__row" data-ng-if="!otherImages.length">
2989+ <div class="table__data table-col--100">
2990+ No images have been selected for syncing.
2991+ </div>
2992+ </div>
2993+ <div class="table__row" data-ng-repeat="image in otherImages | orderBy:['-title', 'arch']">
2994+ <div class="table__data table-col--2">
2995+ <i class="icon {$ image.icon $}"></i>
2996+ </div>
2997+ <div class="table__data table-col--23">
2998+ {$ image.title $}
2999+ </div>
3000+ <div class="table__data table-col--15">{$ image.arch $}</div>
3001+ <div class="table__data table-col--20">{$ image.size $}</div>
3002+ <div class="table__data table-col--30">{$ image.status $}</div>
3003+ </div>
3004+ </main>
3005+ </section>
3006+ <div class="twelve-col" data-ng-if="!source.tooMany">
3007+ <div class="u-float--right">
3008+ <button class="button--secondary button--inline"
3009+ data-ng-if="isSuperUser()"
3010+ data-ng-disabled="saving"
3011+ data-ng-click="saveOtherSelection()">{$ getSaveSelectionText() $}</button>
3012+ </div>
3013+ </div>
3014+ </div>
3015+</div>
3016+<div class="row" data-ng-if="design === 'page' && generatedImages.length">
3017+ <div class="wrapper--inner">
3018+ <h2>Generated Images</h2>
3019+ <section class="table u-margin--top">
3020+ <header class="table__head">
3021+ <div class="table__row">
3022+ <div class="table__header table-col--25">Release</div>
3023+ <div class="table__header table-col--15">Architecture</div>
3024+ <div class="table__header table-col--20">Size</div>
3025+ <div class="table__header table-col--30">Importing status</div>
3026+ </div>
3027+ </header>
3028+ <main class="table__body">
3029+ <div class="table__row" data-ng-if="!generatedImages.length">
3030+ <div class="table__data table-col--100">
3031+ No images have been uploaded.
3032+ </div>
3033+ </div>
3034+ <div class="table__row" data-ng-repeat="image in generatedImages | orderBy:['-title', 'arch']">
3035+ <div class="table__data table-col--2">
3036+ <i class="icon {$ image.icon $}"></i>
3037+ </div>
3038+ <div class="table__data table-col--23">
3039+ {$ image.title $}
3040+ </div>
3041+ <div class="table__data table-col--15">{$ image.arch $}</div>
3042+ <div class="table__data table-col--20">{$ image.size $}</div>
3043+ <div class="table__data table-col--30">{$ image.status $}</div>
3044+ </div>
3045+ </main>
3046+ </section>
3047+ </div>
3048+</div>
3049+<div class="row" data-ng-if="design === 'page' && customImages.length">
3050+ <div class="wrapper--inner">
3051+ <h2>Custom Images</h2>
3052+ <section class="table u-margin--top">
3053+ <header class="table__head">
3054+ <div class="table__row">
3055+ <div class="table__header table-col--25">Release</div>
3056+ <div class="table__header table-col--15">Architecture</div>
3057+ <div class="table__header table-col--20">Size</div>
3058+ <div class="table__header table-col--30">Importing status</div>
3059+ </div>
3060+ </header>
3061+ <main class="table__body">
3062+ <div class="table__row" data-ng-if="!customImages.length">
3063+ <div class="table__data table-col--100">
3064+ No images have been uploaded.
3065+ </div>
3066+ </div>
3067+ <div class="table__row" data-ng-repeat="image in customImages | orderBy:['-title', 'arch']">
3068+ <div class="table__data table-col--2">
3069+ <i class="icon {$ image.icon $}"></i>
3070+ </div>
3071+ <div class="table__data table-col--23">
3072+ {$ image.title $}
3073+ </div>
3074+ <div class="table__data table-col--15">{$ image.arch $}</div>
3075+ <div class="table__data table-col--20">{$ image.size $}</div>
3076+ <div class="table__data table-col--30">{$ image.status $}</div>
3077+ </div>
3078+ </main>
3079+ </section>
3080+ </div>
3081+</div>
3082
3083=== added file 'src/maasserver/static/partials/images.html'
3084--- src/maasserver/static/partials/images.html 1970-01-01 00:00:00 +0000
3085+++ src/maasserver/static/partials/images.html 2016-09-14 21:01:21 +0000
3086@@ -0,0 +1,7 @@
3087+<header class="page-header margin-bottom">
3088+ <div class="wrapper--inner">
3089+ <h1 class="page-header__title">Boot Images</h1>
3090+ <maas-boot-images-status></maas-boot-images-status>
3091+ </div>
3092+</header>
3093+<maas-boot-images></maas-boot-images>
3094
3095=== modified file 'src/maasserver/templates/maasserver/base.html'
3096--- src/maasserver/templates/maasserver/base.html 2016-09-06 15:44:03 +0000
3097+++ src/maasserver/templates/maasserver/base.html 2016-09-14 21:01:21 +0000
3098@@ -115,7 +115,7 @@
3099 <a class="{% block nav-active-node-list %}{% endblock %}" href="{% url 'index' %}#/nodes">Nodes</a>
3100 </li>
3101 <li>
3102- <a class="{% block nav-active-images %}{% endblock %}" href="{% url 'images' %}">Images</a>
3103+ <a class="{% block nav-active-images %}{% endblock %}" href="{% url 'index' %}#/images">Images</a>
3104 </li>
3105 <li>
3106 <a class="{% block nav-active-domains-list %}{% endblock %}" href="{% url 'index' %}#/domains">DNS</a>
3107
3108=== modified file 'src/maasserver/templates/maasserver/index.html'
3109--- src/maasserver/templates/maasserver/index.html 2016-09-06 15:59:15 +0000
3110+++ src/maasserver/templates/maasserver/index.html 2016-09-14 21:01:21 +0000
3111@@ -92,7 +92,7 @@
3112 <a data-ng-class="{ active: page === 'nodes' }" href="#/nodes">Nodes</a>
3113 </li>
3114 <li>
3115- <a data-ng-class="{ active: page === 'images' }" href="{% url 'images' %}">Images</a>
3116+ <a data-ng-class="{ active: page === 'images' }" href="#/images">Images</a>
3117 </li>
3118 <li>
3119 <a data-ng-class="{ active: page === 'domains' }" href="#/domains">DNS</a>
3120
3121=== modified file 'src/maasserver/tests/test_bootresources.py'
3122--- src/maasserver/tests/test_bootresources.py 2016-08-31 06:26:21 +0000
3123+++ src/maasserver/tests/test_bootresources.py 2016-09-14 21:01:21 +0000
3124@@ -83,7 +83,9 @@
3125 get_one,
3126 post_commit_hooks,
3127 reload_object,
3128+ transactional,
3129 )
3130+from maasserver.utils.threads import deferToDatabase
3131 from maasserver.utils.version import get_maas_version_ui
3132 from maastesting.matchers import (
3133 MockCalledOnce,
3134@@ -112,6 +114,7 @@
3135 )
3136 from twisted.application.internet import TimerService
3137 from twisted.internet.defer import (
3138+ Deferred,
3139 fail,
3140 succeed,
3141 )
3142@@ -1149,6 +1152,44 @@
3143 written_data = stream.read()
3144 self.assertEqual(content, written_data)
3145
3146+ @asynchronous(timeout=1)
3147+ def test_finalize_calls_notify_errback(self):
3148+
3149+ @transactional
3150+ def create_store(testcase):
3151+ factory.make_BootResource(rtype=BOOT_RESOURCE_TYPE.SYNCED)
3152+ store = BootResourceStore()
3153+ testcase.patch(store, 'resource_cleaner')
3154+ testcase.patch(store, 'perform_write')
3155+ testcase.patch(store, 'resource_set_cleaner')
3156+ return store
3157+
3158+ notify = Deferred()
3159+ d = deferToDatabase(create_store, self)
3160+ d.addCallback(lambda store: store.finalize(notify=notify))
3161+ d.addCallback(lambda _: notify)
3162+ d.addErrback(lambda failure: failure.trap(Exception))
3163+ return d
3164+
3165+ @asynchronous(timeout=1)
3166+ def test_finalize_calls_notify_callback(self):
3167+
3168+ @transactional
3169+ def create_store(testcase):
3170+ factory.make_BootResource(rtype=BOOT_RESOURCE_TYPE.SYNCED)
3171+ store = BootResourceStore()
3172+ store._content_to_finalize = [sentinel.content]
3173+ testcase.patch(store, 'resource_cleaner')
3174+ testcase.patch(store, 'perform_write')
3175+ testcase.patch(store, 'resource_set_cleaner')
3176+ return store
3177+
3178+ notify = Deferred()
3179+ d = deferToDatabase(create_store, self)
3180+ d.addCallback(lambda store: store.finalize(notify=notify))
3181+ d.addCallback(lambda _: notify)
3182+ return d
3183+
3184
3185 class TestSetGlobalDefaultReleases(MAASServerTestCase):
3186
3187@@ -1300,7 +1341,7 @@
3188 sources=[], product_mapping=product_mapping, store=store)
3189 self.assertThat(
3190 fake_finalize,
3191- MockCalledOnceWith())
3192+ MockCalledOnceWith(notify=None))
3193
3194 def test__import_resources_exits_early_if_lock_held(self):
3195 set_simplestreams_env = self.patch_autospec(
3196@@ -1362,7 +1403,8 @@
3197 MockCalledOnceWith(descriptions))
3198 self.expectThat(
3199 download_all_boot_resources,
3200- MockCalledOnceWith([sentinel.source], sentinel.mapping))
3201+ MockCalledOnceWith(
3202+ [sentinel.source], sentinel.mapping, notify=None))
3203 self.expectThat(
3204 set_global_default_releases,
3205 MockCalledOnceWith())
3206@@ -1417,14 +1459,14 @@
3207 bootresources._import_resources_in_thread()
3208 self.assertThat(
3209 deferToDatabase, MockCalledOnceWith(
3210- bootresources._import_resources))
3211+ bootresources._import_resources, notify=None))
3212
3213 def tests__defaults_force_to_False(self):
3214 deferToDatabase = self.patch(bootresources, "deferToDatabase")
3215 bootresources._import_resources_in_thread()
3216 self.assertThat(
3217 deferToDatabase, MockCalledOnceWith(
3218- bootresources._import_resources))
3219+ bootresources._import_resources, notify=None))
3220
3221 def test__logs_errors_and_does_not_errback(self):
3222 logger = self.useFixture(TwistedLoggerFixture())
3223
3224=== modified file 'src/maasserver/views/combo.py'
3225--- src/maasserver/views/combo.py 2016-08-19 10:22:29 +0000
3226+++ src/maasserver/views/combo.py 2016-09-14 21:01:21 +0000
3227@@ -51,6 +51,7 @@
3228 "content_type": "text/javascript; charset=UTF-8",
3229 "files": [
3230 "js/angular/maas.js",
3231+ "js/angular/factories/bootresources.js",
3232 "js/angular/factories/dhcpsnippets.js",
3233 "js/angular/factories/packagerepositories.js",
3234 "js/angular/factories/region.js",
3235@@ -80,6 +81,7 @@
3236 "js/angular/services/converter.js",
3237 "js/angular/services/json.js",
3238 "js/angular/directives/accordion.js",
3239+ "js/angular/directives/boot_images.js",
3240 "js/angular/directives/call_to_action.js",
3241 "js/angular/directives/code_lines.js",
3242 "js/angular/directives/controller_image_status.js",
3243@@ -107,6 +109,7 @@
3244 "js/angular/controllers/add_hardware.js",
3245 "js/angular/controllers/add_device.js",
3246 "js/angular/controllers/add_domain.js",
3247+ "js/angular/controllers/images.js",
3248 "js/angular/controllers/node_details.js",
3249 "js/angular/controllers/node_details_networking.js",
3250 "js/angular/controllers/node_details_storage.js",
3251
3252=== modified file 'src/maasserver/websockets/handlers/bootresource.py'
3253--- src/maasserver/websockets/handlers/bootresource.py 2016-09-02 13:16:06 +0000
3254+++ src/maasserver/websockets/handlers/bootresource.py 2016-09-14 21:01:21 +0000
3255@@ -38,6 +38,8 @@
3256 Node,
3257 )
3258 from maasserver.utils.converters import human_readable_bytes
3259+from maasserver.utils.orm import transactional
3260+from maasserver.utils.threads import deferToDatabase
3261 from maasserver.utils.version import get_maas_version_ui
3262 from maasserver.websockets.base import (
3263 Handler,
3264@@ -52,6 +54,13 @@
3265 )
3266 from provisioningserver.import_images.keyrings import write_all_keyrings
3267 from provisioningserver.utils.fs import tempdir
3268+from provisioningserver.utils.twisted import (
3269+ asynchronous,
3270+ callOut,
3271+ FOREVER,
3272+)
3273+from twisted.internet.defer import Deferred
3274+from twisted.python import log
3275
3276
3277 def get_distro_series_info_row(series):
3278@@ -94,7 +103,7 @@
3279 'source_type': source_type,
3280 'url': source.url,
3281 'keyring_filename': source.keyring_filename,
3282- 'keyring_data': source.keyring_data.decode('ascii'),
3283+ 'keyring_data': bytes(source.keyring_data).decode("ascii"),
3284 })
3285 return sources
3286
3287@@ -157,6 +166,94 @@
3288 })
3289 return arches
3290
3291+ def check_if_image_matches_resource(self, resource, image):
3292+ """Return True if the resource matches the image."""
3293+ os, series = resource.name.split('/')
3294+ arch, subarch = resource.split_arch()
3295+ if os != image.os or series != image.release or arch != image.arch:
3296+ return False
3297+ if not resource.supports_subarch(subarch):
3298+ return False
3299+ return True
3300+
3301+ def get_matching_resource_for_image(self, resources, image):
3302+ """Return True if the image matches one of the resources."""
3303+ for resource in resources:
3304+ if self.check_if_image_matches_resource(resource, image):
3305+ return resource
3306+ return None
3307+
3308+ def get_other_synced_resources(self):
3309+ """Return all synced resources that are not Ubuntu."""
3310+ resources = list(BootResource.objects.filter(
3311+ rtype=BOOT_RESOURCE_TYPE.SYNCED).exclude(
3312+ name__startswith='ubuntu/').order_by('-name', 'architecture'))
3313+ for resource in resources:
3314+ self.add_resource_template_attributes(resource)
3315+ return resources
3316+
3317+ def add_resource_template_attributes(self, resource):
3318+ """Adds helper attributes to the resource."""
3319+ resource.title = self.get_resource_title(resource)
3320+ resource.arch, resource.subarch = resource.split_arch()
3321+ resource.number_of_nodes = self.get_number_of_nodes_deployed_for(
3322+ resource)
3323+ resource_set = resource.get_latest_set()
3324+ if resource_set is None:
3325+ resource.size = human_readable_bytes(0)
3326+ resource.last_update = resource.updated
3327+ resource.complete = False
3328+ resource.status = "Queued for download"
3329+ resource.downloading = False
3330+ else:
3331+ resource.size = human_readable_bytes(resource_set.total_size)
3332+ resource.last_update = resource_set.updated
3333+ resource.complete = resource_set.complete
3334+ if not resource.complete:
3335+ progress = resource_set.progress
3336+ if progress > 0:
3337+ resource.status = "Downloading %3.0f%%" % progress
3338+ resource.downloading = True
3339+ resource.icon = 'in-progress'
3340+ else:
3341+ resource.status = "Queued for download"
3342+ resource.downloading = False
3343+ resource.icon = 'queued'
3344+ else:
3345+ # See if the resource also exists on all the clusters.
3346+ if resource in self.rack_resources:
3347+ resource.status = "Synced"
3348+ resource.downloading = False
3349+ resource.icon = 'succeeded'
3350+ else:
3351+ resource.complete = False
3352+ if self.racks_syncing:
3353+ resource.status = "Syncing to rack controller(s)"
3354+ resource.downloading = True
3355+ resource.icon = 'in-progress'
3356+ else:
3357+ resource.status = (
3358+ "Waiting for rack controller(s) to sync")
3359+ resource.downloading = False
3360+ resource.icon = 'waiting'
3361+
3362+ def format_other_images(self):
3363+ """Return formatted other images for selection."""
3364+ resources = self.get_other_synced_resources()
3365+ images = []
3366+ for image in BootSourceCache.objects.exclude(os='ubuntu'):
3367+ resource = self.get_matching_resource_for_image(resources, image)
3368+ title = get_os_release_title(image.os, image.release)
3369+ if title is None:
3370+ title = '%s/%s' % (image.os, image.release)
3371+ images.append({
3372+ 'name': '%s/%s/%s/%s' % (
3373+ image.os, image.arch, image.subarch, image.release),
3374+ 'title': title,
3375+ 'checked': True if resource else False,
3376+ })
3377+ return images
3378+
3379 def node_has_architecture_for_resource(self, node, resource):
3380 """Return True if node is the same architecture as resource."""
3381 arch, _ = resource.split_arch()
3382@@ -302,24 +399,29 @@
3383 if progress > 0:
3384 resource.status = "Downloading %3.0f%%" % progress
3385 resource.downloading = True
3386+ resource.icon = 'in-progress'
3387 else:
3388 resource.status = "Queued for download"
3389 resource.downloading = False
3390+ resource.icon = 'queued'
3391 else:
3392 # See if all the resources exist on all the racks.
3393 rack_has_resources = any(
3394 res in group for res in self.rack_resources)
3395 if rack_has_resources:
3396- resource.status = "Complete"
3397+ resource.status = "Synced"
3398 resource.downloading = False
3399+ resource.icon = 'succeeded'
3400 else:
3401 resource.complete = False
3402 if self.racks_syncing:
3403 resource.status = "Syncing to rack controller(s)"
3404 resource.downloading = True
3405+ resource.icon = 'in-progress'
3406 else:
3407 resource.status = "Waiting for rack controller(s) to sync"
3408 resource.downloading = False
3409+ resource.icon = 'waiting'
3410 return resource
3411
3412 def combine_resources(self, resources):
3413@@ -380,7 +482,7 @@
3414 rtype=resource.rtype, name=resource.name,
3415 title=resource.title, arch=resource.arch, size=resource.size,
3416 complete=resource.complete, status=resource.status,
3417- downloading=resource.downloading,
3418+ icon=resource.icon, downloading=resource.downloading,
3419 numberOfNodes=resource.number_of_nodes,
3420 lastUpdate=resource.last_update.strftime(
3421 "%a, %d %b. %Y %H:%M:%S")
3422@@ -396,92 +498,150 @@
3423 region_import_running=is_import_resources_running(),
3424 rack_import_running=self.racks_syncing,
3425 resources=json_resources,
3426- ubuntu=json_ubuntu)
3427+ ubuntu=json_ubuntu,
3428+ other_images=self.format_other_images())
3429 return json.dumps(data)
3430
3431+ def get_bootsource(self, params, from_db=False):
3432+ source_type = params.get('source_type', 'custom')
3433+ if source_type == 'maas.io':
3434+ url = DEFAULT_IMAGES_URL
3435+ keyring_filename = DEFAULT_KEYRINGS_PATH
3436+ keyring_data = b''
3437+ elif source_type == 'custom':
3438+ url = params['url']
3439+ keyring_filename = params.get('keyring_filename', '')
3440+ keyring_data = params.get('keyring_data', '').encode('utf-8')
3441+ if keyring_filename == '' and keyring_data == b'':
3442+ keyring_filename = DEFAULT_KEYRINGS_PATH
3443+ else:
3444+ raise HandlerError('Unknown source_type: %s' % source_type)
3445+
3446+ if from_db:
3447+ source, created = BootSource.objects.get_or_create(
3448+ url=url,
3449+ defaults={
3450+ 'keyring_filename': keyring_filename,
3451+ 'keyring_data': keyring_data})
3452+ if not created:
3453+ source.keyring_filename = keyring_filename
3454+ source.keyring_data = keyring_data
3455+ source.save()
3456+ else:
3457+ # This was a new source, make sure its the only source in the
3458+ # database. This is because the UI only supports handling one
3459+ # source at a time.
3460+ BootSource.objects.exclude(id=source.id).delete()
3461+ return source
3462+ else:
3463+ return BootSource(
3464+ url=url,
3465+ keyring_filename=keyring_filename,
3466+ keyring_data=keyring_data,
3467+ )
3468+
3469+ @asynchronous(timeout=FOREVER)
3470 def save_ubuntu(self, params):
3471 """Called to save the Ubuntu section of the websocket."""
3472 # Must be administrator.
3473 assert self.user.is_superuser, "Permission denied."
3474
3475- os = 'ubuntu'
3476- releases = params['releases']
3477- arches = params['arches']
3478- boot_source = BootSource.objects.first()
3479-
3480- # Remove all selections, that are not of release.
3481- BootSourceSelection.objects.filter(
3482- boot_source=boot_source, os=os).exclude(
3483- release__in=releases).delete()
3484-
3485- if len(releases) > 0:
3486- # Create or update the selections.
3487- for release in releases:
3488+ @transactional
3489+ def update_source(params):
3490+ os = 'ubuntu'
3491+ releases = params['releases']
3492+ arches = params['arches']
3493+ boot_source = self.get_bootsource(params, from_db=True)
3494+
3495+ # Remove all selections, that are not of release.
3496+ BootSourceSelection.objects.filter(
3497+ boot_source=boot_source, os=os).exclude(
3498+ release__in=releases).delete()
3499+
3500+ if len(releases) > 0:
3501+ # Create or update the selections.
3502+ for release in releases:
3503+ selection, _ = BootSourceSelection.objects.get_or_create(
3504+ boot_source=boot_source, os=os, release=release)
3505+ selection.arches = arches
3506+ selection.subarches = ["*"]
3507+ selection.labels = ["*"]
3508+ selection.save()
3509+ else:
3510+ # Create a selection that will cause nothing to be downloaded,
3511+ # since no releases are selected.
3512 selection, _ = BootSourceSelection.objects.get_or_create(
3513- boot_source=boot_source, os=os, release=release)
3514+ boot_source=boot_source, os=os, release="")
3515 selection.arches = arches
3516 selection.subarches = ["*"]
3517 selection.labels = ["*"]
3518 selection.save()
3519- else:
3520- # Create a selection that will cause nothing to be downloaded,
3521- # since no releases are selected.
3522- selection, _ = BootSourceSelection.objects.get_or_create(
3523- boot_source=boot_source, os=os, release="")
3524- selection.arches = arches
3525- selection.subarches = ["*"]
3526- selection.labels = ["*"]
3527- selection.save()
3528-
3529- # Start the import process, now that the selections have changed.
3530- import_resources()
3531- return self.poll({})
3532-
3533+
3534+ notify = Deferred()
3535+ d = deferToDatabase(update_source, params)
3536+ d.addCallback(callOut, import_resources, notify=notify)
3537+ d.addCallback(lambda _: notify)
3538+ d.addCallback(lambda _: deferToDatabase(transactional(self.poll), {}))
3539+ d.addErrback(
3540+ log.err,
3541+ "Failed to start the image import. Unable to save the Ubuntu "
3542+ "image(s) source information.")
3543+ return d
3544+
3545+ @asynchronous(timeout=FOREVER)
3546 def save_other(self, params):
3547 """Update `BootSourceSelection`'s to only include the selected
3548 images."""
3549 # Must be administrator.
3550 assert self.user.is_superuser, "Permission denied."
3551- # Remove all selections that are not Ubuntu.
3552- BootSourceSelection.objects.exclude(os='ubuntu').delete()
3553-
3554- # Break down the images into os/release with multiple arches.
3555- selections = defaultdict(list)
3556- for image in params['images']:
3557- os, arch, _, release = image.split('/', 4)
3558- name = '%s/%s' % (os, release)
3559- selections[name].append(arch)
3560-
3561- # Create each selection for the source.
3562- for name, arches in selections.items():
3563- os, release = name.split('/')
3564- cache = BootSourceCache.objects.filter(
3565- os=os, arch=arch, release=release).first()
3566- if cache is None:
3567- # It is possible the cache changed while waiting for the user
3568- # to perform an action. Ignore the selection as its no longer
3569- # available.
3570- continue
3571- # Create the selection for the source.
3572- BootSourceSelection.objects.create(
3573- boot_source=cache.boot_source,
3574- os=os, release=release,
3575- arches=arches, subarches=["*"], labels=["*"])
3576-
3577- # Start the import process, now that the selections have changed.
3578- import_resources()
3579- return self.poll({})
3580+
3581+ @transactional
3582+ def update_selections(params):
3583+ # Remove all selections that are not Ubuntu.
3584+ BootSourceSelection.objects.exclude(os='ubuntu').delete()
3585+
3586+ # Break down the images into os/release with multiple arches.
3587+ selections = defaultdict(list)
3588+ for image in params['images']:
3589+ os, arch, _, release = image.split('/', 4)
3590+ name = '%s/%s' % (os, release)
3591+ selections[name].append(arch)
3592+
3593+ # Create each selection for the source.
3594+ for name, arches in selections.items():
3595+ os, release = name.split('/')
3596+ cache = BootSourceCache.objects.filter(
3597+ os=os, arch=arch, release=release).first()
3598+ if cache is None:
3599+ # It is possible the cache changed while waiting for the
3600+ # user to perform an action. Ignore the selection as its
3601+ # no longer available.
3602+ continue
3603+ # Create the selection for the source.
3604+ BootSourceSelection.objects.create(
3605+ boot_source=cache.boot_source,
3606+ os=os, release=release,
3607+ arches=arches, subarches=["*"], labels=["*"])
3608+
3609+ notify = Deferred()
3610+ d = deferToDatabase(update_selections, params)
3611+ d.addCallback(callOut, import_resources, notify=notify)
3612+ d.addCallback(lambda _: notify)
3613+ d.addCallback(lambda _: deferToDatabase(transactional(self.poll), {}))
3614+ d.addErrback(
3615+ log.err,
3616+ "Failed to start the image import. Unable to save the non-Ubuntu "
3617+ "image(s) source information")
3618+ return d
3619
3620 def fetch(self, params):
3621 """Fetch the releases and the arches from the provided source."""
3622 # Must be administrator.
3623 assert self.user.is_superuser, "Permission denied."
3624 # Build a source, but its not saved into the database.
3625- source = BootSource(
3626- url=params['url'],
3627- keyring_filename=params.get('keyring_filename', ''),
3628- keyring_data=params.get('keyring_data', '').encode('utf-8'),
3629- ).to_dict_without_selections()
3630+ source = self.get_bootsource(
3631+ params, from_db=False).to_dict_without_selections()
3632+
3633 # FIXME: This modifies the environment of the entire process, which is
3634 # Not Cool. We should integrate with simplestreams in a more
3635 # Pythonic manner.
3636
3637=== modified file 'src/maasserver/websockets/handlers/tests/test_bootresource.py'
3638--- src/maasserver/websockets/handlers/tests/test_bootresource.py 2016-09-01 20:45:56 +0000
3639+++ src/maasserver/websockets/handlers/tests/test_bootresource.py 2016-09-14 21:01:21 +0000
3640@@ -21,7 +21,10 @@
3641 )
3642 from maasserver.models.signals import bootsources
3643 from maasserver.testing.factory import factory
3644-from maasserver.testing.testcase import MAASServerTestCase
3645+from maasserver.testing.testcase import (
3646+ MAASServerTestCase,
3647+ MAASTransactionServerTestCase,
3648+)
3649 from maasserver.utils.converters import human_readable_bytes
3650 from maasserver.utils.orm import (
3651 get_one,
3652@@ -50,6 +53,7 @@
3653 ContainsAll,
3654 HasLength,
3655 )
3656+from twisted.internet import reactor
3657
3658
3659 class PatchOSInfoMixin:
3660@@ -74,6 +78,25 @@
3661 self.addCleanup(bootsources.signals.enable)
3662 bootsources.signals.disable()
3663
3664+ def make_other_resource(self, os=None, arch=None, subarch=None,
3665+ release=None):
3666+ if os is None:
3667+ os = factory.make_name('os')
3668+ if arch is None:
3669+ arch = factory.make_name('arch')
3670+ if subarch is None:
3671+ subarch = factory.make_name('subarch')
3672+ if release is None:
3673+ release = factory.make_name('release')
3674+ name = '%s/%s' % (os, release)
3675+ architecture = '%s/%s' % (arch, subarch)
3676+ resource = factory.make_BootResource(
3677+ rtype=BOOT_RESOURCE_TYPE.SYNCED,
3678+ name=name, architecture=architecture)
3679+ resource_set = factory.make_BootResourceSet(resource)
3680+ factory.make_boot_resource_file_with_content(resource_set)
3681+ return resource
3682+
3683 def test__returns_connection_error_True(self):
3684 owner = factory.make_admin()
3685 handler = BootResourceHandler(owner, {})
3686@@ -237,7 +260,7 @@
3687 json_resource,
3688 ContainsAll([
3689 'id', 'rtype', 'name', 'title', 'arch', 'size',
3690- 'complete', 'status', 'downloading',
3691+ 'complete', 'status', 'icon', 'downloading',
3692 'numberOfNodes', 'lastUpdate']))
3693
3694 def test_returns_ubuntu_release_version_name(self):
3695@@ -431,6 +454,7 @@
3696 json_obj = json.loads(response)
3697 json_resource = json_obj['resources'][0]
3698 self.assertEqual("Downloading 50%", json_resource['status'])
3699+ self.assertEqual("in-progress", json_resource['icon'])
3700
3701 def test_combined_subarch_resource_shows_queued_if_no_progress(self):
3702 owner = factory.make_admin()
3703@@ -449,6 +473,7 @@
3704 json_obj = json.loads(response)
3705 json_resource = json_obj['resources'][0]
3706 self.assertEqual("Queued for download", json_resource['status'])
3707+ self.assertEqual("queued", json_resource['icon'])
3708
3709 def test_combined_subarch_resource_shows_complete_status(self):
3710 owner = factory.make_admin()
3711@@ -468,7 +493,8 @@
3712 response = handler.poll({})
3713 json_obj = json.loads(response)
3714 json_resource = json_obj['resources'][0]
3715- self.assertEqual("Complete", json_resource['status'])
3716+ self.assertEqual("Synced", json_resource['status'])
3717+ self.assertEqual("succeeded", json_resource['icon'])
3718
3719 def test_combined_subarch_resource_shows_waiting_for_cluster_to_sync(self):
3720 owner = factory.make_admin()
3721@@ -488,6 +514,7 @@
3722 json_resource = json_obj['resources'][0]
3723 self.assertEqual(
3724 "Waiting for rack controller(s) to sync", json_resource['status'])
3725+ self.assertEqual("waiting", json_resource['icon'])
3726
3727 def test_combined_subarch_resource_shows_clusters_syncing(self):
3728 owner = factory.make_admin()
3729@@ -509,9 +536,42 @@
3730 json_resource = json_obj['resources'][0]
3731 self.assertEqual(
3732 "Syncing to rack controller(s)", json_resource['status'])
3733-
3734-
3735-class TestBootResourceSaveUbuntu(MAASServerTestCase, PatchOSInfoMixin):
3736+ self.assertEqual("in-progress", json_resource['icon'])
3737+
3738+ def test_other_images_returns_images_from_cache(self):
3739+ owner = factory.make_admin()
3740+ handler = BootResourceHandler(owner, {})
3741+ cache = factory.make_BootSourceCache()
3742+ response = handler.poll({})
3743+ json_obj = json.loads(response)
3744+ other_images = json_obj['other_images']
3745+ self.assertEquals([{
3746+ 'name': '%s/%s/%s/%s' % (
3747+ cache.os, cache.arch, cache.subarch, cache.release),
3748+ 'title': '%s/%s' % (cache.os, cache.release),
3749+ 'checked': False,
3750+ }], other_images)
3751+
3752+ def test_other_images_returns_image_checked_when_synced(self):
3753+ owner = factory.make_admin()
3754+ handler = BootResourceHandler(owner, {})
3755+ cache = factory.make_BootSourceCache()
3756+ self.make_other_resource(
3757+ os=cache.os, arch=cache.arch,
3758+ subarch=cache.subarch, release=cache.release)
3759+ response = handler.poll({})
3760+ json_obj = json.loads(response)
3761+ other_images = json_obj['other_images']
3762+ self.assertEquals([{
3763+ 'name': '%s/%s/%s/%s' % (
3764+ cache.os, cache.arch, cache.subarch, cache.release),
3765+ 'title': '%s/%s' % (cache.os, cache.release),
3766+ 'checked': True,
3767+ }], other_images)
3768+
3769+
3770+class TestBootResourceSaveUbuntu(
3771+ MAASTransactionServerTestCase, PatchOSInfoMixin):
3772
3773 def setUp(self):
3774 super(TestBootResourceSaveUbuntu, self).setUp()
3775@@ -519,6 +579,12 @@
3776 self.addCleanup(bootsources.signals.enable)
3777 bootsources.signals.disable()
3778
3779+ def patch_import_resources(self):
3780+ mock_import = self.patch(bootresource, 'import_resources')
3781+ mock_import.side_effect = (
3782+ lambda notify: reactor.callLater(0, notify.callback, None))
3783+ return mock_import
3784+
3785 def test_asserts_is_admin(self):
3786 owner = factory.make_User()
3787 handler = BootResourceHandler(owner, {})
3788@@ -529,17 +595,19 @@
3789 handler = BootResourceHandler(owner, {})
3790 sources = [factory.make_BootSource()]
3791 self.patch_get_os_info_from_boot_sources(sources)
3792- mock_import = self.patch(bootresource, 'import_resources')
3793- handler.save_ubuntu({'releases': [], 'arches': []})
3794- self.assertThat(mock_import, MockCalledOnceWith())
3795+ mock_import = self.patch_import_resources()
3796+ handler.save_ubuntu(
3797+ {'url': sources[0].url, 'releases': [], 'arches': []})
3798+ self.assertThat(mock_import, MockCalledOnceWith(notify=ANY))
3799
3800 def test_sets_empty_selections(self):
3801 owner = factory.make_admin()
3802 handler = BootResourceHandler(owner, {})
3803 source = factory.make_BootSource()
3804 self.patch_get_os_info_from_boot_sources([source])
3805- self.patch(bootresource, 'import_resources')
3806- handler.save_ubuntu({'releases': [], 'arches': []})
3807+ self.patch_import_resources()
3808+ handler.save_ubuntu(
3809+ {'url': source.url, 'releases': [], 'arches': []})
3810
3811 selections = BootSourceSelection.objects.filter(boot_source=source)
3812 self.assertThat(selections, HasLength(1))
3813@@ -555,8 +623,9 @@
3814 source = factory.make_BootSource()
3815 releases = [factory.make_name('release') for _ in range(3)]
3816 self.patch_get_os_info_from_boot_sources([source])
3817- self.patch(bootresource, 'import_resources')
3818- handler.save_ubuntu({'releases': releases, 'arches': []})
3819+ self.patch_import_resources()
3820+ handler.save_ubuntu(
3821+ {'url': source.url, 'releases': releases, 'arches': []})
3822
3823 selections = BootSourceSelection.objects.filter(boot_source=source)
3824 self.assertThat(selections, HasLength(len(releases)))
3825@@ -571,8 +640,9 @@
3826 releases = [factory.make_name('release') for _ in range(3)]
3827 arches = [factory.make_name('arches') for _ in range(3)]
3828 self.patch_get_os_info_from_boot_sources([source])
3829- self.patch(bootresource, 'import_resources')
3830- handler.save_ubuntu({'releases': releases, 'arches': arches})
3831+ self.patch_import_resources()
3832+ handler.save_ubuntu(
3833+ {'url': source.url, 'releases': releases, 'arches': arches})
3834
3835 selections = BootSourceSelection.objects.filter(boot_source=source)
3836 self.assertThat(selections, HasLength(len(releases)))
3837@@ -591,13 +661,14 @@
3838 keep_selection = BootSourceSelection.objects.create(
3839 boot_source=source, os='ubuntu', release=release)
3840 self.patch_get_os_info_from_boot_sources([source])
3841- self.patch(bootresource, 'import_resources')
3842- handler.save_ubuntu({'releases': [release], 'arches': []})
3843+ self.patch_import_resources()
3844+ handler.save_ubuntu(
3845+ {'url': source.url, 'releases': [release], 'arches': []})
3846 self.assertIsNone(reload_object(delete_selection))
3847 self.assertIsNotNone(reload_object(keep_selection))
3848
3849
3850-class TestBootResourceSaveOther(MAASServerTestCase):
3851+class TestBootResourceSaveOther(MAASTransactionServerTestCase):
3852
3853 def setUp(self):
3854 super(TestBootResourceSaveOther, self).setUp()
3855@@ -624,6 +695,12 @@
3856 factory.make_boot_resource_file_with_content(resource_set)
3857 return resource
3858
3859+ def patch_import_resources(self):
3860+ mock_import = self.patch(bootresource, 'import_resources')
3861+ mock_import.side_effect = (
3862+ lambda notify: reactor.callLater(0, notify.callback, None))
3863+ return mock_import
3864+
3865 def test_asserts_is_admin(self):
3866 owner = factory.make_User()
3867 handler = BootResourceHandler(owner, {})
3868@@ -637,7 +714,7 @@
3869 boot_source=source, os='ubuntu')
3870 other_selection = BootSourceSelection.objects.create(
3871 boot_source=source, os=factory.make_name('os'))
3872- self.patch(bootresource, 'import_resources')
3873+ self.patch_import_resources()
3874 handler.save_other({'images': []})
3875 self.assertIsNotNone(reload_object(ubuntu_selection))
3876 self.assertIsNone(reload_object(other_selection))
3877@@ -654,7 +731,7 @@
3878 factory.make_BootSourceCache(
3879 boot_source=source, os=os, release=release, arch=arch)
3880 images.append('%s/%s/subarch/%s' % (os, arch, release))
3881- self.patch(bootresource, 'import_resources')
3882+ self.patch_import_resources()
3883 handler.save_other({'images': images})
3884
3885 selection = get_one(BootSourceSelection.objects.filter(
3886@@ -665,9 +742,9 @@
3887 def test_calls_import_resources(self):
3888 owner = factory.make_admin()
3889 handler = BootResourceHandler(owner, {})
3890- mock_import = self.patch(bootresource, 'import_resources')
3891+ mock_import = self.patch_import_resources()
3892 handler.save_other({'images': []})
3893- self.assertThat(mock_import, MockCalledOnceWith())
3894+ self.assertThat(mock_import, MockCalledOnceWith(notify=ANY))
3895
3896
3897 class TestBootResourceFetch(MAASServerTestCase):