luci-app-attendedsysupgrade: introduce rebuilders
authorPaul Spooren <mail@aparcar.org>
Thu, 31 Mar 2022 15:01:31 +0000 (16:01 +0100)
committerPaul Spooren <mail@aparcar.org>
Fri, 12 May 2023 18:19:58 +0000 (20:19 +0200)
This adds automatic verification builds to shift trust on multiple
server and multiple entities.

Signed-off-by: Paul Spooren <mail@aparcar.org>
applications/luci-app-attendedsysupgrade/htdocs/luci-static/resources/view/attendedsysupgrade/configuration.js
applications/luci-app-attendedsysupgrade/htdocs/luci-static/resources/view/attendedsysupgrade/overview.js

index 2d539c5e68e238d94370c34dc05e717023fa0862..d5d1ddfbf4c108415f958d586c68a8e1c33a04ff 100644 (file)
@@ -3,28 +3,53 @@
 'require form';
 
 return view.extend({
-       render: function() {
-               var m, s, o;
+       render: function () {
+               let m, s, o;
 
-               m = new form.Map('attendedsysupgrade', _('Attended Sysupgrade'),
-                                _('Attendedsysupgrade Configuration.'));
+               m = new form.Map(
+                       'attendedsysupgrade',
+                       _('Attended Sysupgrade'),
+                       _('Attendedsysupgrade Configuration.')
+               );
 
                s = m.section(form.TypedSection, 'server', _('Server'));
                s.anonymous = true;
 
-               s.option(form.Value, 'url', _('Address'),
-                        _('Address of the sysupgrade server'));
+               s.option(
+                       form.Value,
+                       'url',
+                       _('Address'),
+                       _('Address of the sysupgrade server')
+               );
+
+               s.option(
+                       form.DynamicList,
+                       'rebuilder',
+                       _('Rebuilders'),
+                       _(
+                               'Other ASU server instances that rebuild a requested image. ' +
+                                       'Allows to compare checksums and verify that the results are the same.'
+                       )
+               );
 
                s = m.section(form.TypedSection, 'client', _('Client'));
                s.anonymous = true;
 
-               o = s.option(form.Flag, 'auto_search', _('Search on opening'),
-                _('Search for new sysupgrades on opening the tab'));
+               o = s.option(
+                       form.Flag,
+                       'auto_search',
+                       _('Search on opening'),
+                       _('Search for new sysupgrades on opening the tab')
+               );
                o.default = '1';
                o.rmempty = false;
 
-               o = s.option(form.Flag, 'advanced_mode', _('Advanced Mode'),
-                _('Show advanced options like package list modification'));
+               o = s.option(
+                       form.Flag,
+                       'advanced_mode',
+                       _('Advanced Mode'),
+                       _('Show advanced options like package list modification')
+               );
                o.default = '0';
                o.rmempty = false;
 
index 55ed0509e16eda799f0f2a253ef9194a306804a5..76e504086efcd1f86c162a30d2e794118ea80fc9 100644 (file)
@@ -9,17 +9,17 @@
 'require dom';
 'require fs';
 
-var callPackagelist = rpc.declare({
+let callPackagelist = rpc.declare({
        object: 'rpc-sys',
        method: 'packagelist',
 });
 
-var callSystemBoard = rpc.declare({
+let callSystemBoard = rpc.declare({
        object: 'system',
        method: 'board',
 });
 
-var callUpgradeStart = rpc.declare({
+let callUpgradeStart = rpc.declare({
        object: 'rpc-sys',
        method: 'upgrade_start',
        params: ['keep'],
@@ -28,14 +28,14 @@ var callUpgradeStart = rpc.declare({
 /**
  * Returns the branch of a given version. This helps to offer upgrades
  * for point releases (aka within the branch).
- * 
+ *
  * Logic:
  * SNAPSHOT -> SNAPSHOT
  * 21.02-SNAPSHOT -> 21.02
  * 21.02.0-rc1 -> 21.02
  * 19.07.8 -> 19.07
- * 
- * @param {string} version 
+ *
+ * @param {string} version
  * Input version from which to determine the branch
  * @returns {string}
  * The determined branch
@@ -45,10 +45,10 @@ function get_branch(version) {
 }
 
 /**
- * The OpenWrt revision string contains both a hash as well as the number 
- * commits since the OpenWrt/LEDE reboot. It helps to determine if a 
+ * The OpenWrt revision string contains both a hash as well as the number
+ * commits since the OpenWrt/LEDE reboot. It helps to determine if a
  * snapshot is newer than another.
- * 
+ *
  * @param {string} revision
  * Revision string of a OpenWrt device
  * @returns {integer}
@@ -60,17 +60,19 @@ function get_revision_count(revision) {
 
 return view.extend({
        steps: {
-               init: _('10% Received build request'),
-               download_imagebuilder: _('20% Downloading ImageBuilder archive'),
-               unpack_imagebuilder: _('40% Setup ImageBuilder'),
-               calculate_packages_hash: _('60% Validate package selection'),
-               building_image: _('80% Generating firmware image')
+               init: [10, _('Received build request')],
+               download_imagebuilder: [20, _('Downloading ImageBuilder archive')],
+               unpack_imagebuilder: [40, _('Setup ImageBuilder')],
+               calculate_packages_hash: [60, _('Validate package selection')],
+               building_image: [80, _('Generating firmware image')],
        },
 
        data: {
                url: '',
                revision: '',
                advanced_mode: 0,
+               rebuilder: [],
+               sha256_unsigned: '',
        },
 
        firmware: {
@@ -82,74 +84,107 @@ return view.extend({
                filesystem: '',
        },
 
-       handle200: function (response) {
-               response = response.json();
-               var image;
-               for (image of response.images) {
+       selectImage: function (images) {
+               let image;
+               for (image of images) {
                        if (this.firmware.filesystem == image.filesystem) {
                                if (this.data.efi) {
                                        if (image.type == 'combined-efi') {
-                                               break;
+                                               return image;
                                        }
                                } else {
                                        if (image.type == 'sysupgrade' || image.type == 'combined') {
-                                               break;
+                                               return image;
                                        }
                                }
                        }
                }
+               return null;
+       },
+
+       handle200: function (response) {
+               response = response.json();
+               let image = this.selectImage(response.images);
 
                if (image.name != undefined) {
-                       var sysupgrade_url = `${this.data.url}/store/${response.bin_dir}/${image.name}`;
+                       this.data.sha256_unsigned = image.sha256_unsigned;
+                       let sysupgrade_url = `${this.data.url}/store/${response.bin_dir}/${image.name}`;
 
-                       var keep = E('input', { type: 'checkbox' });
+                       let keep = E('input', { type: 'checkbox' });
                        keep.checked = true;
 
-                       var fields = [
-                               _('Version'), `${response.version_number} ${response.version_code}`,
-                               _('SHA256'), image.sha256,
+                       let fields = [
+                               _('Version'),
+                               `${response.version_number} ${response.version_code}`,
+                               _('SHA256'),
+                               image.sha256,
                        ];
 
                        if (this.data.advanced_mode == 1) {
                                fields.push(
-                                       _('Profile'), response.id,
-                                       _('Target'), response.target,
-                                       _('Build Date'), response.build_at,
-                                       _('Filename'), image.name,
-                                       _('Filesystem'), image.filesystem,
-                               )
+                                       _('Profile'),
+                                       response.id,
+                                       _('Target'),
+                                       response.target,
+                                       _('Build Date'),
+                                       response.build_at,
+                                       _('Filename'),
+                                       image.name,
+                                       _('Filesystem'),
+                                       image.filesystem
+                               );
                        }
 
-                       fields.push('', E('a', { href: sysupgrade_url }, _('Download firmware image')))
+                       fields.push(
+                               '',
+                               E('a', { href: sysupgrade_url }, _('Download firmware image'))
+                       );
+                       if (this.data.rebuilder) {
+                               fields.push(_('Rebuilds'), E('div', { id: 'rebuilder_status' }));
+                       }
 
-                       var table = E('div', { class: 'table' });
+                       let table = E('div', { class: 'table' });
 
-                       for (var i = 0; i < fields.length; i += 2) {
-                               table.appendChild(E('tr', { class: 'tr' }, [
-                                       E('td', { class: 'td left', width: '33%' }, [fields[i]]),
-                                       E('td', { class: 'td left' }, [fields[i + 1]]),
-                               ]));
+                       for (let i = 0; i < fields.length; i += 2) {
+                               table.appendChild(
+                                       E('tr', { class: 'tr' }, [
+                                               E('td', { class: 'td left', width: '33%' }, [fields[i]]),
+                                               E('td', { class: 'td left' }, [fields[i + 1]]),
+                                       ])
+                               );
                        }
 
-                       var modal_body = [
+                       let modal_body = [
                                table,
-                               E('p', { class: 'mt-2' },
+                               E(
+                                       'p',
+                                       { class: 'mt-2' },
                                        E('label', { class: 'btn' }, [
-                                               keep, ' ',
-                                               _('Keep settings and retain the current configuration')
-                                       ])),
+                                               keep,
+                                               ' ',
+                                               _('Keep settings and retain the current configuration'),
+                                       ])
+                               ),
                                E('div', { class: 'right' }, [
-                                       E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')), ' ',
-                                       E('button', {
-                                               'class': 'btn cbi-button cbi-button-positive important',
-                                               'click': ui.createHandlerFn(this, function () {
-                                                       this.handleInstall(sysupgrade_url, keep.checked, image.sha256)
-                                               })
-                                       }, _('Install firmware image')),
+                                       E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
+                                       ' ',
+                                       E(
+                                               'button',
+                                               {
+                                                       class: 'btn cbi-button cbi-button-positive important',
+                                                       click: ui.createHandlerFn(this, function () {
+                                                               this.handleInstall(sysupgrade_url, keep.checked, image.sha256);
+                                                       }),
+                                               },
+                                               _('Install firmware image')
+                                       ),
                                ]),
                        ];
 
                        ui.showModal(_('Successfully created firmware image'), modal_body);
+                       if (this.data.rebuilder) {
+                               this.handleRebuilder();
+                       }
                }
        },
 
@@ -159,20 +194,37 @@ return view.extend({
 
                if ('queue_position' in response) {
                        ui.showModal(_('Queued...'), [
-                               E('p', { 'class': 'spinning' }, _('Request in build queue position %s').format(response.queue_position))
+                               E(
+                                       'p',
+                                       { class: 'spinning' },
+                                       _('Request in build queue position %s').format(
+                                               response.queue_position
+                                       )
+                               ),
                        ]);
                } else {
                        ui.showModal(_('Building Firmware...'), [
-                               E('p', { 'class': 'spinning' }, _('Progress: %s').format(this.steps[response.imagebuilder_status]))
+                               E(
+                                       'p',
+                                       { class: 'spinning' },
+                                       _('Progress: %s%% %s').format(
+                                               this.steps[response.imagebuilder_status][0],
+                                               this.steps[response.imagebuilder_status][1]
+                                       )
+                               ),
                        ]);
                }
        },
 
        handleError: function (response) {
                response = response.json();
-               var body = [
+               let body = [
                        E('p', {}, _('Server response: %s').format(response.detail)),
-                       E('a', { href: 'https://github.com/openwrt/asu/issues' }, _('Please report the error message and request')),
+                       E(
+                               'a',
+                               { href: 'https://github.com/openwrt/asu/issues' },
+                               _('Please report the error message and request')
+                       ),
                        E('p', {}, _('Request Data:')),
                        E('pre', {}, JSON.stringify({ ...this.data, ...this.firmware }, null, 4)),
                ];
@@ -196,61 +248,118 @@ return view.extend({
                ui.showModal(_('Error building the firmware image'), body);
        },
 
-       handleRequest: function () {
-               var request_url = `${this.data.url}/api/v1/build`;
-               var method = "POST"
-               var content = this.firmware;
+       handleRequest: function (server, main) {
+               let request_url = `${server}/api/v1/build`;
+               let method = 'POST';
+               let content = this.firmware;
 
                /**
-                * If `request_hash` is available use a GET request instead of 
+                * If `request_hash` is available use a GET request instead of
                 * sending the entire object.
                 */
-               if (this.data.request_hash) {
+               if (this.data.request_hash && main == true) {
                        request_url += `/${this.data.request_hash}`;
                        content = {};
-                       method = "GET"
+                       method = 'GET';
                }
 
-               request.request(request_url, { method: method, content: content })
+               request
+                       .request(request_url, { method: method, content: content })
                        .then((response) => {
                                switch (response.status) {
                                        case 202:
-                                               this.handle202(response);
+                                               if (main) {
+                                                       this.handle202(response);
+                                               } else {
+                                                       response = response.json();
+
+                                                       let view = document.getElementById(server);
+                                                       view.innerText = `⏳   (${
+                                                               this.steps[response.imagebuilder_status][0]
+                                                       }%) ${server}`;
+                                               }
                                                break;
                                        case 200:
-                                               poll.stop();
-                                               this.handle200(response);
+                                               if (main == true) {
+                                                       poll.remove(this.pollFn);
+                                                       this.handle200(response);
+                                               } else {
+                                                       poll.remove(this.rebuilder_polls[server]);
+                                                       response = response.json();
+                                                       let view = document.getElementById(server);
+                                                       let image = this.selectImage(response.images);
+                                                       if (image.sha256_unsigned == this.data.sha256_unsigned) {
+                                                               view.innerText = '✅ %s'.format(server);
+                                                       } else {
+                                                               view.innerHTML = `⚠️ ${server} (<a href="${server}/store/${
+                                                                       response.bin_dir
+                                                               }/${image.name}">${_('Download')}</a>)`;
+                                                       }
+                                               }
                                                break;
                                        case 400: // bad request
                                        case 422: // bad package
                                        case 500: // build failed
-                                               poll.stop();
-                                               this.handleError(response);
-                                               break;
+                                               if (main == true) {
+                                                       poll.remove(this.pollFn);
+                                                       this.handleError(response);
+                                                       break;
+                                               } else {
+                                                       poll.remove(this.rebuilder_polls[server]);
+                                                       document.getElementById(server).innerText = '🚫 %s'.format(
+                                                               server
+                                                       );
+                                               }
                                }
                        });
        },
 
+       handleRebuilder: function () {
+               this.rebuilder_polls = {};
+               for (let rebuilder of this.data.rebuilder) {
+                       this.rebuilder_polls[rebuilder] = L.bind(
+                               this.handleRequest,
+                               this,
+                               rebuilder,
+                               false
+                       );
+                       poll.add(this.rebuilder_polls[rebuilder], 5);
+                       document.getElementById(
+                               'rebuilder_status'
+                       ).innerHTML += `<p id="${rebuilder}">⏳ ${rebuilder}</p>`;
+               }
+               poll.start();
+       },
+
        handleInstall: function (url, keep, sha256) {
                ui.showModal(_('Downloading...'), [
-                       E('p', { 'class': 'spinning' }, _('Downloading firmware from server to browser'))
+                       E(
+                               'p',
+                               { class: 'spinning' },
+                               _('Downloading firmware from server to browser')
+                       ),
                ]);
 
-               request.get(url, {
-                       headers: {
-                               'Content-Type': 'application/x-www-form-urlencoded',
-                       },
-                       responseType: 'blob',
-               })
+               request
+                       .get(url, {
+                               headers: {
+                                       'Content-Type': 'application/x-www-form-urlencoded',
+                               },
+                               responseType: 'blob',
+                       })
                        .then((response) => {
-                               var form_data = new FormData();
+                               let form_data = new FormData();
                                form_data.append('sessionid', rpc.getSessionID());
                                form_data.append('filename', '/tmp/firmware.bin');
                                form_data.append('filemode', 600);
                                form_data.append('filedata', response.blob());
 
                                ui.showModal(_('Uploading...'), [
-                                       E('p', { 'class': 'spinning' }, _('Uploading firmware from browser to device'))
+                                       E(
+                                               'p',
+                                               { class: 'spinning' },
+                                               _('Uploading firmware from browser to device')
+                                       ),
                                ]);
 
                                request
@@ -261,164 +370,219 @@ return view.extend({
                                        .then((response) => response.json())
                                        .then((response) => {
                                                if (response.sha256sum != sha256) {
-
                                                        ui.showModal(_('Wrong checksum'), [
-                                                               E('p', _('Error during download of firmware. Please try again')),
-                                                               E('div', { class: 'btn', click: ui.hideModal }, _('Close'))
+                                                               E(
+                                                                       'p',
+                                                                       _('Error during download of firmware. Please try again')
+                                                               ),
+                                                               E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
                                                        ]);
                                                } else {
                                                        ui.showModal(_('Installing...'), [
-                                                               E('p', { class: 'spinning' }, _('Installing the sysupgrade. Do not unpower device!'))
+                                                               E(
+                                                                       'p',
+                                                                       { class: 'spinning' },
+                                                                       _('Installing the sysupgrade. Do not unpower device!')
+                                                               ),
                                                        ]);
 
-                                                       L.resolveDefault(callUpgradeStart(keep), {})
-                                                               .then((response) => {
-                                                                       if (keep) {
-                                                                               ui.awaitReconnect(window.location.host);
-                                                                       } else {
-                                                                               ui.awaitReconnect('192.168.1.1', 'openwrt.lan');
-                                                                       }
-                                                               });
+                                                       L.resolveDefault(callUpgradeStart(keep), {}).then((response) => {
+                                                               if (keep) {
+                                                                       ui.awaitReconnect(window.location.host);
+                                                               } else {
+                                                                       ui.awaitReconnect('192.168.1.1', 'openwrt.lan');
+                                                               }
+                                                       });
                                                }
                                        });
                        });
        },
 
        handleCheck: function () {
-               var { url, revision } = this.data
-               var { version, target } = this.firmware
-               var candidates = [];
-               var response;
-               var request_url = `${url}/api/overview`;
+               let { url, revision } = this.data;
+               let { version, target } = this.firmware;
+               let candidates = [];
+               let request_url = `${url}/api/overview`;
                if (version.endsWith('SNAPSHOT')) {
                        request_url = `${url}/api/v1/revision/${version}/${target}`;
                }
 
                ui.showModal(_('Searching...'), [
-                       E('p', { 'class': 'spinning' },
-                               _('Searching for an available sysupgrade of %s - %s').format(version, revision))
+                       E(
+                               'p',
+                               { class: 'spinning' },
+                               _('Searching for an available sysupgrade of %s - %s').format(
+                                       version,
+                                       revision
+                               )
+                       ),
                ]);
 
-               L.resolveDefault(request.get(request_url))
-                       .then(response => {
-                               if (!response.ok) {
-                                       ui.showModal(_('Error connecting to upgrade server'), [
-                                               E('p', {}, _('Could not reach API at "%s". Please try again later.').format(response.url)),
-                                               E('pre', {}, response.responseText),
-                                               E('div', { class: 'right' }, [
-                                                       E('div', { class: 'btn', click: ui.hideModal }, _('Close'))
-                                               ]),
-                                       ]);
-                                       return;
+               L.resolveDefault(request.get(request_url)).then((response) => {
+                       if (!response.ok) {
+                               ui.showModal(_('Error connecting to upgrade server'), [
+                                       E(
+                                               'p',
+                                               {},
+                                               _('Could not reach API at "%s". Please try again later.').format(
+                                                       response.url
+                                               )
+                                       ),
+                                       E('pre', {}, response.responseText),
+                                       E('div', { class: 'right' }, [
+                                               E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
+                                       ]),
+                               ]);
+                               return;
+                       }
+                       if (version.endsWith('SNAPSHOT')) {
+                               const remote_revision = response.json().revision;
+                               if (
+                                       get_revision_count(revision) < get_revision_count(remote_revision)
+                               ) {
+                                       candidates.push([version, remote_revision]);
                                }
-                               if (version.endsWith('SNAPSHOT')) {
-                                       const remote_revision = response.json().revision;
-                                       if (get_revision_count(revision) < get_revision_count(remote_revision)) {
-                                               candidates.push([version, remote_revision]);
-                                       }
-                               } else {
-                                       const latest = response.json().latest;
+                       } else {
+                               const latest = response.json().latest;
 
-                                       for (let remote_version of latest) {
-                                               var remote_branch = get_branch(remote_version);
+                               for (let remote_version of latest) {
+                                       let remote_branch = get_branch(remote_version);
 
-                                               // already latest version installed
-                                               if (version == remote_version) {
-                                                       break;
-                                               }
+                                       // already latest version installed
+                                       if (version == remote_version) {
+                                               break;
+                                       }
 
-                                               // skip branch upgrades outside the advanced mode
-                                               if (this.data.branch != remote_branch && this.data.advanced_mode == 0) {
-                                                       continue;
-                                               }
+                                       // skip branch upgrades outside the advanced mode
+                                       if (
+                                               this.data.branch != remote_branch &&
+                                               this.data.advanced_mode == 0
+                                       ) {
+                                               continue;
+                                       }
 
-                                               candidates.unshift([remote_version, null]);
+                                       candidates.unshift([remote_version, null]);
 
-                                               // don't offer branches older than the current
-                                               if (this.data.branch == remote_branch) {
-                                                       break;
-                                               }
+                                       // don't offer branches older than the current
+                                       if (this.data.branch == remote_branch) {
+                                               break;
                                        }
                                }
+                       }
 
-                               // allow to re-install running firmware in advanced mode
-                               if (this.data.advanced_mode == 1) {
-                                       candidates.unshift([version, revision])
-                               }
-
-                               if (candidates.length) {
-                                       var m, s, o;
-
-                                       var mapdata = {
-                                               request: {
-                                                       profile: this.firmware.profile,
-                                                       version: candidates[0][0],
-                                                       packages: Object.keys(this.firmware.packages).sort(),
-                                               },
-                                       };
-
-                                       var map = new form.JSONMap(mapdata, '');
+                       // allow to re-install running firmware in advanced mode
+                       if (this.data.advanced_mode == 1) {
+                               candidates.unshift([version, revision]);
+                       }
 
-                                       s = map.section(form.NamedSection, 'request', '', '', 'Use defaults for the safest update');
-                                       o = s.option(form.ListValue, 'version', 'Select firmware version');
-                                       for (let candidate of candidates) {
-                                               if (candidate[0] == version && candidate[1] == revision) {
-                                                       o.value(candidate[0], _('[installed] %s')
-                                                               .format(candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]));
-                                               } else {
-                                                       o.value(candidate[0], candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]);
-                                               }
+                       if (candidates.length) {
+                               let s, o;
+
+                               let mapdata = {
+                                       request: {
+                                               profile: this.firmware.profile,
+                                               version: candidates[0][0],
+                                               packages: Object.keys(this.firmware.packages).sort(),
+                                       },
+                               };
+
+                               let map = new form.JSONMap(mapdata, '');
+
+                               s = map.section(
+                                       form.NamedSection,
+                                       'request',
+                                       '',
+                                       '',
+                                       'Use defaults for the safest update'
+                               );
+                               o = s.option(form.ListValue, 'version', 'Select firmware version');
+                               for (let candidate of candidates) {
+                                       if (candidate[0] == version && candidate[1] == revision) {
+                                               o.value(
+                                                       candidate[0],
+                                                       _('[installed] %s').format(
+                                                               candidate[1]
+                                                                       ? `${candidate[0]} - ${candidate[1]}`
+                                                                       : candidate[0]
+                                                       )
+                                               );
+                                       } else {
+                                               o.value(
+                                                       candidate[0],
+                                                       candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]
+                                               );
                                        }
+                               }
 
-                                       if (this.data.advanced_mode == 1) {
-                                               o = s.option(form.Value, 'profile', _('Board Name / Profile'));
-                                               o = s.option(form.DynamicList, 'packages', _('Packages'));
-                                       }
+                               if (this.data.advanced_mode == 1) {
+                                       o = s.option(form.Value, 'profile', _('Board Name / Profile'));
+                                       o = s.option(form.DynamicList, 'packages', _('Packages'));
+                               }
 
-                                       L.resolveDefault(map.render()).
-                                               then(form_rendered => {
-                                                       ui.showModal(_('New firmware upgrade available'), [
-                                                               E('p', _('Currently running: %s - %s').format(this.firmware.version, this.data.revision)),
-                                                               form_rendered,
-                                                               E('div', { class: 'right' }, [
-                                                                       E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')), ' ',
-                                                                       E('button', {
-                                                                               'class': 'btn cbi-button cbi-button-positive important',
-                                                                               'click': ui.createHandlerFn(this, function () {
-                                                                                       map.save().then(() => {
-                                                                                               this.firmware.packages = mapdata.request.packages;
-                                                                                               this.firmware.version = mapdata.request.version;
-                                                                                               this.firmware.profile = mapdata.request.profile;
-                                                                                               poll.add(L.bind(this.handleRequest, this), 5);
-                                                                                       });
-                                                                               })
-                                                                       }, _('Request firmware image')),
-                                                               ]),
-                                                       ]);
-                                               });
-                               } else {
-                                       ui.showModal(_('No upgrade available'), [
-                                               E('p', _('The device runs the latest firmware version %s - %s').format(version, revision)),
+                               L.resolveDefault(map.render()).then((form_rendered) => {
+                                       ui.showModal(_('New firmware upgrade available'), [
+                                               E(
+                                                       'p',
+                                                       _('Currently running: %s - %s').format(
+                                                               this.firmware.version,
+                                                               this.data.revision
+                                                       )
+                                               ),
+                                               form_rendered,
                                                E('div', { class: 'right' }, [
-                                                       E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
+                                                       E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
+                                                       ' ',
+                                                       E(
+                                                               'button',
+                                                               {
+                                                                       class: 'btn cbi-button cbi-button-positive important',
+                                                                       click: ui.createHandlerFn(this, function () {
+                                                                               map.save().then(() => {
+                                                                                       this.firmware.packages = mapdata.request.packages;
+                                                                                       this.firmware.version = mapdata.request.version;
+                                                                                       this.firmware.profile = mapdata.request.profile;
+                                                                                       this.pollFn = L.bind(function () {
+                                                                                               this.handleRequest(this.data.url, true);
+                                                                                       }, this);
+                                                                                       poll.add(this.pollFn, 5);
+                                                                                       poll.start();
+                                                                               });
+                                                                       }),
+                                                               },
+                                                               _('Request firmware image')
+                                                       ),
                                                ]),
                                        ]);
-                               }
-
-                       });
+                               });
+                       } else {
+                               ui.showModal(_('No upgrade available'), [
+                                       E(
+                                               'p',
+                                               _('The device runs the latest firmware version %s - %s').format(
+                                                       version,
+                                                       revision
+                                               )
+                                       ),
+                                       E('div', { class: 'right' }, [
+                                               E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
+                                       ]),
+                               ]);
+                       }
+               });
        },
 
        load: function () {
                return Promise.all([
                        L.resolveDefault(callPackagelist(), {}),
                        L.resolveDefault(callSystemBoard(), {}),
-                       L.resolveDefault(fs.stat("/sys/firmware/efi"), null),
+                       L.resolveDefault(fs.stat('/sys/firmware/efi'), null),
                        uci.load('attendedsysupgrade'),
                ]);
        },
 
        render: function (response) {
-               this.firmware.client = 'luci/' + response[0].packages['luci-app-attendedsysupgrade'];
+               this.firmware.client =
+                       'luci/' + response[0].packages['luci-app-attendedsysupgrade'];
                this.firmware.packages = response[0].packages;
 
                this.firmware.profile = response[1].board_name;
@@ -431,20 +595,46 @@ return view.extend({
                this.data.efi = response[2];
 
                this.data.url = uci.get_first('attendedsysupgrade', 'server', 'url');
-               this.data.advanced_mode = uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0
+               this.data.advanced_mode =
+                       uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0;
+               this.data.rebuilder = uci.get_first(
+                       'attendedsysupgrade',
+                       'server',
+                       'rebuilder'
+               );
 
                return E('p', [
                        E('h2', _('Attended Sysupgrade')),
-                       E('p', _('The attended sysupgrade service allows to easily upgrade vanilla and custom firmware images.')),
-                       E('p', _('This is done by building a new firmware on demand via an online service.')),
-                       E('p', _('Currently running: %s - %s').format(this.firmware.version, this.data.revision)),
-                       E('button', {
-                               'class': 'btn cbi-button cbi-button-positive important',
-                               'click': ui.createHandlerFn(this, this.handleCheck)
-                       }, _('Search for firmware upgrade'))
+                       E(
+                               'p',
+                               _(
+                                       'The attended sysupgrade service allows to easily upgrade vanilla and custom firmware images.'
+                               )
+                       ),
+                       E(
+                               'p',
+                               _(
+                                       'This is done by building a new firmware on demand via an online service.'
+                               )
+                       ),
+                       E(
+                               'p',
+                               _('Currently running: %s - %s').format(
+                                       this.firmware.version,
+                                       this.data.revision
+                               )
+                       ),
+                       E(
+                               'button',
+                               {
+                                       class: 'btn cbi-button cbi-button-positive important',
+                                       click: ui.createHandlerFn(this, this.handleCheck),
+                               },
+                               _('Search for firmware upgrade')
+                       ),
                ]);
        },
        handleSaveApply: null,
        handleSave: null,
-       handleReset: null
+       handleReset: null,
 });