Merge pull request #5631 from coudu/luci-app-nut-fixes
[project/luci.git] / applications / luci-app-attendedsysupgrade / htdocs / luci-static / resources / view / attendedsysupgrade / overview.js
1 'use strict';
2 'require view';
3 'require form';
4 'require uci';
5 'require rpc';
6 'require ui';
7 'require poll';
8 'require request';
9 'require dom';
10 'require fs';
11
12 let callPackagelist = rpc.declare({
13 object: 'rpc-sys',
14 method: 'packagelist',
15 });
16
17 let callSystemBoard = rpc.declare({
18 object: 'system',
19 method: 'board',
20 });
21
22 let callUpgradeStart = rpc.declare({
23 object: 'rpc-sys',
24 method: 'upgrade_start',
25 params: ['keep'],
26 });
27
28 /**
29 * Returns the branch of a given version. This helps to offer upgrades
30 * for point releases (aka within the branch).
31 *
32 * Logic:
33 * SNAPSHOT -> SNAPSHOT
34 * 21.02-SNAPSHOT -> 21.02
35 * 21.02.0-rc1 -> 21.02
36 * 19.07.8 -> 19.07
37 *
38 * @param {string} version
39 * Input version from which to determine the branch
40 * @returns {string}
41 * The determined branch
42 */
43 function get_branch(version) {
44 return version.replace('-SNAPSHOT', '').split('.').slice(0, 2).join('.');
45 }
46
47 /**
48 * The OpenWrt revision string contains both a hash as well as the number
49 * commits since the OpenWrt/LEDE reboot. It helps to determine if a
50 * snapshot is newer than another.
51 *
52 * @param {string} revision
53 * Revision string of a OpenWrt device
54 * @returns {integer}
55 * The number of commits since OpenWrt/LEDE reboot
56 */
57 function get_revision_count(revision) {
58 return parseInt(revision.substring(1).split('-')[0]);
59 }
60
61 return view.extend({
62 steps: {
63 init: [10, _('Received build request')],
64 download_imagebuilder: [20, _('Downloading ImageBuilder archive')],
65 unpack_imagebuilder: [40, _('Setting Up ImageBuilder')],
66 calculate_packages_hash: [60, _('Validate package selection')],
67 building_image: [80, _('Generating firmware image')],
68 },
69
70 data: {
71 url: '',
72 revision: '',
73 advanced_mode: 0,
74 rebuilder: [],
75 sha256_unsigned: '',
76 },
77
78 firmware: {
79 profile: '',
80 target: '',
81 version: '',
82 packages: [],
83 diff_packages: true,
84 filesystem: '',
85 },
86
87 selectImage: function (images) {
88 let image;
89 for (image of images) {
90 if (this.firmware.filesystem == image.filesystem) {
91 // x86 images can be combined-efi (EFI) or combined (BIOS)
92 if(this.firmware.target.indexOf("x86") != -1) {
93 if (this.data.efi && image.type == 'combined-efi') {
94 return image;
95 } else if (image.type == 'combined') {
96 return image;
97 }
98 } else {
99 if (image.type == 'sysupgrade' || image.type == 'combined') {
100 return image;
101 }
102 }
103 }
104 }
105 return null;
106 },
107
108 handle200: function (response) {
109 response = response.json();
110 let image = this.selectImage(response.images);
111
112 if (image.name != undefined) {
113 this.data.sha256_unsigned = image.sha256_unsigned;
114 let sysupgrade_url = `${this.data.url}/store/${response.bin_dir}/${image.name}`;
115
116 let keep = E('input', { type: 'checkbox' });
117 keep.checked = true;
118
119 let fields = [
120 _('Version'),
121 `${response.version_number} ${response.version_code}`,
122 _('SHA256'),
123 image.sha256,
124 ];
125
126 if (this.data.advanced_mode == 1) {
127 fields.push(
128 _('Profile'),
129 response.id,
130 _('Target'),
131 response.target,
132 _('Build Date'),
133 response.build_at,
134 _('Filename'),
135 image.name,
136 _('Filesystem'),
137 image.filesystem
138 );
139 }
140
141 fields.push(
142 '',
143 E('a', { href: sysupgrade_url }, _('Download firmware image'))
144 );
145 if (this.data.rebuilder) {
146 fields.push(_('Rebuilds'), E('div', { id: 'rebuilder_status' }));
147 }
148
149 let table = E('div', { class: 'table' });
150
151 for (let i = 0; i < fields.length; i += 2) {
152 table.appendChild(
153 E('tr', { class: 'tr' }, [
154 E('td', { class: 'td left', width: '33%' }, [fields[i]]),
155 E('td', { class: 'td left' }, [fields[i + 1]]),
156 ])
157 );
158 }
159
160 let modal_body = [
161 table,
162 E(
163 'p',
164 { class: 'mt-2' },
165 E('label', { class: 'btn' }, [
166 keep,
167 ' ',
168 _('Keep settings and retain the current configuration'),
169 ])
170 ),
171 E('div', { class: 'right' }, [
172 E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
173 ' ',
174 E(
175 'button',
176 {
177 class: 'btn cbi-button cbi-button-positive important',
178 click: ui.createHandlerFn(this, function () {
179 this.handleInstall(sysupgrade_url, keep.checked, image.sha256);
180 }),
181 },
182 _('Install firmware image')
183 ),
184 ]),
185 ];
186
187 ui.showModal(_('Successfully created firmware image'), modal_body);
188 if (this.data.rebuilder) {
189 this.handleRebuilder();
190 }
191 }
192 },
193
194 handle202: function (response) {
195 response = response.json();
196 this.data.request_hash = response.request_hash;
197
198 if ('queue_position' in response) {
199 ui.showModal(_('Queued...'), [
200 E(
201 'p',
202 { class: 'spinning' },
203 _('Request in build queue position %s').format(
204 response.queue_position
205 )
206 ),
207 ]);
208 } else {
209 ui.showModal(_('Building Firmware...'), [
210 E(
211 'p',
212 { class: 'spinning' },
213 _('Progress: %s%% %s').format(
214 this.steps[response.imagebuilder_status][0],
215 this.steps[response.imagebuilder_status][1]
216 )
217 ),
218 ]);
219 }
220 },
221
222 handleError: function (response) {
223 response = response.json();
224 let body = [
225 E('p', {}, _('Server response: %s').format(response.detail)),
226 E(
227 'a',
228 { href: 'https://github.com/openwrt/asu/issues' },
229 _('Please report the error message and request')
230 ),
231 E('p', {}, _('Request Data:')),
232 E('pre', {}, JSON.stringify({ ...this.data, ...this.firmware }, null, 4)),
233 ];
234
235 if (response.stdout) {
236 body.push(E('b', {}, 'STDOUT:'));
237 body.push(E('pre', {}, response.stdout));
238 }
239
240 if (response.stderr) {
241 body.push(E('b', {}, 'STDERR:'));
242 body.push(E('pre', {}, response.stderr));
243 }
244
245 body = body.concat([
246 E('div', { class: 'right' }, [
247 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
248 ]),
249 ]);
250
251 ui.showModal(_('Error building the firmware image'), body);
252 },
253
254 handleRequest: function (server, main) {
255 let request_url = `${server}/api/v1/build`;
256 let method = 'POST';
257 let content = this.firmware;
258
259 /**
260 * If `request_hash` is available use a GET request instead of
261 * sending the entire object.
262 */
263 if (this.data.request_hash && main == true) {
264 request_url += `/${this.data.request_hash}`;
265 content = {};
266 method = 'GET';
267 }
268
269 request
270 .request(request_url, { method: method, content: content })
271 .then((response) => {
272 switch (response.status) {
273 case 202:
274 if (main) {
275 this.handle202(response);
276 } else {
277 response = response.json();
278
279 let view = document.getElementById(server);
280 view.innerText = `⏳ (${
281 this.steps[response.imagebuilder_status][0]
282 }%) ${server}`;
283 }
284 break;
285 case 200:
286 if (main == true) {
287 poll.remove(this.pollFn);
288 this.handle200(response);
289 } else {
290 poll.remove(this.rebuilder_polls[server]);
291 response = response.json();
292 let view = document.getElementById(server);
293 let image = this.selectImage(response.images);
294 if (image.sha256_unsigned == this.data.sha256_unsigned) {
295 view.innerText = '✅ %s'.format(server);
296 } else {
297 view.innerHTML = `⚠️ ${server} (<a href="${server}/store/${
298 response.bin_dir
299 }/${image.name}">${_('Download')}</a>)`;
300 }
301 }
302 break;
303 case 400: // bad request
304 case 422: // bad package
305 case 500: // build failed
306 if (main == true) {
307 poll.remove(this.pollFn);
308 this.handleError(response);
309 break;
310 } else {
311 poll.remove(this.rebuilder_polls[server]);
312 document.getElementById(server).innerText = '🚫 %s'.format(
313 server
314 );
315 }
316 }
317 });
318 },
319
320 handleRebuilder: function () {
321 this.rebuilder_polls = {};
322 for (let rebuilder of this.data.rebuilder) {
323 this.rebuilder_polls[rebuilder] = L.bind(
324 this.handleRequest,
325 this,
326 rebuilder,
327 false
328 );
329 poll.add(this.rebuilder_polls[rebuilder], 5);
330 document.getElementById(
331 'rebuilder_status'
332 ).innerHTML += `<p id="${rebuilder}">⏳ ${rebuilder}</p>`;
333 }
334 poll.start();
335 },
336
337 handleInstall: function (url, keep, sha256) {
338 ui.showModal(_('Downloading...'), [
339 E(
340 'p',
341 { class: 'spinning' },
342 _('Downloading firmware from server to browser')
343 ),
344 ]);
345
346 request
347 .get(url, {
348 headers: {
349 'Content-Type': 'application/x-www-form-urlencoded',
350 },
351 responseType: 'blob',
352 })
353 .then((response) => {
354 let form_data = new FormData();
355 form_data.append('sessionid', rpc.getSessionID());
356 form_data.append('filename', '/tmp/firmware.bin');
357 form_data.append('filemode', 600);
358 form_data.append('filedata', response.blob());
359
360 ui.showModal(_('Uploading...'), [
361 E(
362 'p',
363 { class: 'spinning' },
364 _('Uploading firmware from browser to device')
365 ),
366 ]);
367
368 request
369 .get(`${L.env.cgi_base}/cgi-upload`, {
370 method: 'PUT',
371 content: form_data,
372 })
373 .then((response) => response.json())
374 .then((response) => {
375 if (response.sha256sum != sha256) {
376 ui.showModal(_('Wrong checksum'), [
377 E(
378 'p',
379 _('Error during download of firmware. Please try again')
380 ),
381 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
382 ]);
383 } else {
384 ui.showModal(_('Installing...'), [
385 E(
386 'p',
387 { class: 'spinning' },
388 _('Installing the sysupgrade. Do not unpower device!')
389 ),
390 ]);
391
392 L.resolveDefault(callUpgradeStart(keep), {}).then((response) => {
393 if (keep) {
394 ui.awaitReconnect(window.location.host);
395 } else {
396 ui.awaitReconnect('192.168.1.1', 'openwrt.lan');
397 }
398 });
399 }
400 });
401 });
402 },
403
404 handleCheck: function () {
405 let { url, revision } = this.data;
406 let { version, target } = this.firmware;
407 let candidates = [];
408 let request_url = `${url}/api/overview`;
409 if (version.endsWith('SNAPSHOT')) {
410 request_url = `${url}/api/v1/revision/${version}/${target}`;
411 }
412
413 ui.showModal(_('Searching...'), [
414 E(
415 'p',
416 { class: 'spinning' },
417 _('Searching for an available sysupgrade of %s - %s').format(
418 version,
419 revision
420 )
421 ),
422 ]);
423
424 L.resolveDefault(request.get(request_url)).then((response) => {
425 if (!response.ok) {
426 ui.showModal(_('Error connecting to upgrade server'), [
427 E(
428 'p',
429 {},
430 _('Could not reach API at "%s". Please try again later.').format(
431 response.url
432 )
433 ),
434 E('pre', {}, response.responseText),
435 E('div', { class: 'right' }, [
436 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
437 ]),
438 ]);
439 return;
440 }
441 if (version.endsWith('SNAPSHOT')) {
442 const remote_revision = response.json().revision;
443 if (
444 get_revision_count(revision) < get_revision_count(remote_revision)
445 ) {
446 candidates.push([version, remote_revision]);
447 }
448 } else {
449 const latest = response.json().latest;
450
451 for (let remote_version of latest) {
452 let remote_branch = get_branch(remote_version);
453
454 // already latest version installed
455 if (version == remote_version) {
456 break;
457 }
458
459 // skip branch upgrades outside the advanced mode
460 if (
461 this.data.branch != remote_branch &&
462 this.data.advanced_mode == 0
463 ) {
464 continue;
465 }
466
467 candidates.unshift([remote_version, null]);
468
469 // don't offer branches older than the current
470 if (this.data.branch == remote_branch) {
471 break;
472 }
473 }
474 }
475
476 // allow to re-install running firmware in advanced mode
477 if (this.data.advanced_mode == 1) {
478 candidates.unshift([version, revision]);
479 }
480
481 if (candidates.length) {
482 let s, o;
483
484 let mapdata = {
485 request: {
486 profile: this.firmware.profile,
487 version: candidates[0][0],
488 packages: Object.keys(this.firmware.packages).sort(),
489 },
490 };
491
492 let map = new form.JSONMap(mapdata, '');
493
494 s = map.section(
495 form.NamedSection,
496 'request',
497 '',
498 '',
499 'Use defaults for the safest update'
500 );
501 o = s.option(form.ListValue, 'version', 'Select firmware version');
502 for (let candidate of candidates) {
503 if (candidate[0] == version && candidate[1] == revision) {
504 o.value(
505 candidate[0],
506 _('[installed] %s').format(
507 candidate[1]
508 ? `${candidate[0]} - ${candidate[1]}`
509 : candidate[0]
510 )
511 );
512 } else {
513 o.value(
514 candidate[0],
515 candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]
516 );
517 }
518 }
519
520 if (this.data.advanced_mode == 1) {
521 o = s.option(form.Value, 'profile', _('Board Name / Profile'));
522 o = s.option(form.DynamicList, 'packages', _('Packages'));
523 }
524
525 L.resolveDefault(map.render()).then((form_rendered) => {
526 ui.showModal(_('New firmware upgrade available'), [
527 E(
528 'p',
529 _('Currently running: %s - %s').format(
530 this.firmware.version,
531 this.data.revision
532 )
533 ),
534 form_rendered,
535 E('div', { class: 'right' }, [
536 E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
537 ' ',
538 E(
539 'button',
540 {
541 class: 'btn cbi-button cbi-button-positive important',
542 click: ui.createHandlerFn(this, function () {
543 map.save().then(() => {
544 this.firmware.packages = mapdata.request.packages;
545 this.firmware.version = mapdata.request.version;
546 this.firmware.profile = mapdata.request.profile;
547 this.pollFn = L.bind(function () {
548 this.handleRequest(this.data.url, true);
549 }, this);
550 poll.add(this.pollFn, 5);
551 poll.start();
552 });
553 }),
554 },
555 _('Request firmware image')
556 ),
557 ]),
558 ]);
559 });
560 } else {
561 ui.showModal(_('No upgrade available'), [
562 E(
563 'p',
564 _('The device runs the latest firmware version %s - %s').format(
565 version,
566 revision
567 )
568 ),
569 E('div', { class: 'right' }, [
570 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
571 ]),
572 ]);
573 }
574 });
575 },
576
577 load: function () {
578 return Promise.all([
579 L.resolveDefault(callPackagelist(), {}),
580 L.resolveDefault(callSystemBoard(), {}),
581 L.resolveDefault(fs.stat('/sys/firmware/efi'), null),
582 uci.load('attendedsysupgrade'),
583 ]);
584 },
585
586 render: function (response) {
587 this.firmware.client =
588 'luci/' + response[0].packages['luci-app-attendedsysupgrade'];
589 this.firmware.packages = response[0].packages;
590
591 this.firmware.profile = response[1].board_name;
592 this.firmware.target = response[1].release.target;
593 this.firmware.version = response[1].release.version;
594 this.data.branch = get_branch(response[1].release.version);
595 this.firmware.filesystem = response[1].rootfs_type;
596 this.data.revision = response[1].release.revision;
597
598 this.data.efi = response[2];
599
600 this.data.url = uci.get_first('attendedsysupgrade', 'server', 'url');
601 this.data.advanced_mode =
602 uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0;
603 this.data.rebuilder = uci.get_first(
604 'attendedsysupgrade',
605 'server',
606 'rebuilder'
607 );
608
609 return E('p', [
610 E('h2', _('Attended Sysupgrade')),
611 E(
612 'p',
613 _(
614 'The attended sysupgrade service allows to easily upgrade vanilla and custom firmware images.'
615 )
616 ),
617 E(
618 'p',
619 _(
620 'This is done by building a new firmware on demand via an online service.'
621 )
622 ),
623 E(
624 'p',
625 _('Currently running: %s - %s').format(
626 this.firmware.version,
627 this.data.revision
628 )
629 ),
630 E(
631 'button',
632 {
633 class: 'btn cbi-button cbi-button-positive important',
634 click: ui.createHandlerFn(this, this.handleCheck),
635 },
636 _('Search for firmware upgrade')
637 ),
638 ]);
639 },
640 handleSaveApply: null,
641 handleSave: null,
642 handleReset: null,
643 });