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