13 /* --------------------------------------------------------------------------
14 * This section should be sufficient to isolate the changes that any forked
15 * versions need to change with a custom support url.
18 const support_url = 'https://forum.openwrt.org/t/luci-attended-sysupgrade-support-thread/230552';
19 const support_link = E('a', { href: support_url }, _('this forum thread'));
21 function detailsBlock(title, content, pre) {
22 /* Formatter for discourse-based forum "details" block.
24 * If the above support_url is changed to github, say, then you'd
25 * probably need to get rid of the '[details...]' syntax.
27 return ! content ? '' : ''.concat(
28 '[details="', title, '"]\n',
36 /* -------------------------------------------------------------------------- */
38 const callPackagelist = rpc.declare({
40 method: 'packagelist',
43 const callSystemBoard = rpc.declare({
48 const callUpgradeStart = rpc.declare({
50 method: 'upgrade_start',
55 * Returns the branch of a given version. This helps to offer upgrades
56 * for point releases (aka within the branch).
59 * SNAPSHOT -> SNAPSHOT
60 * 21.02-SNAPSHOT -> 21.02
61 * 21.02.0-rc1 -> 21.02
64 * @param {string} version
65 * Input version from which to determine the branch
67 * The determined branch
69 function get_branch(version) {
70 return version.replace('-SNAPSHOT', '').split('.').slice(0, 2).join('.');
74 * The OpenWrt revision string contains both a hash as well as the number
75 * commits since the OpenWrt/LEDE reboot. It helps to determine if a
76 * snapshot is newer than another.
78 * @param {string} revision
79 * Revision string of a OpenWrt device
81 * The number of commits since OpenWrt/LEDE reboot
83 function get_revision_count(revision) {
84 return parseInt(revision.substring(1).split('-')[0]);
89 init: [ 0, _('Received build request')],
90 container_setup: [ 10, _('Setting up ImageBuilder')],
91 validate_revision: [ 20, _('Validating revision')],
92 validate_manifest: [ 30, _('Validating package selection')],
93 calculate_packages_hash: [ 40, _('Calculating package hash')],
94 building_image: [ 50, _('Generating firmware image')],
95 signing_images: [ 95, _('Signing images')],
96 done: [100, _('Completed generating firmware image')],
97 failed: [100, _('Failed to generate firmware image')],
99 /* Obsolete status values, retained for backward compatibility. */
100 download_imagebuilder: [ 20, _('Downloading ImageBuilder archive')],
101 unpack_imagebuilder: [ 40, _('Setting Up ImageBuilder')],
104 request_hash: new Map(),
107 applyPackageChanges: async function(package_info) {
108 let { url, target, version, packages } = package_info;
110 const overview_url = `${url}/api/v1/overview`;
111 const revision_url = `${url}/api/v1/revision/${version}/${target}`;
113 let changes, target_revision;
116 request.get(overview_url)
117 .then(response => response.json())
118 .then(json => json.branches)
119 .then(branches => branches[get_branch(version)])
120 .then(branch => { changes = branch.package_changes; })
122 throw Error(`Get overview failed:<br>${overview_url}<br>${error}`);
125 request.get(revision_url)
126 .then(response => response.json())
127 .then(json => json.revision)
128 .then(revision => { target_revision = get_revision_count(revision); })
130 throw Error(`Get revision failed:<br>${revision_url}<br>${error}`);
134 for (const change of changes) {
135 let idx = packages.indexOf(change.source);
136 if (idx >= 0 && change.revision <= target_revision) {
138 packages[idx] = change.target;
140 packages.splice(idx, 1);
146 selectImage: function (images, data, firmware) {
147 var filesystemFilter = function(e) {
148 return (e.filesystem == firmware.filesystem);
150 var typeFilter = function(e) {
151 let efi_targets = ['armsr', 'loongarch', 'x86'];
152 let efi_capable = efi_targets.some((tgt) => firmware.target.startsWith(tgt));
155 return (e.type == 'combined-efi');
157 return (e.type == 'combined');
160 return (e.type == 'sysupgrade' || e.type == 'combined');
163 return images.filter(filesystemFilter).filter(typeFilter)[0];
166 handle200: function (response, content, data, firmware) {
167 response = response.json();
168 let image = this.selectImage(response.images, data, firmware);
170 if (image.name != undefined) {
171 this.sha256_unsigned = image.sha256_unsigned;
172 let sysupgrade_url = `${data.url}/store/${response.bin_dir}/${image.name}`;
174 let keep = E('input', { type: 'checkbox' });
179 `${response.version_number} ${response.version_code}`,
184 if (data.advanced_mode == 1) {
201 E('a', { href: sysupgrade_url }, _('Download firmware image'))
203 if (data.rebuilder) {
204 fields.push(_('Rebuilds'), E('div', { id: 'rebuilder_status' }));
207 let table = E('div', { class: 'table' });
209 for (let i = 0; i < fields.length; i += 2) {
211 E('tr', { class: 'tr' }, [
212 E('td', { class: 'td left', width: '33%' }, [fields[i]]),
213 E('td', { class: 'td left' }, [fields[i + 1]]),
223 E('label', { class: 'btn' }, [
226 _('Keep settings and retain the current configuration'),
229 E('div', { class: 'right' }, [
230 E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
235 class: 'btn cbi-button cbi-button-positive important',
236 click: ui.createHandlerFn(this, function () {
237 this.handleInstall(sysupgrade_url, keep.checked, image.sha256);
240 _('Install firmware image')
245 ui.showModal(_('Successfully created firmware image'), modal_body);
246 if (data.rebuilder) {
247 this.handleRebuilder(content, data, firmware);
252 handle202: function (response) {
253 if ('queue_position' in response) {
254 ui.showModal(_('Queued...'), [
257 { class: 'spinning' },
258 _('Request in build queue position %s').format(
259 response.queue_position
264 ui.showModal(_('Building Firmware...'), [
267 { class: 'spinning' },
268 _('Progress: %s%% %s').format(
269 this.steps[response.imagebuilder_status][0],
270 this.steps[response.imagebuilder_status][1]
277 handleError: function (response, data, firmware, request_hash) {
278 response = response.json();
279 const request_data = {
281 request_hash: request_hash,
282 sha256_unsigned: this.sha256_unsigned,
285 const request_str = JSON.stringify(request_data, null, 4);
288 _('First, check'), ' ',
290 _('. If you don\'t find a solution there, then report all of the information below.')
296 class: 'btn cbi-button cbi-button-positive important',
297 style: 'margin-bottom: 1em; padding: 0.2em',
298 click: ui.createHandlerFn(this, function () {
299 var text = ''.concat(
300 // No translations in here as it's intended for the forum.
301 'Server response: %s\n\n'.format(response.detail),
302 detailsBlock('Request Data', request_str, true),
303 detailsBlock('STDOUT', response.stdout, true),
304 detailsBlock('STDERR', response.stderr, true),
307 navigator.clipboard.writeText(text);
309 ui.showModal(_('Data copied!'), [
311 _('Paste the contents of the clipboard to'), ' ',
315 E('div', { class: 'right' }, [
316 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
321 _('Copy error data to clipboard...')
324 E('p', _('Server response: %s').format(response.detail)),
325 E('p', _('Request Data:')),
326 E('pre', {}, request_str),
329 if (response.stdout) {
332 E('pre', response.stdout),
336 if (response.stderr) {
339 E('pre', response.stderr),
344 E('div', { class: 'right' }, [
345 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
349 ui.showModal(_('Error building the firmware image'), body);
352 handleRequest: function (server, main, content, data, firmware) {
353 let request_url = `${server}/api/v1/build`;
355 let local_content = content;
356 const request_hash = this.request_hash.get(server);
359 * If `request_hash` is available use a GET request instead of
360 * sending the entire object.
363 request_url += `/${request_hash}`;
369 .request(request_url, { method: method, content: local_content })
370 .then((response) => {
371 switch (response.status) {
373 response = response.json();
375 this.request_hash.set(server, response.request_hash);
378 this.handle202(response);
380 let view = document.getElementById(server);
381 view.innerText = `⏳ (${
382 this.steps[response.imagebuilder_status][0]
388 poll.remove(this.pollFn);
389 this.handle200(response, content, data, firmware);
391 poll.remove(this.rebuilder_polls[server]);
392 response = response.json();
393 let view = document.getElementById(server);
394 let image = this.selectImage(response.images, data, firmware);
395 if (image.sha256_unsigned == this.sha256_unsigned) {
396 view.innerText = '✅ %s'.format(server);
398 view.innerHTML = `⚠️ ${server} (<a href="${server}/store/${
400 }/${image.name}">${_('Download')}</a>)`;
404 default: // any error or unexpected responses
406 poll.remove(this.pollFn);
407 this.handleError(response, data, firmware, request_hash);
409 poll.remove(this.rebuilder_polls[server]);
410 document.getElementById(server).innerText = '🚫 %s'.format(
419 handleRebuilder: function (content, data, firmware) {
420 this.rebuilder_polls = {};
421 for (let rebuilder of data.rebuilder) {
422 this.rebuilder_polls[rebuilder] = L.bind(
431 poll.add(this.rebuilder_polls[rebuilder], 5);
432 document.getElementById(
434 ).innerHTML += `<p id="${rebuilder}">⏳ ${rebuilder}</p>`;
439 handleInstall: function (url, keep, sha256) {
440 ui.showModal(_('Downloading...'), [
443 { class: 'spinning' },
444 _('Downloading firmware from server to browser')
451 'Content-Type': 'application/x-www-form-urlencoded',
453 responseType: 'blob',
455 .then((response) => {
456 let form_data = new FormData();
457 form_data.append('sessionid', rpc.getSessionID());
458 form_data.append('filename', '/tmp/firmware.bin');
459 form_data.append('filemode', 600);
460 form_data.append('filedata', response.blob());
462 ui.showModal(_('Uploading...'), [
465 { class: 'spinning' },
466 _('Uploading firmware from browser to device')
471 .get(`${L.env.cgi_base}/cgi-upload`, {
475 .then((response) => response.json())
476 .then((response) => {
477 if (response.sha256sum != sha256) {
478 ui.showModal(_('Wrong checksum'), [
479 E('p', _('Error during download of firmware. Please try again')),
480 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
483 ui.showModal(_('Installing...'), [
484 E('div', { class: 'spinning' }, [
485 E('p', _('Installing the sysupgrade image...')),
487 _('Once the image is written, the system will reboot.')
489 _('This should take at least a minute, so please wait for the login screen.')
491 E('b', _('While you are waiting, do not unpower device!')),
495 L.resolveDefault(callUpgradeStart(keep), {}).then((response) => {
496 // Wait 10 seconds before we try to reconnect...
497 let hosts = keep ? [] : ['192.168.1.1', 'openwrt.lan'];
498 setTimeout(() => { ui.awaitReconnect(...hosts); }, 10000);
505 handleCheck: function (data, firmware) {
506 this.request_hash.clear();
507 let { url, revision, advanced_mode, branch } = data;
508 let { version, target, profile, packages } = firmware;
511 const endpoint = version.endsWith('SNAPSHOT') ? `revision/${version}/${target}` : 'overview';
512 const request_url = `${url}/api/v1/${endpoint}`;
514 ui.showModal(_('Searching...'), [
517 { class: 'spinning' },
518 _('Searching for an available sysupgrade of %s - %s').format(
525 L.resolveDefault(request.get(request_url)).then((response) => {
527 ui.showModal(_('Error connecting to upgrade server'), [
531 _('Could not reach API at "%s". Please try again later.').format(
535 E('pre', {}, response.responseText),
536 E('div', { class: 'right' }, [
537 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
542 if (version.endsWith('SNAPSHOT')) {
543 const remote_revision = response.json().revision;
545 get_revision_count(revision) < get_revision_count(remote_revision)
547 candidates.push([version, remote_revision]);
550 const latest = response.json().latest;
552 // ensure order: newest to oldest release
553 latest.sort().reverse();
555 for (let remote_version of latest) {
556 let remote_branch = get_branch(remote_version);
558 // already latest version installed
559 if (version == remote_version) {
563 candidates.unshift([remote_version, null]);
565 // don't offer branches older than the current
566 if (branch == remote_branch) {
572 // allow to re-install running firmware in advanced mode
573 if (advanced_mode == 1) {
574 candidates.unshift([version, revision]);
577 if (candidates.length) {
583 version: candidates[0][0],
584 packages: Object.keys(packages).sort(),
588 let map = new form.JSONMap(mapdata, '');
595 'Use defaults for the safest update'
597 o = s.option(form.ListValue, 'version', 'Select firmware version');
598 for (let candidate of candidates) {
599 if (candidate[0] == version && candidate[1] == revision) {
602 _('[installed] %s').format(
604 ? `${candidate[0]} - ${candidate[1]}`
611 candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]
616 if (advanced_mode == 1) {
617 o = s.option(form.Value, 'profile', _('Board Name / Profile'));
618 o = s.option(form.DynamicList, 'packages', _('Packages'));
621 L.resolveDefault(map.render()).then((form_rendered) => {
622 ui.showModal(_('New firmware upgrade available'), [
625 _('Currently running: %s - %s').format(
631 E('div', { class: 'right' }, [
632 E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
637 class: 'btn cbi-button cbi-button-positive important',
638 click: ui.createHandlerFn(this, function () {
639 map.save().then(() => {
640 this.applyPackageChanges({
643 version: mapdata.request.version,
644 packages: mapdata.request.packages,
645 }).then((packages) => {
649 version: mapdata.request.version,
650 profile: mapdata.request.profile
652 this.pollFn = L.bind(function () {
653 this.handleRequest(url, true, content, data, firmware);
655 poll.add(this.pollFn, 5);
659 ui.addNotification(null, E('p', error.message));
665 _('Request firmware image')
671 ui.showModal(_('No upgrade available'), [
674 _('The device runs the latest firmware version %s - %s').format(
679 E('div', { class: 'right' }, [
680 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
687 load: async function () {
688 const promises = await Promise.all([
689 L.resolveDefault(callPackagelist(), {}),
690 L.resolveDefault(callSystemBoard(), {}),
691 L.resolveDefault(fs.stat('/sys/firmware/efi'), null),
692 uci.load('attendedsysupgrade'),
695 system_board: promises[1],
696 advanced_mode: uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0,
697 url: uci.get_first('attendedsysupgrade', 'server', 'url').replace(/\/+$/, ''),
698 branch: get_branch(promises[1].release.version),
699 revision: promises[1].release.revision,
701 rebuilder: uci.get_first('attendedsysupgrade', 'server', 'rebuilder'),
704 client: 'luci/' + promises[0].packages['luci-app-attendedsysupgrade'],
705 packages: promises[0].packages,
706 profile: promises[1].board_name,
707 target: promises[1].release.target,
708 version: promises[1].release.version,
710 filesystem: promises[1].rootfs_type,
712 // If the user has changed the rootfs partition size via owut,
713 // then make sure to keep new image the same size. A null value
714 // is interpreted by the ASU server as "default".
715 rootfs_size_mb: uci.get('attendedsysupgrade', 'owut', 'rootfs_size'),
717 return [data, firmware];
720 render: function (response) {
721 const data = response[0];
722 const firmware = response[1];
725 E('h2', _('Attended Sysupgrade')),
729 'The attended sysupgrade service allows to upgrade vanilla and custom firmware images easily.'
735 'This is done by building a new firmware on demand via an online service.'
740 _('Currently running: %s - %s').format(
748 class: 'btn cbi-button cbi-button-positive important',
749 click: ui.createHandlerFn(this, this.handleCheck, data, firmware),
751 _('Search for firmware upgrade')
755 handleSaveApply: null,