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