2959c5b823f89f11c32448ea36fb59f61d7eb773
[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: new Map(),
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                 if ('queue_position' in response) {
228                         ui.showModal(_('Queued...'), [
229                                 E(
230                                         'p',
231                                         { class: 'spinning' },
232                                         _('Request in build queue position %s').format(
233                                                 response.queue_position
234                                         )
235                                 ),
236                         ]);
237                 } else {
238                         ui.showModal(_('Building Firmware...'), [
239                                 E(
240                                         'p',
241                                         { class: 'spinning' },
242                                         _('Progress: %s%% %s').format(
243                                                 this.steps[response.imagebuilder_status][0],
244                                                 this.steps[response.imagebuilder_status][1]
245                                         )
246                                 ),
247                         ]);
248                 }
249         },
250
251         handleError: function (response, data, firmware, request_hash) {
252                 response = response.json();
253                 const request_data = {
254                         ...data,
255                         request_hash: request_hash,
256                         sha256_unsigned: this.sha256_unsigned,
257                         ...firmware
258                 };
259                 let body = [
260                         E('p', {}, [
261                                 _('First check '),
262                                 E(
263                                         'a',
264                                         { href: 'https://forum.openwrt.org/t/luci-attended-sysupgrade-support-thread/230552' },
265                                         _('this forum thread')
266                                 ),
267                                 _('.  If you don\'t find a solution there, report all of the information below.')
268                         ]),
269
270                         E(
271                                 'button',
272                                 {
273                                         class: 'btn cbi-button cbi-button-positive important',
274                                         click: ui.createHandlerFn(this, function () {
275                                                 // No translations in here as it's intended for the forum.
276                                                 var text =
277                                                         'Server response: %s'.format(response.detail)
278                                                         + '\n\n'
279                                                         + '`--version-to %s --device %s:%s`'.format(
280                                                                 request_data.version,
281                                                                 request_data.target,
282                                                                 request_data.profile,
283                                                           )
284                                                         + '\n\n'
285                                                         + '[details="Request Data"]\n'
286                                                         + '```\n'
287                                                         + JSON.stringify({ ...request_data }, null, 4) + '\n'
288                                                         + '```\n'
289                                                         + '[/details]\n'
290                                                 ;
291                                                 if (response.stdout) {
292                                                         text = text +
293                                                                 '[details="STDOUT"]\n'
294                                                                 + '```\n'
295                                                                 + response.stdout + '\n'
296                                                                 + '```\n'
297                                                                 + '[/details]\n'
298                                                         ;
299                                                 }
300                                                 if (response.stderr) {
301                                                         text = text +
302                                                                 '[details="STDERR"]\n'
303                                                                 + '```\n'
304                                                                 + response.stderr + '\n'
305                                                                 + '```\n'
306                                                                 + '[/details]\n'
307                                                         ;
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                                                                 E(
316                                                                         'a',
317                                                                         { href: 'https://forum.openwrt.org/t/luci-attended-sysupgrade-support-thread/230552' },
318                                                                         _('this forum thread')
319                                                                 ),
320                                                         ]),
321                                                         E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
322                                                 ]);
323                                         }),
324                                 },
325                                 _('Copy error data to clipboard')
326                         ),
327
328                         E('p', {}, _('Server response: %s').format(response.detail)),
329                         E('p', {}, _('Request Data:')),
330                         E('pre', {}, JSON.stringify({ ...request_data }, null, 4)),
331                 ];
332
333                 if (response.stdout) {
334                         body.push(E('b', {}, 'STDOUT:'));
335                         body.push(E('pre', {}, response.stdout));
336                 }
337
338                 if (response.stderr) {
339                         body.push(E('b', {}, 'STDERR:'));
340                         body.push(E('pre', {}, response.stderr));
341                 }
342
343                 body = body.concat([
344                         E('div', { class: 'right' }, [
345                                 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
346                         ]),
347                 ]);
348
349                 ui.showModal(_('Error building the firmware image'), body);
350         },
351
352         handleRequest: function (server, main, content, data, firmware) {
353                 let request_url = `${server}/api/v1/build`;
354                 let method = 'POST';
355                 let local_content = content;
356                 const request_hash = this.request_hash.get(server);
357
358                 /**
359                  * If `request_hash` is available use a GET request instead of
360                  * sending the entire object.
361                  */
362                 if (request_hash) {
363                         request_url += `/${request_hash}`;
364                         local_content = {};
365                         method = 'GET';
366                 }
367
368                 request
369                         .request(request_url, { method: method, content: local_content })
370                         .then((response) => {
371                                 switch (response.status) {
372                                         case 202:
373                                                 response = response.json();
374
375                                                 this.request_hash.set(server, response.request_hash);
376
377                                                 if (main) {
378                                                         this.handle202(response);
379                                                 } else {
380                                                         let view = document.getElementById(server);
381                                                         view.innerText = `⏳   (${
382                                                                 this.steps[response.imagebuilder_status][0]
383                                                         }%) ${server}`;
384                                                 }
385                                                 break;
386                                         case 200:
387                                                 if (main == true) {
388                                                         poll.remove(this.pollFn);
389                                                         this.handle200(response, content, data, firmware);
390                                                 } else {
391                                                         poll.remove(this.rebuilder_polls[server]);
392                                                         response = response.json();
393                                                         let view = document.getElementById(server);
394                                                         let image = this.selectImage(response.images, data, firmware);
395                                                         if (image.sha256_unsigned == this.sha256_unsigned) {
396                                                                 view.innerText = '✅ %s'.format(server);
397                                                         } else {
398                                                                 view.innerHTML = `⚠️ ${server} (<a href="${server}/store/${
399                                                                         response.bin_dir
400                                                                 }/${image.name}">${_('Download')}</a>)`;
401                                                         }
402                                                 }
403                                                 break;
404                                         default:  // any error or unexpected responses
405                                                 if (main == true) {
406                                                         poll.remove(this.pollFn);
407                                                         this.handleError(response, data, firmware, request_hash);
408                                                 } else {
409                                                         poll.remove(this.rebuilder_polls[server]);
410                                                         document.getElementById(server).innerText = '🚫 %s'.format(
411                                                                 server
412                                                         );
413                                                 }
414                                                 break;
415                                 }
416                         });
417         },
418
419         handleRebuilder: function (content, data, firmware) {
420                 this.rebuilder_polls = {};
421                 for (let rebuilder of data.rebuilder) {
422                         this.rebuilder_polls[rebuilder] = L.bind(
423                                 this.handleRequest,
424                                 this,
425                                 rebuilder,
426                                 false,
427                                 content,
428                                 data,
429                                 firmware
430                         );
431                         poll.add(this.rebuilder_polls[rebuilder], 5);
432                         document.getElementById(
433                                 'rebuilder_status'
434                         ).innerHTML += `<p id="${rebuilder}">⏳ ${rebuilder}</p>`;
435                 }
436                 poll.start();
437         },
438
439         handleInstall: function (url, keep, sha256) {
440                 ui.showModal(_('Downloading...'), [
441                         E(
442                                 'p',
443                                 { class: 'spinning' },
444                                 _('Downloading firmware from server to browser')
445                         ),
446                 ]);
447
448                 request
449                         .get(url, {
450                                 headers: {
451                                         'Content-Type': 'application/x-www-form-urlencoded',
452                                 },
453                                 responseType: 'blob',
454                         })
455                         .then((response) => {
456                                 let form_data = new FormData();
457                                 form_data.append('sessionid', rpc.getSessionID());
458                                 form_data.append('filename', '/tmp/firmware.bin');
459                                 form_data.append('filemode', 600);
460                                 form_data.append('filedata', response.blob());
461
462                                 ui.showModal(_('Uploading...'), [
463                                         E(
464                                                 'p',
465                                                 { class: 'spinning' },
466                                                 _('Uploading firmware from browser to device')
467                                         ),
468                                 ]);
469
470                                 request
471                                         .get(`${L.env.cgi_base}/cgi-upload`, {
472                                                 method: 'PUT',
473                                                 content: form_data,
474                                         })
475                                         .then((response) => response.json())
476                                         .then((response) => {
477                                                 if (response.sha256sum != sha256) {
478                                                         ui.showModal(_('Wrong checksum'), [
479                                                                 E('p', _('Error during download of firmware. Please try again')),
480                                                                 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
481                                                         ]);
482                                                 } else {
483                                                         ui.showModal(_('Installing...'), [
484                                                                 E('div', { class: 'spinning' }, [
485                                                                         E('p', _('Installing the sysupgrade image...')),
486                                                                         E('p', 
487                                                                         _('Once the image is written, the system will reboot.')
488                                                                         + ' ' +
489                                                                         _('This should take at least a minute, so please wait for the login screen.')
490                                                                         ),
491                                                                         E('b', _('While you are waiting, do not unpower device!')),
492                                                                 ]),
493                                                         ]);
494
495                                                         L.resolveDefault(callUpgradeStart(keep), {}).then((response) => {
496                                                                 // Wait 10 seconds before we try to reconnect...
497                                                                 let hosts = keep ? [] : ['192.168.1.1', 'openwrt.lan'];
498                                                                 setTimeout(() => { ui.awaitReconnect(...hosts); }, 10000);
499                                                         });
500                                                 }
501                                         });
502                         });
503         },
504
505         handleCheck: function (data, firmware) {
506                 this.request_hash.clear();
507                 let { url, revision, advanced_mode, branch } = data;
508                 let { version, target, profile, packages } = firmware;
509                 let candidates = [];
510
511                 const endpoint = version.endsWith('SNAPSHOT') ? `revision/${version}/${target}` : 'overview';
512                 const request_url = `${url}/api/v1/${endpoint}`;
513
514                 ui.showModal(_('Searching...'), [
515                         E(
516                                 'p',
517                                 { class: 'spinning' },
518                                 _('Searching for an available sysupgrade of %s - %s').format(
519                                         version,
520                                         revision
521                                 )
522                         ),
523                 ]);
524
525                 L.resolveDefault(request.get(request_url)).then((response) => {
526                         if (!response.ok) {
527                                 ui.showModal(_('Error connecting to upgrade server'), [
528                                         E(
529                                                 'p',
530                                                 {},
531                                                 _('Could not reach API at "%s". Please try again later.').format(
532                                                         response.url
533                                                 )
534                                         ),
535                                         E('pre', {}, response.responseText),
536                                         E('div', { class: 'right' }, [
537                                                 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
538                                         ]),
539                                 ]);
540                                 return;
541                         }
542                         if (version.endsWith('SNAPSHOT')) {
543                                 const remote_revision = response.json().revision;
544                                 if (
545                                         get_revision_count(revision) < get_revision_count(remote_revision)
546                                 ) {
547                                         candidates.push([version, remote_revision]);
548                                 }
549                         } else {
550                                 const latest = response.json().latest;
551
552                                 // ensure order: newest to oldest release
553                                 latest.sort().reverse();
554
555                                 for (let remote_version of latest) {
556                                         let remote_branch = get_branch(remote_version);
557
558                                         // already latest version installed
559                                         if (version == remote_version) {
560                                                 break;
561                                         }
562
563                                         candidates.unshift([remote_version, null]);
564
565                                         // don't offer branches older than the current
566                                         if (branch == remote_branch) {
567                                                 break;
568                                         }
569                                 }
570                         }
571
572                         // allow to re-install running firmware in advanced mode
573                         if (advanced_mode == 1) {
574                                 candidates.unshift([version, revision]);
575                         }
576
577                         if (candidates.length) {
578                                 let s, o;
579
580                                 let mapdata = {
581                                         request: {
582                                                 profile,
583                                                 version: candidates[0][0],
584                                                 packages: Object.keys(packages).sort(),
585                                         },
586                                 };
587
588                                 let map = new form.JSONMap(mapdata, '');
589
590                                 s = map.section(
591                                         form.NamedSection,
592                                         'request',
593                                         '',
594                                         '',
595                                         'Use defaults for the safest update'
596                                 );
597                                 o = s.option(form.ListValue, 'version', 'Select firmware version');
598                                 for (let candidate of candidates) {
599                                         if (candidate[0] == version && candidate[1] == revision) {
600                                                 o.value(
601                                                         candidate[0],
602                                                         _('[installed] %s').format(
603                                                                 candidate[1]
604                                                                         ? `${candidate[0]} - ${candidate[1]}`
605                                                                         : candidate[0]
606                                                         )
607                                                 );
608                                         } else {
609                                                 o.value(
610                                                         candidate[0],
611                                                         candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]
612                                                 );
613                                         }
614                                 }
615
616                                 if (advanced_mode == 1) {
617                                         o = s.option(form.Value, 'profile', _('Board Name / Profile'));
618                                         o = s.option(form.DynamicList, 'packages', _('Packages'));
619                                 }
620
621                                 L.resolveDefault(map.render()).then((form_rendered) => {
622                                         ui.showModal(_('New firmware upgrade available'), [
623                                                 E(
624                                                         'p',
625                                                         _('Currently running: %s - %s').format(
626                                                                 version,
627                                                                 revision
628                                                         )
629                                                 ),
630                                                 form_rendered,
631                                                 E('div', { class: 'right' }, [
632                                                         E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
633                                                         ' ',
634                                                         E(
635                                                                 'button',
636                                                                 {
637                                                                         class: 'btn cbi-button cbi-button-positive important',
638                                                                         click: ui.createHandlerFn(this, function () {
639                                                                                 map.save().then(() => {
640                                                                                         this.applyPackageChanges({
641                                                                                                 url,
642                                                                                                 target,
643                                                                                                 version:  mapdata.request.version,
644                                                                                                 packages: mapdata.request.packages,
645                                                                                         }).then((packages) => {
646                                                                                                 const content = {
647                                                                                                         ...firmware,
648                                                                                                         packages: packages,
649                                                                                                         version: mapdata.request.version,
650                                                                                                         profile: mapdata.request.profile
651                                                                                                 };
652                                                                                                 this.pollFn = L.bind(function () {
653                                                                                                         this.handleRequest(url, true, content, data, firmware);
654                                                                                                 }, this);
655                                                                                                 poll.add(this.pollFn, 5);
656                                                                                                 poll.start();
657                                                                                         })
658                                                                                         .catch(error => {
659                                                                                             ui.addNotification(null, E('p', error.message));
660                                                                                             ui.hideModal();
661                                                                                         });
662                                                                                 });
663                                                                         }),
664                                                                 },
665                                                                 _('Request firmware image')
666                                                         ),
667                                                 ]),
668                                         ]);
669                                 });
670                         } else {
671                                 ui.showModal(_('No upgrade available'), [
672                                         E(
673                                                 'p',
674                                                 _('The device runs the latest firmware version %s - %s').format(
675                                                         version,
676                                                         revision
677                                                 )
678                                         ),
679                                         E('div', { class: 'right' }, [
680                                                 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
681                                         ]),
682                                 ]);
683                         }
684                 });
685         },
686
687         load: async function () {
688                 const promises = await Promise.all([
689                         L.resolveDefault(callPackagelist(), {}),
690                         L.resolveDefault(callSystemBoard(), {}),
691                         L.resolveDefault(fs.stat('/sys/firmware/efi'), null),
692                         uci.load('attendedsysupgrade'),
693                 ]);
694                 const data = {
695                         url: uci.get_first('attendedsysupgrade', 'server', 'url').replace(/\/+$/, ''),
696                         branch: get_branch(promises[1].release.version),
697                         revision: promises[1].release.revision,
698                         efi: promises[2],
699                         advanced_mode: uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0,
700                         rebuilder: uci.get_first('attendedsysupgrade', 'server', 'rebuilder')
701                 };
702                 const firmware = {
703                         client: 'luci/' + promises[0].packages['luci-app-attendedsysupgrade'],
704                         packages: promises[0].packages,
705                         profile: promises[1].board_name,
706                         target: promises[1].release.target,
707                         version: promises[1].release.version,
708                         diff_packages: true,
709                         filesystem: promises[1].rootfs_type,
710
711                         // If the user has changed the rootfs partition size via owut,
712                         // then make sure to keep new image the same size.  A null value
713                         // is interpreted by the ASU server as "default".
714                         rootfs_size_mb: uci.get('attendedsysupgrade', 'owut', 'rootfs_size'),
715                 };
716                 return [data, firmware];
717         },
718
719         render: function (response) {
720                 const data = response[0];
721                 const firmware = response[1];
722
723                 return E('p', [
724                         E('h2', _('Attended Sysupgrade')),
725                         E(
726                                 'p',
727                                 _(
728                                         'The attended sysupgrade service allows to upgrade vanilla and custom firmware images easily.'
729                                 )
730                         ),
731                         E(
732                                 'p',
733                                 _(
734                                         'This is done by building a new firmware on demand via an online service.'
735                                 )
736                         ),
737                         E(
738                                 'p',
739                                 _('Currently running: %s - %s').format(
740                                         firmware.version,
741                                         data.revision
742                                 )
743                         ),
744                         E(
745                                 'button',
746                                 {
747                                         class: 'btn cbi-button cbi-button-positive important',
748                                         click: ui.createHandlerFn(this, this.handleCheck, data, firmware),
749                                 },
750                                 _('Search for firmware upgrade')
751                         ),
752                 ]);
753         },
754         handleSaveApply: null,
755         handleSave: null,
756         handleReset: null,
757 });
git clone https://git.99rst.org/PROJECT