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