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