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