Files
luci/applications/luci-app-attendedsysupgrade/htdocs/luci-static/resources/view/attendedsysupgrade/overview.js
Eric Fahlgren cf868f4398 luci-app-attendedsysupgrade: do major cleanup of error handler
Extract and isolate the forum-specific constants and formats to
a location that is easily modified.  Factor out a lot of common
code and simply make everything much cleaner and more readable.

Output to the "Request Data" block was augmented to contain the
system board info, and was reordered to put more important data
earlier in the output.

Also fix a potential bug in construction of the error html, where
the final step was coercing everything to a string prematurely.

Signed-off-by: Eric Fahlgren <ericfahlgren@gmail.com>
2025-10-29 16:18:59 +01:00

759 lines
20 KiB
JavaScript

'use strict';
'require view';
'require form';
'require uci';
'require rpc';
'require ui';
'require poll';
'require request';
'require dom';
'require fs';
/* --------------------------------------------------------------------------
* This section should be sufficient to isolate the changes that any forked
* versions need to change with a custom support url.
*/
const support_url = 'https://forum.openwrt.org/t/luci-attended-sysupgrade-support-thread/230552';
const support_link = E('a', { href: support_url }, _('this forum thread'));
function detailsBlock(title, content, pre) {
/* Formatter for discourse-based forum "details" block.
*
* If the above support_url is changed to github, say, then you'd
* probably need to get rid of the '[details...]' syntax.
*/
return ! content ? '' : ''.concat(
'[details="', title, '"]\n',
pre ? '```\n' : '',
content, '\n',
pre ? '```\n' : '',
'[/details]\n',
);
}
/* -------------------------------------------------------------------------- */
const callPackagelist = rpc.declare({
object: 'rpc-sys',
method: 'packagelist',
});
const callSystemBoard = rpc.declare({
object: 'system',
method: 'board',
});
const callUpgradeStart = rpc.declare({
object: 'rpc-sys',
method: 'upgrade_start',
params: ['keep'],
});
/**
* 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
* Input version from which to determine the branch
* @returns {string}
* The determined branch
*/
function get_branch(version) {
return version.replace('-SNAPSHOT', '').split('.').slice(0, 2).join('.');
}
/**
* 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}
* The number of commits since OpenWrt/LEDE reboot
*/
function get_revision_count(revision) {
return parseInt(revision.substring(1).split('-')[0]);
}
return view.extend({
steps: {
init: [ 0, _('Received build request')],
container_setup: [ 10, _('Setting up ImageBuilder')],
validate_revision: [ 20, _('Validating revision')],
validate_manifest: [ 30, _('Validating package selection')],
calculate_packages_hash: [ 40, _('Calculating package hash')],
building_image: [ 50, _('Generating firmware image')],
signing_images: [ 95, _('Signing images')],
done: [100, _('Completed generating firmware image')],
failed: [100, _('Failed to generate firmware image')],
/* Obsolete status values, retained for backward compatibility. */
download_imagebuilder: [ 20, _('Downloading ImageBuilder archive')],
unpack_imagebuilder: [ 40, _('Setting Up ImageBuilder')],
},
request_hash: new Map(),
sha256_unsigned: '',
applyPackageChanges: async function(package_info) {
let { url, target, version, packages } = package_info;
const overview_url = `${url}/api/v1/overview`;
const revision_url = `${url}/api/v1/revision/${version}/${target}`;
let changes, target_revision;
await Promise.all([
request.get(overview_url)
.then(response => response.json())
.then(json => json.branches)
.then(branches => branches[get_branch(version)])
.then(branch => { changes = branch.package_changes; })
.catch(error => {
throw Error(`Get overview failed:<br>${overview_url}<br>${error}`);
}),
request.get(revision_url)
.then(response => response.json())
.then(json => json.revision)
.then(revision => { target_revision = get_revision_count(revision); })
.catch(error => {
throw Error(`Get revision failed:<br>${revision_url}<br>${error}`);
}),
]);
for (const change of changes) {
let idx = packages.indexOf(change.source);
if (idx >= 0 && change.revision <= target_revision) {
if (change.target)
packages[idx] = change.target;
else
packages.splice(idx, 1);
}
}
return packages;
},
selectImage: function (images, data, firmware) {
var filesystemFilter = function(e) {
return (e.filesystem == firmware.filesystem);
}
var typeFilter = function(e) {
let efi_targets = ['armsr', 'loongarch', 'x86'];
let efi_capable = efi_targets.some((tgt) => firmware.target.startsWith(tgt));
if (efi_capable) {
if (data.efi) {
return (e.type == 'combined-efi');
} else {
return (e.type == 'combined');
}
} else {
return (e.type == 'sysupgrade' || e.type == 'combined');
}
}
return images.filter(filesystemFilter).filter(typeFilter)[0];
},
handle200: function (response, content, data, firmware) {
response = response.json();
let image = this.selectImage(response.images, data, firmware);
if (image.name != undefined) {
this.sha256_unsigned = image.sha256_unsigned;
let sysupgrade_url = `${data.url}/store/${response.bin_dir}/${image.name}`;
let keep = E('input', { type: 'checkbox' });
keep.checked = true;
let fields = [
_('Version'),
`${response.version_number} ${response.version_code}`,
_('SHA256'),
image.sha256,
];
if (data.advanced_mode == 1) {
fields.push(
_('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'))
);
if (data.rebuilder) {
fields.push(_('Rebuilds'), E('div', { id: 'rebuilder_status' }));
}
let table = E('div', { class: 'table' });
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]]),
])
);
}
let modal_body = [
table,
E(
'p',
{ class: 'mt-2' },
E('label', { class: 'btn' }, [
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')
),
]),
];
ui.showModal(_('Successfully created firmware image'), modal_body);
if (data.rebuilder) {
this.handleRebuilder(content, data, firmware);
}
}
},
handle202: function (response) {
if ('queue_position' in response) {
ui.showModal(_('Queued...'), [
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%% %s').format(
this.steps[response.imagebuilder_status][0],
this.steps[response.imagebuilder_status][1]
)
),
]);
}
},
handleError: function (response, data, firmware, request_hash) {
response = response.json();
const request_data = {
...data,
request_hash: request_hash,
sha256_unsigned: this.sha256_unsigned,
...firmware
};
const request_str = JSON.stringify(request_data, null, 4);
let body = [
E('p', {}, [
_('First, check'), ' ',
support_link,
_('. If you don\'t find a solution there, then report all of the information below.')
]),
E(
'button',
{
class: 'btn cbi-button cbi-button-positive important',
style: 'margin-bottom: 1em; padding: 0.2em',
click: ui.createHandlerFn(this, function () {
var text = ''.concat(
// No translations in here as it's intended for the forum.
'Server response: %s\n\n'.format(response.detail),
detailsBlock('Request Data', request_str, true),
detailsBlock('STDOUT', response.stdout, true),
detailsBlock('STDERR', response.stderr, true),
);
navigator.clipboard.writeText(text);
ui.showModal(_('Data copied!'), [
E('p', [
_('Paste the contents of the clipboard to'), ' ',
support_link,
'.',
]),
E('div', { class: 'right' }, [
E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
]),
]);
}),
},
_('Copy error data to clipboard...')
),
E('p', _('Server response: %s').format(response.detail)),
E('p', _('Request Data:')),
E('pre', {}, request_str),
];
if (response.stdout) {
body.push(
E('b', 'STDOUT:'),
E('pre', response.stdout),
);
}
if (response.stderr) {
body.push(
E('b', 'STDERR:'),
E('pre', response.stderr),
);
}
body.push(
E('div', { class: 'right' }, [
E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
]),
);
ui.showModal(_('Error building the firmware image'), body);
},
handleRequest: function (server, main, content, data, firmware) {
let request_url = `${server}/api/v1/build`;
let method = 'POST';
let local_content = content;
const request_hash = this.request_hash.get(server);
/**
* If `request_hash` is available use a GET request instead of
* sending the entire object.
*/
if (request_hash) {
request_url += `/${request_hash}`;
local_content = {};
method = 'GET';
}
request
.request(request_url, { method: method, content: local_content })
.then((response) => {
switch (response.status) {
case 202:
response = response.json();
this.request_hash.set(server, response.request_hash);
if (main) {
this.handle202(response);
} else {
let view = document.getElementById(server);
view.innerText = `⏳ (${
this.steps[response.imagebuilder_status][0]
}%) ${server}`;
}
break;
case 200:
if (main == true) {
poll.remove(this.pollFn);
this.handle200(response, content, data, firmware);
} else {
poll.remove(this.rebuilder_polls[server]);
response = response.json();
let view = document.getElementById(server);
let image = this.selectImage(response.images, data, firmware);
if (image.sha256_unsigned == this.sha256_unsigned) {
view.innerText = '✅ %s'.format(server);
} else {
view.innerHTML = `⚠️ ${server} (<a href="${server}/store/${
response.bin_dir
}/${image.name}">${_('Download')}</a>)`;
}
}
break;
default: // any error or unexpected responses
if (main == true) {
poll.remove(this.pollFn);
this.handleError(response, data, firmware, request_hash);
} else {
poll.remove(this.rebuilder_polls[server]);
document.getElementById(server).innerText = '🚫 %s'.format(
server
);
}
break;
}
});
},
handleRebuilder: function (content, data, firmware) {
this.rebuilder_polls = {};
for (let rebuilder of data.rebuilder) {
this.rebuilder_polls[rebuilder] = L.bind(
this.handleRequest,
this,
rebuilder,
false,
content,
data,
firmware
);
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')
),
]);
request
.get(url, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
responseType: 'blob',
})
.then((response) => {
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')
),
]);
request
.get(`${L.env.cgi_base}/cgi-upload`, {
method: 'PUT',
content: form_data,
})
.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')),
]);
} else {
ui.showModal(_('Installing...'), [
E('div', { class: 'spinning' }, [
E('p', _('Installing the sysupgrade image...')),
E('p',
_('Once the image is written, the system will reboot.')
+ ' ' +
_('This should take at least a minute, so please wait for the login screen.')
),
E('b', _('While you are waiting, do not unpower device!')),
]),
]);
L.resolveDefault(callUpgradeStart(keep), {}).then((response) => {
// Wait 10 seconds before we try to reconnect...
let hosts = keep ? [] : ['192.168.1.1', 'openwrt.lan'];
setTimeout(() => { ui.awaitReconnect(...hosts); }, 10000);
});
}
});
});
},
handleCheck: function (data, firmware) {
this.request_hash.clear();
let { url, revision, advanced_mode, branch } = data;
let { version, target, profile, packages } = firmware;
let candidates = [];
const endpoint = version.endsWith('SNAPSHOT') ? `revision/${version}/${target}` : 'overview';
const request_url = `${url}/api/v1/${endpoint}`;
ui.showModal(_('Searching...'), [
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;
}
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;
// ensure order: newest to oldest release
latest.sort().reverse();
for (let remote_version of latest) {
let remote_branch = get_branch(remote_version);
// already latest version installed
if (version == remote_version) {
break;
}
candidates.unshift([remote_version, null]);
// don't offer branches older than the current
if (branch == remote_branch) {
break;
}
}
}
// allow to re-install running firmware in advanced mode
if (advanced_mode == 1) {
candidates.unshift([version, revision]);
}
if (candidates.length) {
let s, o;
let mapdata = {
request: {
profile,
version: candidates[0][0],
packages: Object.keys(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 (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(
version,
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.applyPackageChanges({
url,
target,
version: mapdata.request.version,
packages: mapdata.request.packages,
}).then((packages) => {
const content = {
...firmware,
packages: packages,
version: mapdata.request.version,
profile: mapdata.request.profile
};
this.pollFn = L.bind(function () {
this.handleRequest(url, true, content, data, firmware);
}, this);
poll.add(this.pollFn, 5);
poll.start();
})
.catch(error => {
ui.addNotification(null, E('p', error.message));
ui.hideModal();
});
});
}),
},
_('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: async function () {
const promises = await Promise.all([
L.resolveDefault(callPackagelist(), {}),
L.resolveDefault(callSystemBoard(), {}),
L.resolveDefault(fs.stat('/sys/firmware/efi'), null),
uci.load('attendedsysupgrade'),
]);
const data = {
system_board: promises[1],
advanced_mode: uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0,
url: uci.get_first('attendedsysupgrade', 'server', 'url').replace(/\/+$/, ''),
branch: get_branch(promises[1].release.version),
revision: promises[1].release.revision,
efi: promises[2],
rebuilder: uci.get_first('attendedsysupgrade', 'server', 'rebuilder'),
};
const firmware = {
client: 'luci/' + promises[0].packages['luci-app-attendedsysupgrade'],
packages: promises[0].packages,
profile: promises[1].board_name,
target: promises[1].release.target,
version: promises[1].release.version,
diff_packages: true,
filesystem: promises[1].rootfs_type,
// If the user has changed the rootfs partition size via owut,
// then make sure to keep new image the same size. A null value
// is interpreted by the ASU server as "default".
rootfs_size_mb: uci.get('attendedsysupgrade', 'owut', 'rootfs_size'),
};
return [data, firmware];
},
render: function (response) {
const data = response[0];
const firmware = response[1];
return E('p', [
E('h2', _('Attended Sysupgrade')),
E(
'p',
_(
'The attended sysupgrade service allows to upgrade vanilla and custom firmware images easily.'
)
),
E(
'p',
_(
'This is done by building a new firmware on demand via an online service.'
)
),
E(
'p',
_('Currently running: %s - %s').format(
firmware.version,
data.revision
)
),
E(
'button',
{
class: 'btn cbi-button cbi-button-positive important',
click: ui.createHandlerFn(this, this.handleCheck, data, firmware),
},
_('Search for firmware upgrade')
),
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null,
});