]> git.99rst.org Git - openwrt-luci.git/blob
19fc0d20c989771ae2cebb686fcbf0e869193df4
[openwrt-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
13 /* --------------------------------------------------------------------------
14 * This section should be sufficient to isolate the changes that any forked
15 * versions need to change with a custom support url.
16 */
17
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'));
20
21 function detailsBlock(title, content, pre) {
22 /* Formatter for discourse-based forum "details" block.
23 *
24 * If the above support_url is changed to github, say, then you'd
25 * probably need to get rid of the '[details...]' syntax.
26 */
27 return ! content ? '' : ''.concat(
28 '[details="', title, '"]\n',
29 pre ? '```\n' : '',
30 content, '\n',
31 pre ? '```\n' : '',
32 '[/details]\n',
33 );
34 }
35
36 /* -------------------------------------------------------------------------- */
37
38 const callPackagelist = rpc.declare({
39 object: 'rpc-sys',
40 method: 'packagelist',
41 });
42
43 const callSystemBoard = rpc.declare({
44 object: 'system',
45 method: 'board',
46 });
47
48 const callUpgradeStart = rpc.declare({
49 object: 'rpc-sys',
50 method: 'upgrade_start',
51 params: ['keep'],
52 });
53
54 /**
55 * Returns the branch of a given version. This helps to offer upgrades
56 * for point releases (aka within the branch).
57 *
58 * Logic:
59 * SNAPSHOT -> SNAPSHOT
60 * 21.02-SNAPSHOT -> 21.02
61 * 21.02.0-rc1 -> 21.02
62 * 19.07.8 -> 19.07
63 *
64 * @param {string} version
65 * Input version from which to determine the branch
66 * @returns {string}
67 * The determined branch
68 */
69 function get_branch(version) {
70 return version.replace('-SNAPSHOT', '').split('.').slice(0, 2).join('.');
71 }
72
73 /**
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.
77 *
78 * @param {string} revision
79 * Revision string of a OpenWrt device
80 * @returns {integer}
81 * The number of commits since OpenWrt/LEDE reboot
82 */
83 function get_revision_count(revision) {
84 return parseInt(revision.substring(1).split('-')[0]);
85 }
86
87 return view.extend({
88 steps: {
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')],
98
99 /* Obsolete status values, retained for backward compatibility. */
100 download_imagebuilder: [ 20, _('Downloading ImageBuilder archive')],
101 unpack_imagebuilder: [ 40, _('Setting Up ImageBuilder')],
102 },
103
104 request_hash: new Map(),
105 sha256_unsigned: '',
106
107 applyPackageChanges: async function(package_info) {
108 let { url, target, version, packages } = package_info;
109
110 const overview_url = `${url}/api/v1/overview`;
111 const revision_url = `${url}/api/v1/revision/${version}/${target}`;
112
113 let changes, target_revision;
114
115 await Promise.all([
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; })
121 .catch(error => {
122 throw Error(`Get overview failed:<br>${overview_url}<br>${error}`);
123 }),
124
125 request.get(revision_url)
126 .then(response => response.json())
127 .then(json => json.revision)
128 .then(revision => { target_revision = get_revision_count(revision); })
129 .catch(error => {
130 throw Error(`Get revision failed:<br>${revision_url}<br>${error}`);
131 }),
132 ]);
133
134 for (const change of changes) {
135 let idx = packages.indexOf(change.source);
136 if (idx >= 0 && change.revision <= target_revision) {
137 if (change.target)
138 packages[idx] = change.target;
139 else
140 packages.splice(idx, 1);
141 }
142 }
143 return packages;
144 },
145
146 selectImage: function (images, data, firmware) {
147 var filesystemFilter = function(e) {
148 return (e.filesystem == firmware.filesystem);
149 }
150 var typeFilter = function(e) {
151 let efi_targets = ['armsr', 'loongarch', 'x86'];
152 let efi_capable = efi_targets.some((tgt) => firmware.target.startsWith(tgt));
153 if (efi_capable) {
154 if (data.efi) {
155 return (e.type == 'combined-efi');
156 } else {
157 return (e.type == 'combined');
158 }
159 } else {
160 return (e.type == 'sysupgrade' || e.type == 'combined');
161 }
162 }
163 return images.filter(filesystemFilter).filter(typeFilter)[0];
164 },
165
166 handle200: function (response, content, data, firmware) {
167 response = response.json();
168 let image = this.selectImage(response.images, data, firmware);
169
170 if (image.name != undefined) {
171 this.sha256_unsigned = image.sha256_unsigned;
172 let sysupgrade_url = `${data.url}/store/${response.bin_dir}/${image.name}`;
173
174 let keep = E('input', { type: 'checkbox' });
175 keep.checked = true;
176
177 let fields = [
178 _('Version'),
179 `${response.version_number} ${response.version_code}`,
180 _('SHA256'),
181 image.sha256,
182 ];
183
184 if (data.advanced_mode == 1) {
185 fields.push(
186 _('Profile'),
187 response.id,
188 _('Target'),
189 response.target,
190 _('Build Date'),
191 response.build_at,
192 _('Filename'),
193 image.name,
194 _('Filesystem'),
195 image.filesystem
196 );
197 }
198
199 fields.push(
200 '',
201 E('a', { href: sysupgrade_url }, _('Download firmware image'))
202 );
203 if (data.rebuilder) {
204 fields.push(_('Rebuilds'), E('div', { id: 'rebuilder_status' }));
205 }
206
207 let table = E('div', { class: 'table' });
208
209 for (let i = 0; i < fields.length; i += 2) {
210 table.appendChild(
211 E('tr', { class: 'tr' }, [
212 E('td', { class: 'td left', width: '33%' }, [fields[i]]),
213 E('td', { class: 'td left' }, [fields[i + 1]]),
214 ])
215 );
216 }
217
218 let modal_body = [
219 table,
220 E(
221 'p',
222 { class: 'mt-2' },
223 E('label', { class: 'btn' }, [
224 keep,
225 ' ',
226 _('Keep settings and retain the current configuration'),
227 ])
228 ),
229 E('div', { class: 'right' }, [
230 E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
231 ' ',
232 E(
233 'button',
234 {
235 class: 'btn cbi-button cbi-button-positive important',
236 click: ui.createHandlerFn(this, function () {
237 this.handleInstall(sysupgrade_url, keep.checked, image.sha256);
238 }),
239 },
240 _('Install firmware image')
241 ),
242 ]),
243 ];
244
245 ui.showModal(_('Successfully created firmware image'), modal_body);
246 if (data.rebuilder) {
247 this.handleRebuilder(content, data, firmware);
248 }
249 }
250 },
251
252 handle202: function (response) {
253 if ('queue_position' in response) {
254 ui.showModal(_('Queued...'), [
255 E(
256 'p',
257 { class: 'spinning' },
258 _('Request in build queue position %s').format(
259 response.queue_position
260 )
261 ),
262 ]);
263 } else {
264 ui.showModal(_('Building Firmware...'), [
265 E(
266 'p',
267 { class: 'spinning' },
268 _('Progress: %s%% %s').format(
269 this.steps[response.imagebuilder_status][0],
270 this.steps[response.imagebuilder_status][1]
271 )
272 ),
273 ]);
274 }
275 },
276
277 handleError: function (response, data, firmware, request_hash) {
278 response = response.json();
279 const request_data = {
280 ...data,
281 request_hash: request_hash,
282 sha256_unsigned: this.sha256_unsigned,
283 ...firmware
284 };
285 const request_str = JSON.stringify(request_data, null, 4);
286 if (typeof response.detail != "string") {
287 response.detail = JSON.stringify(response.detail, null, 4);
288 }
289 let body = [
290 E('p', {}, [
291 _('First, check'), ' ',
292 support_link,
293 _('. If you don\'t find a solution there, then report all of the information below.')
294 ]),
295
296 E(
297 'button',
298 {
299 class: 'btn cbi-button cbi-button-positive important',
300 style: 'margin-bottom: 1em; padding: 0.2em',
301 click: ui.createHandlerFn(this, function () {
302 var text = ''.concat(
303 // No translations in here as it's intended for the forum.
304 'Server response: %s\n\n'.format(response.detail),
305 detailsBlock('Request Data', request_str, true),
306 detailsBlock('STDOUT', response.stdout, true),
307 detailsBlock('STDERR', response.stderr, true),
308 );
309
310 navigator.clipboard.writeText(text);
311
312 ui.showModal(_('Data copied!'), [
313 E('p', [
314 _('Paste the contents of the clipboard to'), ' ',
315 support_link,
316 '.',
317 ]),
318 E('div', { class: 'right' }, [
319 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
320 ]),
321 ]);
322 }),
323 },
324 _('Copy error data to clipboard...')
325 ),
326
327 E('p', _('Server response: %s').format(response.detail)),
328 E('p', _('Request Data:')),
329 E('pre', {}, request_str),
330 ];
331
332 if (response.stdout) {
333 body.push(
334 E('b', 'STDOUT:'),
335 E('pre', response.stdout),
336 );
337 }
338
339 if (response.stderr) {
340 body.push(
341 E('b', 'STDERR:'),
342 E('pre', response.stderr),
343 );
344 }
345
346 body.push(
347 E('div', { class: 'right' }, [
348 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
349 ]),
350 );
351
352 ui.showModal(_('Error building the firmware image'), body);
353 },
354
355 handleRequest: function (server, main, content, data, firmware) {
356 let request_url = `${server}/api/v1/build`;
357 let method = 'POST';
358 let local_content = content;
359 const request_hash = this.request_hash.get(server);
360
361 /**
362 * If `request_hash` is available use a GET request instead of
363 * sending the entire object.
364 */
365 if (request_hash) {
366 request_url += `/${request_hash}`;
367 local_content = {};
368 method = 'GET';
369 }
370
371 request
372 .request(request_url, { method: method, content: local_content })
373 .then((response) => {
374 switch (response.status) {
375 case 202:
376 response = response.json();
377
378 this.request_hash.set(server, response.request_hash);
379
380 if (main) {
381 this.handle202(response);
382 } else {
383 let view = document.getElementById(server);
384 view.innerText = `⏳ (${
385 this.steps[response.imagebuilder_status][0]
386 }%) ${server}`;
387 }
388 break;
389 case 200:
390 if (main == true) {
391 poll.remove(this.pollFn);
392 this.handle200(response, content, data, firmware);
393 } else {
394 poll.remove(this.rebuilder_polls[server]);
395 response = response.json();
396 let view = document.getElementById(server);
397 let image = this.selectImage(response.images, data, firmware);
398 if (image.sha256_unsigned == this.sha256_unsigned) {
399 view.innerText = '✅ %s'.format(server);
400 } else {
401 view.innerHTML = `⚠️ ${server} (<a href="${server}/store/${
402 response.bin_dir
403 }/${image.name}">${_('Download')}</a>)`;
404 }
405 }
406 break;
407 default: // any error or unexpected responses
408 if (main == true) {
409 poll.remove(this.pollFn);
410 this.handleError(response, data, firmware, request_hash);
411 } else {
412 poll.remove(this.rebuilder_polls[server]);
413 document.getElementById(server).innerText = '🚫 %s'.format(
414 server
415 );
416 }
417 break;
418 }
419 });
420 },
421
422 handleRebuilder: function (content, data, firmware) {
423 this.rebuilder_polls = {};
424 for (let rebuilder of data.rebuilder) {
425 this.rebuilder_polls[rebuilder] = L.bind(
426 this.handleRequest,
427 this,
428 rebuilder,
429 false,
430 content,
431 data,
432 firmware
433 );
434 poll.add(this.rebuilder_polls[rebuilder], 5);
435 document.getElementById(
436 'rebuilder_status'
437 ).innerHTML += `<p id="${rebuilder}">⏳ ${rebuilder}</p>`;
438 }
439 poll.start();
440 },
441
442 handleInstall: function (url, keep, sha256) {
443 ui.showModal(_('Downloading...'), [
444 E(
445 'p',
446 { class: 'spinning' },
447 _('Downloading firmware from server to browser')
448 ),
449 ]);
450
451 request
452 .get(url, {
453 headers: {
454 'Content-Type': 'application/x-www-form-urlencoded',
455 },
456 responseType: 'blob',
457 })
458 .then((response) => {
459 let form_data = new FormData();
460 form_data.append('sessionid', rpc.getSessionID());
461 form_data.append('filename', '/tmp/firmware.bin');
462 form_data.append('filemode', 600);
463 form_data.append('filedata', response.blob());
464
465 ui.showModal(_('Uploading...'), [
466 E(
467 'p',
468 { class: 'spinning' },
469 _('Uploading firmware from browser to device')
470 ),
471 ]);
472
473 request
474 .get(`${L.env.cgi_base}/cgi-upload`, {
475 method: 'PUT',
476 content: form_data,
477 })
478 .then((response) => response.json())
479 .then((response) => {
480 if (response.sha256sum != sha256) {
481 ui.showModal(_('Wrong checksum'), [
482 E('p', _('Error during download of firmware. Please try again')),
483 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
484 ]);
485 } else {
486 ui.showModal(_('Installing...'), [
487 E('div', { class: 'spinning' }, [
488 E('p', _('Installing the sysupgrade image...')),
489 E('p',
490 _('Once the image is written, the system will reboot.')
491 + ' ' +
492 _('This should take at least a minute, so please wait for the login screen.')
493 ),
494 E('b', _('While you are waiting, do not unpower device!')),
495 ]),
496 ]);
497
498 L.resolveDefault(callUpgradeStart(keep), {}).then((response) => {
499 // Wait 10 seconds before we try to reconnect...
500 let hosts = keep ? [] : ['192.168.1.1', 'openwrt.lan'];
501 setTimeout(() => { ui.awaitReconnect(...hosts); }, 10000);
502 });
503 }
504 });
505 });
506 },
507
508 handleCheck: function (data, firmware) {
509 this.request_hash.clear();
510 let { url, revision, advanced_mode, branch } = data;
511 let { version, target, profile, packages } = firmware;
512 let candidates = [];
513
514 const endpoint = version.endsWith('SNAPSHOT') ? `revision/${version}/${target}` : 'overview';
515 const request_url = `${url}/api/v1/${endpoint}`;
516
517 ui.showModal(_('Searching...'), [
518 E(
519 'p',
520 { class: 'spinning' },
521 _('Searching for an available sysupgrade of %s - %s').format(
522 version,
523 revision
524 )
525 ),
526 ]);
527
528 L.resolveDefault(request.get(request_url)).then((response) => {
529 if (!response.ok) {
530 ui.showModal(_('Error connecting to upgrade server'), [
531 E(
532 'p',
533 {},
534 _('Could not reach API at "%s". Please try again later.').format(
535 response.url
536 )
537 ),
538 E('pre', {}, response.responseText),
539 E('div', { class: 'right' }, [
540 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
541 ]),
542 ]);
543 return;
544 }
545 if (version.endsWith('SNAPSHOT')) {
546 const remote_revision = response.json().revision;
547 if (
548 get_revision_count(revision) < get_revision_count(remote_revision)
549 ) {
550 candidates.push([version, remote_revision]);
551 }
552 } else {
553 const latest = response.json().latest;
554
555 // ensure order: newest to oldest release
556 latest.sort().reverse();
557
558 for (let remote_version of latest) {
559 let remote_branch = get_branch(remote_version);
560
561 // already latest version installed
562 if (version == remote_version) {
563 break;
564 }
565
566 candidates.unshift([remote_version, null]);
567
568 // don't offer branches older than the current
569 if (branch == remote_branch) {
570 break;
571 }
572 }
573 }
574
575 // allow to re-install running firmware in advanced mode
576 if (advanced_mode == 1) {
577 candidates.unshift([version, revision]);
578 }
579
580 if (candidates.length) {
581 let s, o;
582
583 let mapdata = {
584 request: {
585 profile,
586 version: candidates[0][0],
587 packages: Object.keys(packages).sort(),
588 },
589 };
590
591 let map = new form.JSONMap(mapdata, '');
592
593 s = map.section(
594 form.NamedSection,
595 'request',
596 '',
597 '',
598 'Use defaults for the safest update'
599 );
600 o = s.option(form.ListValue, 'version', 'Select firmware version');
601 for (let candidate of candidates) {
602 if (candidate[0] == version && candidate[1] == revision) {
603 o.value(
604 candidate[0],
605 _('[installed] %s').format(
606 candidate[1]
607 ? `${candidate[0]} - ${candidate[1]}`
608 : candidate[0]
609 )
610 );
611 } else {
612 o.value(
613 candidate[0],
614 candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]
615 );
616 }
617 }
618
619 if (advanced_mode == 1) {
620 o = s.option(form.Value, 'profile', _('Board Name / Profile'));
621 o = s.option(form.DynamicList, 'packages', _('Packages'));
622 }
623
624 L.resolveDefault(map.render()).then((form_rendered) => {
625 ui.showModal(_('New firmware upgrade available'), [
626 E(
627 'p',
628 _('Currently running: %s - %s').format(
629 version,
630 revision
631 )
632 ),
633 form_rendered,
634 E('div', { class: 'right' }, [
635 E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
636 ' ',
637 E(
638 'button',
639 {
640 class: 'btn cbi-button cbi-button-positive important',
641 click: ui.createHandlerFn(this, function () {
642 map.save().then(() => {
643 this.applyPackageChanges({
644 url,
645 target,
646 version: mapdata.request.version,
647 packages: mapdata.request.packages,
648 }).then((packages) => {
649 const content = {
650 ...firmware,
651 packages: packages,
652 version: mapdata.request.version,
653 profile: mapdata.request.profile
654 };
655 this.pollFn = L.bind(function () {
656 this.handleRequest(url, true, content, data, firmware);
657 }, this);
658 poll.add(this.pollFn, 5);
659 poll.start();
660 })
661 .catch(error => {
662 ui.addNotification(null, E('p', error.message));
663 ui.hideModal();
664 });
665 });
666 }),
667 },
668 _('Request firmware image')
669 ),
670 ]),
671 ]);
672 });
673 } else {
674 ui.showModal(_('No upgrade available'), [
675 E(
676 'p',
677 _('The device runs the latest firmware version %s - %s').format(
678 version,
679 revision
680 )
681 ),
682 E('div', { class: 'right' }, [
683 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
684 ]),
685 ]);
686 }
687 });
688 },
689
690 load: async function () {
691 const promises = await Promise.all([
692 L.resolveDefault(callPackagelist(), {}),
693 L.resolveDefault(callSystemBoard(), {}),
694 L.resolveDefault(fs.stat('/sys/firmware/efi'), null),
695 uci.load('attendedsysupgrade'),
696 ]);
697 const data = {
698 system_board: promises[1],
699 advanced_mode: uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0,
700 url: uci.get_first('attendedsysupgrade', 'server', 'url').replace(/\/+$/, ''),
701 branch: get_branch(promises[1].release.version),
702 revision: promises[1].release.revision,
703 efi: promises[2],
704 rebuilder: uci.get_first('attendedsysupgrade', 'server', 'rebuilder'),
705 };
706 const firmware = {
707 client: 'luci/' + promises[0].packages['luci-app-attendedsysupgrade'],
708 packages: promises[0].packages,
709 profile: promises[1].board_name,
710 target: promises[1].release.target,
711 version: promises[1].release.version,
712 diff_packages: true,
713 filesystem: promises[1].rootfs_type,
714
715 // If the user has changed the rootfs partition size via owut,
716 // then make sure to keep new image the same size. A null value
717 // is interpreted by the ASU server as "default".
718 rootfs_size_mb: uci.get('attendedsysupgrade', 'owut', 'rootfs_size'),
719 };
720 return [data, firmware];
721 },
722
723 render: function (response) {
724 const data = response[0];
725 const firmware = response[1];
726
727 return E('p', [
728 E('h2', _('Attended Sysupgrade')),
729 E(
730 'p',
731 _(
732 'The attended sysupgrade service allows to upgrade vanilla and custom firmware images easily.'
733 )
734 ),
735 E(
736 'p',
737 _(
738 'This is done by building a new firmware on demand via an online service.'
739 )
740 ),
741 E(
742 'p',
743 _('Currently running: %s - %s').format(
744 firmware.version,
745 data.revision
746 )
747 ),
748 E(
749 'button',
750 {
751 class: 'btn cbi-button cbi-button-positive important',
752 click: ui.createHandlerFn(this, this.handleCheck, data, firmware),
753 },
754 _('Search for firmware upgrade')
755 ),
756 ]);
757 },
758 handleSaveApply: null,
759 handleSave: null,
760 handleReset: null,
761 });
git clone https://git.99rst.org/PROJECT