✨ Sync 2026-05-11 21:02:00

This commit is contained in:
github-actions[bot] committed 2026-05-11 21:02:00 +08:00
1 parent 3d2fefa43a
commit ce784c582e
60 files changed
+3238 -2526

No files matched your search

@@ -0,0 +1,84 @@
/* SPDX-License-Identifier: GPL-2.0
*
* Theme-aware styles for luci-app-amlogic JS views.
*/
/* --- Status text ------------------------------------ */
.amlogic-status-ok { color: #1a7f37; }
.amlogic-status-err { color: #c0392b; }
.amlogic-status-info { color: #1f6feb; }
/* --- Spacious row table -----------------------------------------------
* Used by views that lay out a single button + status text per row
* (check / backup / upload). border-collapse:collapse avoids the visible
* blank band that border-spacing would otherwise add around the table
* edges; row breathing room comes purely from cell padding.
* ---------------------------------------------------------------------- */
.amlogic-row-table {
width: 100%;
border-collapse: collapse;
}
.amlogic-row-table > tbody > tr > td,
.amlogic-row-table > tr > td {
padding: 10px 12px;
vertical-align: middle;
line-height: 1.6;
}
/* --- Snapshot grid (cards) --------------------------------------------- */
.amlogic-snap-list {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
width: 100%;
background: transparent;
}
.amlogic-snap-list:after,
.amlogic-snap-list .amlogic-snap-item:not(:nth-child(3n)) {
content: "";
}
.amlogic-snap-item {
box-sizing: border-box;
width: 150px;
height: 130px;
margin: 8px;
padding: 4px;
text-align: center;
font-size: 14px;
color: inherit;
background: transparent;
border: 1px solid rgba(127, 127, 127, 0.35);
border-radius: 4px;
transition: background-color 0.15s ease-in-out;
}
.amlogic-snap-item:hover {
background: rgba(127, 127, 127, 0.15);
}
.amlogic-snap-line {
line-height: 40px;
}
/* --- Info-page logo / author tiles ------------------------------------- */
.amlogic-info-logo {
max-width: 100%;
height: auto;
}
/* --- Dark mode overrides (system / browser preference) -----------------
* Reads prefers-color-scheme from the system / browser. When the user has
* picked a dark theme, switch to brighter colors to keep readable contrast.
* LuCI itself only adjusts the background, so this stylesheet fills in the
* status text and snapshot border colors that the upstream theme leaves
* untouched.
* ---------------------------------------------------------------------- */
@media (prefers-color-scheme: dark) {
.amlogic-status-ok { color: #4ade80; }
.amlogic-status-err { color: #f87171; }
.amlogic-status-info { color: #60a5fa; }
.amlogic-snap-item {
border-color: rgba(200, 200, 200, 0.25);
}
.amlogic-snap-item:hover {
background: rgba(255, 255, 255, 0.08);
}
}
@@ -0,0 +1,92 @@
// SPDX-License-Identifier: GPL-2.0
// CPU Freq Settings page
//
// Purpose: configure scaling governor and min/max frequency per CPU policy
// (cluster). After save, the backend script fixcpufreq.pl is invoked so the
// new values take effect immediately. The backing RPC is exposed by
// /usr/share/rpcd/ucode/luci.amlogic.
'use strict';
'require view';
'require form';
'require rpc';
'require uci';
// Query CPU cluster info (each policy: core type, available governors, freqs).
const callPolicies = rpc.declare({ object: 'luci.amlogic', method: 'cpu_policies',
expect: { policies: [] } });
// Tell backend to apply the new governor / frequency limits right after save.
const callReloadCpu = rpc.declare({ object: 'luci.amlogic', method: 'reload_cpu' });
return view.extend({
// Load in parallel: CPU policy info + UCI config (we will read/write it).
load: function () {
return Promise.all([
callPolicies(),
uci.load('amlogic')
]);
},
render: function (data) {
const policies = data[0] || [];
// Auto-create the armcpu section when missing, so that on a fresh
// install/upgrade the user can save without first creating the section
// manually.
if (!uci.get('amlogic', 'armcpu')) {
uci.add('amlogic', 'settings', 'armcpu');
}
// Build CBI Map bound to the amlogic.armcpu NamedSection.
const m = new form.Map('amlogic', _('CPU Freq Settings'),
_('Set CPU Scaling Governor to Max Performance or Balance Mode'));
const s = m.section(form.NamedSection, 'armcpu', 'settings');
s.anonymous = true;
// Render an independent tab + form options per CPU cluster (big/little).
policies.forEach(function (p) {
const tabId = 'tab' + p.id;
s.tab(tabId, p.name);
// Microarchitecture (read-only display, inline so it aligns with the label).
const ct = s.taboption(tabId, form.DummyValue, 'core_type' + p.id,
_('Microarchitectures:'));
ct.cfgvalue = function () { return p.core_type; };
ct.renderWidget = function (section_id) {
return E('span', { style: 'line-height:2em; display:inline-block; vertical-align:middle' },
this.cfgvalue(section_id) || '');
};
// CPU scaling governor: list of governors exposed by the kernel
// (performance / schedutil / etc).
const gov = s.taboption(tabId, form.ListValue, 'governor' + p.id,
_('CPU Scaling Governor:'));
(p.governors || []).forEach(function (e) { gov.value(e, e.toUpperCase()); });
gov.default = 'schedutil';
gov.rmempty = false;
// Minimum running frequency (kHz).
const minF = s.taboption(tabId, form.ListValue, 'minfreq' + p.id, _('Min Freq:'));
(p.freqs || []).forEach(function (e) { minF.value(e); });
minF.default = '500000';
minF.rmempty = false;
// Maximum running frequency (kHz).
const maxF = s.taboption(tabId, form.ListValue, 'maxfreq' + p.id, _('Max Freq:'));
(p.freqs || []).forEach(function (e) { maxF.value(e); });
maxF.default = '1512000';
maxF.rmempty = false;
});
// Wrap the default saveApply: after a successful UCI commit, trigger
// reload_cpu so the new governor and frequency limits take effect
// without the user manually restarting the service.
const origHandle = m.handleSaveApply;
m.handleSaveApply = function () {
return origHandle.apply(this, arguments)
.then(function (r) { callReloadCpu(); return r; });
};
return m.render();
}
});
@@ -0,0 +1,269 @@
// SPDX-License-Identifier: GPL-2.0
// Backup Firmware Config + Snapshot management + (optional) KVM switch
//
// This page exposes three groups of capabilities:
// 1. Pack the current config into openwrt_config.tar.gz and let the user
// download it;
// 2. List / create / restore / delete etc snapshots (etc-000 / etc-001 are
// the read-only initial / update snapshots and cannot be deleted);
// 3. If the backend reports has_kvm, render the KVM dual-system switch
// button.
'use strict';
'require view';
'require rpc';
'require ui';
'require fs';
'require view.amlogic.shared as amlogicShared';
// Generate one openwrt_config.tar.gz on the server and return its path so the
// frontend can stream it back via fs.read_direct.
const callBackup = rpc.declare({ object: 'luci.amlogic', method: 'backup_create' });
// List existing snapshot names (e.g. etc-000 / etc-001 / etc-202401...).
const callSnapList = rpc.declare({ object: 'luci.amlogic', method: 'snapshot_list',
expect: { names: [] } });
// Create a new snapshot (the backend chooses its name).
const callSnapCreate = rpc.declare({ object: 'luci.amlogic', method: 'snapshot_create' });
// Delete a snapshot by short name (without the etc- prefix).
const callSnapDel = rpc.declare({ object: 'luci.amlogic', method: 'snapshot_delete',
params: ['name'] });
// Restore the given snapshot and reboot.
const callSnapRest = rpc.declare({ object: 'luci.amlogic', method: 'snapshot_restore',
params: ['name'] });
// Read platform info (this page only consumes has_kvm to decide whether to
// show the KVM switch row).
const callPlatform = rpc.declare({ object: 'luci.amlogic', method: 'platform_info' });
// Switch active KVM partition.
const callKvmSwitch = rpc.declare({ object: 'luci.amlogic', method: 'kvm_switch' });
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
load: function () {
// Load in parallel: platform info + current snapshot list.
amlogicShared.ensureCss();
return Promise.all([callPlatform(), callSnapList()]);
},
render: function (data) {
const platform = data[0] || {};
const snapNames = data[1] || [];
const view = this;
const editListBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save',
value: _('Open List'),
// Navigate to the backuplist page to edit the backup file list.
click: function () {
location.href = L.url('admin/system/amlogic/backuplist');
}
});
// Download status hint: uses the warning color; replaced by the
// success message after a successful generation.
const downloadStatus = E('span', { class: 'amlogic-status-err' });
const downloadBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save',
value: _('Download Backup'),
click: ui.createHandlerFn(view, function (ev) {
const btn = ev.currentTarget;
btn.disabled = true;
btn.value = _('Generating...');
// Step 1: ask the server to pack /etc into openwrt_config.tar.gz.
return callBackup().then(function (r) {
if (!r || !r.ok) {
downloadStatus.textContent = _("Couldn't open file:") + ' ' + (r ? r.path : '');
return;
}
// Switch to success color before streaming the blob
downloadStatus.className = 'amlogic-status-ok';
downloadStatus.textContent = _('The file Will download automatically.') + ' ' + r.path;
// Step 2: fetch the file as a Blob via cgi-download (bypasses
// ubus message-size limits) and trigger a browser download.
// We use fs.read_direct which internally POST to cgi-download
// with the correct sessionid.
return fs.read_direct(r.path, 'blob').then(function (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'openwrt_config.tar.gz';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
});
}).catch(function (e) {
downloadStatus.textContent = _('Create upload file error.') +
' ' + (e && e.message ? e.message : '');
}).finally(function () {
btn.disabled = false;
btn.value = _('Download Backup');
});
})
});
const restoreBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save',
value: _('Upload Backup'),
click: function () {
location.href = L.url('admin/system/amlogic/upload');
}
});
// Snapshots
// Snapshot grid: uses amlogic-snap-list / amlogic-snap-item /
// amlogic-snap-line classes whose styles adapt to the current theme
// (see amlogic.css).
const snapDiv = E('div', { class: 'amlogic-snap-list' });
// Rebuild the snapshot cards from a list of names:
// - etc-000 / etc-001 are read-only and do not get a Delete button;
// - other snapshots get both Restore and Delete buttons.
// The backend takes the short name (without the "etc-" prefix).
function renderSnapshots(names) {
snapDiv.innerHTML = '';
if (!names || !names.length) {
snapDiv.appendChild(E('span', { class: 'amlogic-status-err' },
_('Currently OpenWrt does not support the snapshot function.') +
_('Please use this plugin to reinstall or upgrade OpenWrt to enable the snapshot function.')));
return;
}
names.forEach(function (n) {
let title;
if (n === 'etc-000') title = _('Initialize Snapshot');
else if (n === 'etc-001') title = _('Update Snapshot');
else title = n;
const restoreSnapBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-apply',
value: _('Restore Snap'),
click: ui.createHandlerFn(view, function (ev) {
const short = n.replace(/^etc-/, '');
if (!confirm(_('You selected a snapshot:') + ' [ ' + n + ' ] , ' +
_('Confirm recovery and restart OpenWrt?')))
return;
// Capture the button now: ev.currentTarget is null once the
// promise chain resumes asynchronously.
const btn = ev.currentTarget;
btn.disabled = true;
btn.value = _('Restoring...');
return callSnapRest(short).then(function (r) {
if (r && r.code == 0) {
btn.value = _('Successfully Restored');
ui.addNotification(null,
E('p', { class: 'amlogic-status-err' },
_('Snapshot restored. The system will reboot now.')));
} else {
btn.value = _('Restore Failed');
}
}).catch(function () { btn.value = _('Restore Failed'); });
})
});
const item = E('div', {
class: 'amlogic-snap-item',
id: 'snapshots_div_' + n
}, [
E('div', { class: 'amlogic-snap-line' }, title),
E('div', { class: 'amlogic-snap-line' }, restoreSnapBtn)
]);
if (n !== 'etc-000' && n !== 'etc-001') {
const delBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-remove',
value: _('Delete Snap'),
click: ui.createHandlerFn(view, function (ev) {
const short = n.replace(/^etc-/, '');
if (!confirm(_('You selected a snapshot:') + ' [ ' + n + ' ] , ' +
_('Confirm delete?')))
return;
// Capture the button: ev.currentTarget is null in the
// async then() that runs after the RPC resolves.
const btn = ev.currentTarget;
btn.disabled = true;
btn.value = _('Deleting...');
return callSnapDel(short).then(function (r) {
if (r && r.code == 0) {
btn.value = _('Successfully Deleted');
item.style.display = 'none';
} else {
btn.value = _('Delete Failed');
}
}).catch(function () { btn.value = _('Delete Failed'); });
})
});
item.appendChild(E('div', { class: 'amlogic-snap-line' }, delBtn));
}
snapDiv.appendChild(item);
});
}
renderSnapshots(snapNames);
const createSnapBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save',
value: _('Create Snapshot'),
click: ui.createHandlerFn(view, function (ev) {
const btn = ev.currentTarget;
btn.disabled = true;
return callSnapCreate().then(function () {
return callSnapList();
}).then(function (r) {
renderSnapshots(Array.isArray(r) ? r : (r && r.names) || []);
}).finally(function () { btn.disabled = false; });
})
});
const sections = [
E('h2', _('Backup Firmware Config')),
E('p', _('Backup OpenWrt config (openwrt_config.tar.gz). Use this file to restore the config in [Manually Upload Update].')),
E('div', { class: 'cbi-section' }, [
E('table', { class: 'amlogic-row-table' }, [
E('tr', [
E('td', { width: '20%', align: 'right' }, _('Edit List:')),
E('td', { width: '80%', align: 'left' }, editListBtn)
]),
E('tr', [
E('td', { width: '20%', align: 'right' }, _('Backup Config:')),
E('td', { width: '80%', align: 'left' }, [downloadBtn, ' ', downloadStatus])
]),
E('tr', [
E('td', { width: '20%', align: 'right' }, _('Restore Backup:')),
E('td', { width: '80%', align: 'left' }, restoreBtn)
])
])
]),
E('h2', _('Snapshot Management')),
E('p', _('Create a snapshot of the current system configuration, or restore to a snapshot.')),
E('div', { class: 'cbi-section' }, [
E('div', { style: 'margin-bottom:10px' }, createSnapBtn),
snapDiv
])
];
if (platform.has_kvm) {
const kvmBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save',
value: _('Switch System'),
click: ui.createHandlerFn(view, function (ev) {
if (!confirm(_('Are you sure you want to switch systems?'))) return;
ev.currentTarget.disabled = true;
ui.showModal(_('System is switching...'), [
E('p', { class: 'spinning' }, _('Waiting for system switching...'))
]);
return callKvmSwitch().then(function () {
if (ui.awaitReconnect) {
ui.awaitReconnect();
} else {
// Fallback for older LuCI builds without awaitReconnect
setTimeout(function () { location.reload(); }, 5000);
}
});
})
});
sections.push(E('h2', _('KVM dual system switching')));
sections.push(E('p', _('You can freely switch between KVM dual partitions, using OpenWrt systems in different partitions.')));
sections.push(E('div', { class: 'cbi-section' }, [kvmBtn]));
}
return E('div', {}, sections);
}
});
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: GPL-2.0
// Backup file list editor
//
// Edits /etc/amlogic_backup_list.conf, which controls which files / directories
// the openwrt-backup script archives. On load, if the file does not exist or
// is empty, the backend prime_backup_list extracts the default BACKUP_LIST
// from /usr/sbin/openwrt-backup as a starting template.
'use strict';
'require view';
'require rpc';
'require ui';
'require fs';
// Persist the user-edited content back to /etc/amlogic_backup_list.conf.
const callSave = rpc.declare({ object: 'luci.amlogic', method: 'save_backup_list',
params: ['content'] });
// Pre-fill: if the list file is missing or empty, seed it from the defaults
// embedded in the openwrt-backup script (idempotent: existing content stays).
const callPrime = rpc.declare({ object: 'luci.amlogic', method: 'prime_backup_list' });
return view.extend({
// This page uses its own Save button; hide LuCI's default Save/Apply/Reset.
handleSave: null,
handleSaveApply: null,
handleReset: null,
load: function () {
// Run prime first (idempotent), then read the file content.
return callPrime().then(function () {
return fs.read('/etc/amlogic_backup_list.conf').catch(function () { return ''; });
});
},
render: function (text) {
// Full-width multi-line editor pre-filled with the existing list.
const ta = E('textarea', {
rows: '30',
style: 'width:100%; font-family:monospace'
}, text || '');
// Status hint area (saved successfully / failed).
const status = E('span', { style: 'margin-left:1em' });
const saveBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save',
value: _('Save'),
click: ui.createHandlerFn(this, function (ev) {
const btn = ev.currentTarget;
btn.disabled = true;
return callSave(ta.value).then(function (r) {
if (r && (r.ok || r.code == 0)) {
status.textContent = _('Successfully saved.');
// Wait 0.7s before navigating back so the success text is visible.
setTimeout(function () {
location.href = L.url('admin/system/amlogic/backup');
}, 700);
} else {
status.textContent = _('Save Failed');
}
}).catch(function (e) {
status.textContent = _('Save Failed') + ': ' + (e && e.message ? e.message : String(e));
}).finally(function () { btn.disabled = false; });
})
});
// Return to the Backup main page (without saving).
const backBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-reset',
value: _('Back'),
click: function () { location.href = L.url('admin/system/amlogic/backup'); }
});
return E('div', {}, [
E('h2', _('Edit list of backup files')),
E('p', _('In this list, you can manually add the file or directory list of files that need to be backed up. Please confirm that the format and content of the file are correct.')),
E('div', { class: 'cbi-section' }, [ta]),
E('div', { class: 'cbi-page-actions' }, [saveBtn, ' ', backBtn, status])
]);
}
});
@@ -0,0 +1,487 @@
// SPDX-License-Identifier: GPL-2.0
// Online Download Update + Rescue Kernel
//
// Flow matches the Lua version: check_* RPCs write progress logs; the final
// log line is a raw HTML <input> button string that we parse and render as a
// real DOM button so the user can explicitly click Download → then Update.
'use strict';
'require view';
'require rpc';
'require ui';
'require dom';
'require poll';
'require view.amlogic.shared as amlogicShared';
// ── RPCs ────────────────────────────────────────────────────────────────────
const callState = rpc.declare({ object: 'luci.amlogic', method: 'state' });
const callCheckPlugin = rpc.declare({ object: 'luci.amlogic', method: 'check_plugin', params: ['options'] });
const callCheckKernel = rpc.declare({ object: 'luci.amlogic', method: 'check_kernel', params: ['options'] });
const callCheckFirmware = rpc.declare({ object: 'luci.amlogic', method: 'check_firmware', params: ['options'] });
const callStartPlugin = rpc.declare({ object: 'luci.amlogic', method: 'start_plugin' });
const callStartKernel = rpc.declare({ object: 'luci.amlogic', method: 'start_kernel' });
const callStartUpdate = rpc.declare({ object: 'luci.amlogic', method: 'start_update', params: ['amlogic_update_sel'] });
const callStartRescue = rpc.declare({ object: 'luci.amlogic', method: 'start_rescue' });
const callLogTail = rpc.declare({ object: 'luci.amlogic', method: 'read_log_tail', params: ['name'], expect: { line: '' } });
// ── Log-line HTML button parser ──────────────────────────────────────────────
// Returns null when the line is plain text, or an object when it is a button:
// { type: 'firmware-download'|'kernel-download'|'firmware-update'|
// 'kernel-update'|'plugin-update',
// param: string|null, // extracted onclick argument (if any)
// label: string } // text after the closing />
function parseButtonLine(line) {
if (!line || line.indexOf('<input') === -1) return null;
// Extract value="..."
var valueMatch = line.match(/value="([^"]*)"/);
var onclickMatch = line.match(/onclick="([^"]*)"/);
if (!valueMatch || !onclickMatch) return null;
var value = valueMatch[1];
var onclick = onclickMatch[1];
// Text after the self-closing tag
var labelMatch = line.match(/\/>(.*)$/);
var label = labelMatch ? labelMatch[1].trim() : '';
// Plugin download: b_check_plugin(this, 'download_VERSION')
if (value === 'Download' && onclick.indexOf('b_check_plugin') !== -1) {
var m = onclick.match(/b_check_plugin\s*\(\s*this\s*,\s*'([^']*)'\s*\)/);
return { type: 'plugin-download', param: m ? m[1] : null, label: label };
}
// Firmware download: b_check_firmware(this, 'PARAM')
if (value === 'Download' && onclick.indexOf('b_check_firmware') !== -1) {
var m = onclick.match(/b_check_firmware\s*\(\s*this\s*,\s*'([^']*)'\s*\)/);
return { type: 'firmware-download', param: m ? m[1] : null, label: label };
}
// Kernel download: b_check_kernel(this, 'PARAM')
if (value === 'Download' && onclick.indexOf('b_check_kernel') !== -1) {
var m = onclick.match(/b_check_kernel\s*\(\s*this\s*,\s*'([^']*)'\s*\)/);
return { type: 'kernel-download', param: m ? m[1] : null, label: label };
}
// Firmware install: amlogic_update(this, 'PARAM')
if (value === 'Update' && onclick.indexOf('amlogic_update') !== -1) {
var m = onclick.match(/amlogic_update\s*\(\s*this\s*,\s*'([^']*)'\s*\)/);
return { type: 'firmware-update', param: m ? m[1] : null, label: label };
}
// Kernel install: amlogic_kernel(this)
if (value === 'Update' && onclick.indexOf('amlogic_kernel') !== -1) {
return { type: 'kernel-update', param: null, label: label };
}
// Plugin install: amlogic_plugin(this)
if (value === 'Update' && onclick.indexOf('amlogic_plugin') !== -1) {
return { type: 'plugin-update', param: null, label: label };
}
return null;
}
// ── Per-action state machine ─────────────────────────────────────────────────
// States: 'idle' | 'checking' | 'button' | 'installing' | 'done'
//
// statusEl – the span shown in the right column
// Each machine is a plain object; we keep one per action.
function makeAction(name, statusEl) {
return { name: name, statusEl: statusEl, state: 'idle', subBtn: null };
}
// Update the status span with a text string (blue info style).
function showInfo(el, text) {
dom.content(el, E('span', { class: 'amlogic-status-info' }, text));
}
// Update the status span with a success string (green).
function showOk(el, text) {
dom.content(el, E('span', { class: 'amlogic-status-ok' }, text));
}
// Update the status span with an error string (red).
function showErr(el, text) {
dom.content(el, E('span', { class: 'amlogic-status-err' }, text));
}
// Replace statusEl contents with a real DOM button + optional label text.
// onClick is called when the user presses it.
function showSubButton(el, btnLabel, labelText, onClick) {
var btn = E('input', {
type: 'button',
class: 'cbi-button cbi-button-reload',
value: btnLabel,
click: onClick
});
var nodes = [btn];
if (labelText) nodes.push(E('span', { class: 'amlogic-status-info' }, ' ' + labelText));
dom.content(el, nodes);
return btn;
}
// ── Poll handler (called at 1 Hz) ────────────────────────────────────────────
function handlePoll(action) {
if (action.state === 'done') return;
return callLogTail(action.name).then(function (raw) {
// read_log_tail returns up to 4096 bytes (multi-line). Only inspect
// the last non-empty line so that intermediate opkg/apk output
// (e.g. "Collected errors:", dependency warnings) cannot trigger a
// false-positive 'Failed'/'error' match before the final result is
// written. The terminal keywords ("Successful Update", "FAILED",
// etc.) are always written as the very last log line.
var lines = (raw || '').split('\n');
var line = '';
for (var i = lines.length - 1; i >= 0; i--) {
var l = lines[i].trim();
if (l) { line = l; break; }
}
if (action.state === 'idle') {
return;
}
if (action.state === 'button') {
// Waiting for user to click sub-button; don't re-render on every poll.
return;
}
if (action.state === 'installing') {
// Show progress lines; detect terminal keywords.
if (!line) return;
// Skip if line is the same HTML button (stale log from check phase).
if (line.indexOf('<input') !== -1) return;
if (line.indexOf('Successfully updated') !== -1 ||
line.indexOf('Successful Update') !== -1 ||
line.indexOf('Successful') !== -1) {
showOk(action.statusEl, line);
action.state = 'done';
} else if (line.indexOf('Failed') !== -1 || line.indexOf('failed') !== -1 ||
line.indexOf('Error') !== -1 || line.indexOf('error') !== -1) {
showErr(action.statusEl, line);
action.state = 'done';
} else {
// Show current progress line (may change rapidly).
showInfo(action.statusEl, line);
}
return;
}
// state === 'checking': look for HTML button line or plain progress text.
var parsed = parseButtonLine(line);
if (parsed) {
action.state = 'button';
action._parsed = parsed;
renderParsedButton(action);
} else if (line) {
showInfo(action.statusEl, line);
}
});
}
// Render the sub-button extracted from the log line. The onClick triggers the
// appropriate second-phase RPC and transitions to 'installing'.
function renderParsedButton(action) {
var parsed = action._parsed;
var el = action.statusEl;
switch (parsed.type) {
case 'plugin-download':
showSubButton(el, _('Download'), parsed.label, function (ev) {
ev.currentTarget.disabled = true;
ev.currentTarget.value = _('Downloading...');
action.state = 'checking'; // re-enter checking loop, wait for Update button
// parsed.param is e.g. "download_3.1.290" — pass directly to the script
callCheckPlugin(parsed.param).then(function () {});
});
break;
case 'firmware-download':
showSubButton(el, _('Download'), parsed.label, function (ev) {
ev.currentTarget.disabled = true;
ev.currentTarget.value = _('Downloading...');
action.state = 'checking'; // will re-enter checking loop
callCheckFirmware(parsed.param).then(function () {
// After download, keep polling; log will emit Update button
});
});
break;
case 'kernel-download':
showSubButton(el, _('Download'), parsed.label, function (ev) {
ev.currentTarget.disabled = true;
ev.currentTarget.value = _('Downloading...');
action.state = 'checking';
callCheckKernel(parsed.param).then(function () {});
});
break;
case 'firmware-update':
showSubButton(el, _('Update'), parsed.label, function (ev) {
ev.currentTarget.disabled = true;
ev.currentTarget.value = _('Updating...');
// Transition to 'installing': poll will show log lines and detect
// the terminal result. callStartUpdate fires the background script
// and returns {code:0} immediately — do NOT use its return value
// to judge success/failure.
action.state = 'installing';
dom.content(el, E('span', { class: 'amlogic-status-info' }, _('Starting update...')));
callStartUpdate(parsed.param).catch(function () {
// Only show error if the trigger call itself fails (rare).
showErr(el, _('Failed to start update'));
action.state = 'done';
});
});
break;
case 'kernel-update':
showSubButton(el, _('Update'), parsed.label, function (ev) {
ev.currentTarget.disabled = true;
ev.currentTarget.value = _('Updating...');
action.state = 'installing';
dom.content(el, E('span', { class: 'amlogic-status-info' }, _('Starting kernel update...')));
callStartKernel().catch(function () {
showErr(el, _('Failed to start kernel update'));
action.state = 'done';
});
});
break;
case 'plugin-update':
showSubButton(el, _('Update'), parsed.label, function (ev) {
ev.currentTarget.disabled = true;
ev.currentTarget.value = _('Updating...');
action.state = 'installing';
dom.content(el, E('span', { class: 'amlogic-status-info' }, _('Starting plugin update...')));
callStartPlugin().catch(function () {
showErr(el, _('Failed to start plugin update'));
action.state = 'done';
});
});
break;
default:
showInfo(el, parsed.label || '');
}
}
// ── Main view ────────────────────────────────────────────────────────────────
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
render: function () {
amlogicShared.ensureCss();
// Version display spans (left persistent, filled by state RPC)
var verFirmware = E('span', _('Collecting data...'));
var verPlugin = E('span', _('Collecting data...'));
var verKernel = E('span', _('Collecting data...'));
var verRescue = E('span', _('Collecting data...'));
var branchSpan = E('span');
// Per-action status spans (right side, updated by poll)
var statusFw = E('span');
var statusPl = E('span');
var statusKn = E('span');
var statusRs = E('span');
// State machines
var actPlugin = makeAction('plugin', statusPl);
var actKernel = makeAction('kernel', statusKn);
var actFirmware = makeAction('firmware', statusFw);
var actRescue = makeAction('rescue', statusRs);
// ── Action buttons (left column) ──────────────────────────────────
// Helper: reset an action back to idle so it can be re-run after 'done'.
function resetAction(action, btn, idleLabel) {
action.state = 'idle';
action.subBtn = null;
dom.content(action.statusEl, '');
btn.disabled = false;
btn.value = idleLabel;
}
var btnPlugin = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Only update Amlogic Service'),
click: ui.createHandlerFn(this, function (ev) {
var btn = ev.currentTarget;
// Allow re-run after completion by resetting 'done' → 'idle'
if (actPlugin.state === 'done')
resetAction(actPlugin, btn, _('Only update Amlogic Service'));
if (actPlugin.state !== 'idle') return;
btn.disabled = true;
btn.value = _('Checking...');
actPlugin.state = 'checking';
dom.content(actPlugin.statusEl, '');
return callCheckPlugin('check').then(function () {
btn.disabled = false;
btn.value = _('Only update Amlogic Service');
}).catch(function () {
btn.disabled = false;
btn.value = _('Only update Amlogic Service');
showErr(actPlugin.statusEl, _('Check Failed'));
actPlugin.state = 'idle';
});
})
});
var btnKernel = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Update system kernel only'),
click: ui.createHandlerFn(this, function (ev) {
var btn = ev.currentTarget;
if (actKernel.state === 'done')
resetAction(actKernel, btn, _('Update system kernel only'));
if (actKernel.state !== 'idle') return;
btn.disabled = true;
btn.value = _('Checking...');
actKernel.state = 'checking';
dom.content(actKernel.statusEl, '');
return callCheckKernel('check').then(function () {
btn.disabled = false;
btn.value = _('Update system kernel only');
}).catch(function () {
btn.disabled = false;
btn.value = _('Update system kernel only');
showErr(actKernel.statusEl, _('Check Failed'));
actKernel.state = 'idle';
});
})
});
var btnFirmware = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Complete system update'),
click: ui.createHandlerFn(this, function (ev) {
var btn = ev.currentTarget;
if (actFirmware.state === 'done')
resetAction(actFirmware, btn, _('Complete system update'));
if (actFirmware.state !== 'idle') return;
btn.disabled = true;
btn.value = _('Checking...');
actFirmware.state = 'checking';
dom.content(actFirmware.statusEl, '');
return callCheckFirmware('check').then(function () {
btn.disabled = false;
btn.value = _('Complete system update');
}).catch(function () {
btn.disabled = false;
btn.value = _('Complete system update');
showErr(actFirmware.statusEl, _('Check Failed'));
actFirmware.state = 'idle';
});
})
});
var btnRescue = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Rescue the original system kernel'),
click: ui.createHandlerFn(this, function (ev) {
var btn = ev.currentTarget;
if (actRescue.state === 'done')
resetAction(actRescue, btn, _('Rescue the original system kernel'));
if (actRescue.state !== 'idle') return;
btn.disabled = true;
btn.value = _('Rescuing...');
// Set installing state BEFORE the call so the poller picks up
// log lines. The script runs in the background (shbg); the RPC
// returns {code:0} immediately and we do NOT use it to judge
// success/failure — the poll loop reads LOG_RESCUE and detects
// the terminal line.
actRescue.state = 'installing';
showInfo(actRescue.statusEl, _('Starting rescue...'));
return callStartRescue().then(function () {
// RPC triggered OK; result comes via log polling.
// Re-enable the button so the user can retry if needed.
btn.disabled = false;
btn.value = _('Rescue the original system kernel');
}).catch(function () {
btn.disabled = false;
btn.value = _('Rescue the original system kernel');
showErr(actRescue.statusEl, _('Failed to start rescue'));
actRescue.state = 'idle';
});
})
});
// ── Layout ────────────────────────────────────────────────────────
var body = E('div', {}, [
E('h2', _('Check Update')),
E('p', _('Provide OpenWrt Firmware, Kernel and Plugin online check, download and update service.')),
E('div', { class: 'cbi-section' }, [
E('p', { style: 'text-align:center' }, [
_('Update plugins first, then update the kernel or firmware. More options can be configured in [Plugin Settings].'),
' ', branchSpan
]),
E('table', { class: 'amlogic-row-table' }, [
E('tr', [
E('td', { width: '35%', align: 'right' }, btnPlugin),
E('td', { width: '65%', align: 'left' }, [verPlugin, ' ', statusPl])
]),
E('tr', [
E('td', { width: '35%', align: 'right' }, btnKernel),
E('td', { width: '65%', align: 'left' }, [verKernel, ' ', statusKn])
]),
E('tr', [
E('td', { width: '35%', align: 'right' }, btnFirmware),
E('td', { width: '65%', align: 'left' }, [verFirmware, ' ', statusFw])
])
])
]),
E('h2', _('Rescue Kernel')),
E('p', _('When a kernel update fails and causes the OpenWrt system to be unbootable, the kernel can be restored by mutual recovery from eMMC/NVMe/sdX.')),
E('div', { class: 'cbi-section' }, [
E('table', { class: 'amlogic-row-table' }, [
E('tr', [
E('td', { width: '35%', align: 'right' }, btnRescue),
E('td', { width: '65%', align: 'left' }, [verRescue, ' ', statusRs])
])
])
])
]);
// ── Populate version spans ────────────────────────────────────────
callState().then(function (s) {
dom.content(verFirmware, s && s.current_firmware_version
? E('span', { class: 'amlogic-status-ok' }, _('Current Version') + ' [ ' + s.current_firmware_version + ' ] ')
: E('span', { class: 'amlogic-status-err' }, _('Invalid value.')));
dom.content(verPlugin, s && s.current_plugin_version
? E('span', { class: 'amlogic-status-ok' }, _('Current Version') + ' [ ' + s.current_plugin_version + ' ] ')
: E('span', { class: 'amlogic-status-err' }, _('Invalid value.')));
dom.content(verKernel, s && s.current_kernel_version
? E('span', { class: 'amlogic-status-ok' }, _('Current Version') + ' [ ' + s.current_kernel_version + ' ] ')
: E('span', { class: 'amlogic-status-err' }, _('Invalid value.')));
dom.content(branchSpan, s && s.current_kernel_branch
? ' [ ' + s.current_kernel_branch + '.y ] '
: ' [ ' + _('Invalid value.') + ' ]');
// Rescue row: show same kernel version directly via the verRescue span
dom.content(verRescue, s && s.current_kernel_version
? E('span', { class: 'amlogic-status-ok' }, _('Current Version') + ' [ ' + s.current_kernel_version + ' ] ')
: E('span', { class: 'amlogic-status-err' }, _('Invalid value.')));
});
// ── 1 Hz poll ─────────────────────────────────────────────────────
poll.add(function () {
return Promise.all([
handlePoll(actPlugin),
handlePoll(actKernel),
handlePoll(actFirmware),
handlePoll(actRescue)
]);
}, 1);
return body;
}
});
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: GPL-2.0
// Plugin Settings
//
// Uses form.Map to maintain the `config` section in /etc/config/amlogic,
// covering:
// - OpenWrt firmware GitHub repo, tag keyword, file suffix
// - Kernel download repo / tag / version branch
// - Keep config, auto-write bootloader, shared partition fs type
// This page uses the default form.Map Save / Apply buttons; saving only
// writes UCI, while other pages (Online Update / Manual Upload) read these
// values to drive the actual operations.
'use strict';
'require view';
'require form';
'require rpc';
'require uci';
// Get platform info; only used to display the device PLATFORM tag.
const callPlatform = rpc.declare({ object: 'luci.amlogic', method: 'platform_info' });
// Get runtime state; only kernel_release is used here, to derive default
// values for kernel tags / version branch.
const callState = rpc.declare({ object: 'luci.amlogic', method: 'state' });
return view.extend({
load: function () {
return Promise.all([
callPlatform(),
callState(),
uci.load('amlogic')
]);
},
render: function (data) {
const platform = data[0] || {};
const state = data[1] || {};
// Auto-create the section if missing
if (!uci.get('amlogic', 'config')) {
uci.add('amlogic', 'amlogic', 'config');
}
const m = new form.Map('amlogic', _('Plugin Settings'),
_('You can customize the github.com download repository of OpenWrt files and kernels in [Online Download Update].') +
'<br />' + _('Tip: The same files as the current OpenWrt system\'s BOARD (such as rock5b) and kernel (such as 5.10) will be downloaded.'));
const o = m.section(form.NamedSection, 'config', 'amlogic');
o.anonymous = true;
// 1. Display device platform
const dev = o.option(form.DummyValue, 'mydevice', _('Current Device:'),
_('Display the PLATFORM classification of the device.'));
dev.cfgvalue = function () { return 'PLATFORM: ' + (platform.platform || 'Unknown'); };
// 2. OpenWrt download repository
const repo = o.option(form.Value, 'amlogic_firmware_repo',
_('OpenWrt download repository:'),
_('Set the OpenWrt files download repository on github.com in [Online Download Update].'));
repo.default = 'https://github.com/breakingbadboy/OpenWrt';
repo.rmempty = false;
// 3. Tags keyword
const tag = o.option(form.Value, 'amlogic_firmware_tag',
_('OpenWrt download tags keyword:'),
_('Set the OpenWrt files download tags keyword for github.com in [Online Download Update].'));
tag.default = 'ARMv8';
tag.rmempty = false;
// 4. File suffix
const suffix = o.option(form.ListValue, 'amlogic_firmware_suffix',
_('OpenWrt files suffix:'),
_('Set the OpenWrt files download suffix for github.com in [Online Download Update].'));
['.7z', '.zip', '.img.gz', '.img.xz'].forEach(function (s) { suffix.value(s, _(s)); });
suffix.default = '.img.gz';
suffix.rmempty = false;
// 5. Kernel download repository
const kpath = o.option(form.ListValue, 'amlogic_kernel_path',
_('Kernel download repository:'),
_('Set the kernel files download repository on github.com in [Online Download Update].'));
kpath.value('https://github.com/breakingbadboy/OpenWrt');
kpath.value('https://github.com/ophub/kernel');
kpath.default = 'https://github.com/breakingbadboy/OpenWrt';
kpath.rmempty = false;
// 6. Kernel tags - the list depends on currently saved kpath value.
// The breakingbadboy/OpenWrt repo only ships rk3588/rk35xx/stable;
// ophub/kernel additionally ships flippy/h6/beta. The default value is
// auto-derived from -rk3588 / -rk35xx / -h6 keywords in `uname` so the
// user does not have to pick the wrong tag.
const currentKpath = uci.get('amlogic', 'config', 'amlogic_kernel_path') ||
'https://github.com/breakingbadboy/OpenWrt';
const knownTags = {
kernel_rk3588: 'kernel_rk3588 [Rockchip RK3588 Kernel]',
kernel_rk35xx: 'kernel_rk35xx [Rockchip RK35xx Kernel]',
kernel_stable: 'kernel_stable [Mainline Stable Kernel]'
};
if (currentKpath.indexOf('ophub/kernel') >= 0) {
knownTags.kernel_flippy = 'kernel_flippy [Mainline Stable Kernel by Flippy]';
knownTags.kernel_h6 = 'kernel_h6 [Allwinner H6 Kernel]';
knownTags.kernel_beta = 'kernel_beta [Beta Kernel]';
}
// Determine default tag from existing config or uname
let kernelTagDefault = uci.get('amlogic', 'config', 'amlogic_kernel_tags') || '';
if (!kernelTagDefault) {
const u = state.kernel_release || '';
if (u.indexOf('-rk3588') >= 0) kernelTagDefault = 'kernel_rk3588';
else if (u.indexOf('-rk35xx') >= 0) kernelTagDefault = 'kernel_rk35xx';
else if (u.indexOf('-h6') >= 0 || u.indexOf('-zicai') >= 0) kernelTagDefault = 'kernel_h6';
else kernelTagDefault = 'kernel_stable';
}
const ktags = o.option(form.ListValue, 'amlogic_kernel_tags',
_('Kernel download tags:'),
_('Set the kernel files download tags on github.com in [Online Download Update].'));
Object.keys(knownTags).forEach(function (k) { ktags.value(k, _(knownTags[k])); });
if (!knownTags[kernelTagDefault]) ktags.value(kernelTagDefault, kernelTagDefault);
ktags.default = kernelTagDefault;
ktags.rmempty = false;
// 7. Kernel branch
const kbranch = o.option(form.ListValue, 'amlogic_kernel_branch',
_('Set version branch:'),
_('Set the version branch of the OpenWrt files and kernel selected in [Online Download Update].'));
['5.4', '5.10', '5.15', '6.1', '6.6', '6.12', '6.18'].forEach(function (b) {
kbranch.value(b, _(b));
});
const m2 = (state.kernel_release || '').match(/^(\d+\.\d+)/);
kbranch.default = m2 ? m2[1] : '5.10';
kbranch.rmempty = false;
// 8. Plugin branch
const pbranch = o.option(form.ListValue, 'amlogic_plugin_branch',
_('Set plugin branch:'),
_('Set the branch of the luci-app-amlogic plugin used in [Only update Amlogic Service]. Default (empty) uses the main (JavaScript) branch.'));
pbranch.value('', _('main [JavaScript version]'));
pbranch.value('lua', _('lua [Lua version]'));
pbranch.default = '';
pbranch.rmempty = true;
// 9. Keep config
const fcfg = o.option(form.Flag, 'amlogic_firmware_config',
_('Keep config update:'),
_('Set whether to keep the current config during [Online Download Update] and [Manually Upload Update].'));
fcfg.default = '1';
fcfg.rmempty = false;
// 9. Write bootloader
const wb = o.option(form.Flag, 'amlogic_write_bootloader',
_('Auto write bootloader:'),
_('[Recommended choice] Set whether to auto write bootloader during install and update OpenWrt.'));
wb.default = '0';
wb.rmempty = false;
// 10. Shared partition fs type
const fst = o.option(form.ListValue, 'amlogic_shared_fstype',
_('Set the file system type:'),
_('[Default ext4] Set the file system type of the shared partition (/mnt/mmcblk*p4) when install OpenWrt.'));
['ext4', 'f2fs', 'btrfs', 'xfs'].forEach(function (s) { fst.value(s, _(s)); });
fst.default = 'ext4';
fst.rmempty = false;
return m.render();
}
});
@@ -0,0 +1,126 @@
// SPDX-License-Identifier: GPL-2.0
// Plugin landing / info page
//
// Renders the plugin LOGO, three quick-link badges (packit project, author
// homepage, plugin repository), the feature summary and the list of
// supported boxes. The page does not read or write any UCI config; it only
// calls the author RPC to obtain the author's repository URL.
'use strict';
'require view';
'require rpc';
'require view.amlogic.shared as amlogicShared';
// Extract OPENWRT_AUTHOR from /usr/sbin/openwrt-update-amlogic and return the
// author's GitHub repo URL, used when the user clicks author.svg.
const callAuthor = rpc.declare({
object: 'luci.amlogic',
method: 'author',
expect: { openwrt_author: 'Invalid value.' }
});
// Sync menu_install / menu_armcpu UCI flags from runtime platform detection.
// Called on every info page load so the sidebar menu is correct from first visit.
const callSyncMenu = rpc.declare({ object: 'luci.amlogic', method: 'sync_menu' });
// Static list of supported boxes (display only, not used in any logic).
const SUPPORTED_BOXES = [
_('Amlogic s922x --- [ Beelink, Beelink-Pro, Ugoos-AM6-Plus, ODROID-N2, Khadas-VIM3, Ali-CT2000 ]'),
_('Amlogic s905x3 -- [ X96-Max+, HK1-Box, H96-Max-X3, Ugoos-X3, TX3, X96-Air, A95XF3-Air ]'),
_('Amlogic s905x2 -- [ X96Max-4G, X96Max-2G, MECOOL-KM3-4G, Tanix-Tx5-Max, A95X-F2 ]'),
_('Amlogic s912 ---- [ H96-Pro-Plus, Octopus-Planet, A1, A2, Z6-Plus, TX92, X92, TX8-MAX, TX9-Pro ]'),
_('Amlogic s905x --- [ HG680P, B860H, TBee, T95, TX9, XiaoMI-3S, X96 ]'),
_('Amlogic s905w --- [ X96-Mini, TX3-Mini, W95, X96W/FunTV, MXQ-Pro-4K ]'),
_('Amlogic s905d --- [ Phicomm-N1, MECOOL-KI-Pro, SML-5442TW ]'),
_('Amlogic s905l --- [ UNT402A, M201-S ]'),
_('Amlogic s905l2 -- [ MGV2000, MGV3000, Wojia-TV-IPBS9505, M301A, E900v21E ]'),
_('Amlogic s905l3 -- [ CM211-1, CM311-1, HG680-LC, M401A, UNT400G1, UNT402A, ZXV10-BV310 ]'),
_('Amlogic s905l3a - [ E900V22C/D, CM311-1a-YST, M401A, M411A, UNT403A, UNT413A, IP112H ]'),
_('Amlogic s905l3b - [ CM211-1, CM311-1, E900V22D, E900V21E, E900V22E, M302A/M304A ]'),
_('Amlogic s905 ---- [ Beelink-Mini-MX-2G, Sunvell-T95M, MXQ-Pro+4K, SumaVision-Q5 ]'),
_('Allwinner H6 ---- [ V-Plus Cloud ]'),
_('Rockchip -------- [ BeikeYun, L1-Pro, FastRhino R66S/R68S, Radxa 5B/E25 ]'),
_('Used in KVM ----- [ Can be used in KVM virtual machine of Armbian system. ]')
];
// Open an external link in a new tab; fall back to the current window if the
// browser blocks the popup.
function openExternal(url) {
const w = window.open(url, '_blank', 'noopener,noreferrer');
if (!w) window.location.href = url;
}
return view.extend({
// Read-only info page: disable the top Save / Apply / Reset buttons.
handleSave: null,
handleSaveApply: null,
handleReset: null,
load: function () {
// Fire sync_menu in background (don't block render on it).
callSyncMenu().catch(function () {});
return callAuthor();
},
render: function (authorUrl) {
// Inject the theme stylesheet on first entry so amlogic-* classes work.
amlogicShared.ensureCss();
const res = L.resource('amlogic');
// Flatten the supported-boxes list to [text, <br>, text, <br>, ...].
const boxRows = [];
for (let i = 0; i < SUPPORTED_BOXES.length; i++)
boxRows.push(SUPPORTED_BOXES[i], E('br'));
return E('div', { class: 'cbi-section' }, [
E('h2', { style: 'border-bottom:none' }, _('Amlogic Service')),
E('p', { style: 'border:none; margin-bottom:0' }, _('Supports management of Amlogic s9xxx, Allwinner (V-Plus Cloud), and Rockchip (BeikeYun, Chainedbox L1 Pro) boxes.')),
E('table', { class: 'amlogic-row-table' }, [
E('tr', [
E('td', { colspan: '2', style: 'text-align:center' }, [
E('img', { src: res + '/logo.png', alt: 'Logo', width: '135' })
])
]),
E('tr', [
E('td', { colspan: '2', style: 'text-align:center' }, [
E('img', {
src: res + '/packit.svg', alt: 'Packit',
width: '168', height: '20',
style: 'cursor:pointer; margin:0 5px',
click: function () { openExternal('https://github.com/unifreq/openwrt_packit'); }
}),
E('img', {
src: res + '/author.svg', alt: 'Author',
width: '168', height: '20',
style: 'cursor:pointer; margin:0 5px',
click: function () {
callAuthor().then(function (repo) {
let url = String(repo || '').trim();
if (!url) return;
if (url.indexOf('https') === -1)
url = 'https://github.com/' + url;
openExternal(url);
});
}
}),
E('img', {
src: res + '/plugin.svg', alt: 'luci-app-amlogic',
width: '160', height: '20',
style: 'cursor:pointer; margin:0 5px',
click: function () { openExternal('https://github.com/ophub/luci-app-amlogic'); }
})
])
]),
E('tr', [
E('td', { width: '20%', align: 'right' }, _('Supported functions:')),
E('td', { width: '80%', align: 'left' },
_('Provide services such as install to EMMC, Update Firmware and Kernel, Backup and Recovery Config, Snapshot management, etc.'))
]),
E('tr', [
E('td', { width: '20%', align: 'right', style: 'vertical-align:top; padding-top:4px' }, _('Supported Boxes:')),
E('td', { width: '80%', align: 'left', style: 'line-height:1.8' }, boxRows)
])
])
]);
}
});
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: GPL-2.0
// Install OpenWrt to EMMC
//
// The dropdown lists common devices from the backend model database, plus an
// "Enter the dtb file name" option. When the user picks the manual option and
// fills in dtb / soc / uboot_overload, the frontend assembles the string
// "id@dtb:soc:uboot" and passes it to the start_install RPC, which invokes
// openwrt-install-amlogic to perform the actual installation. The install log
// tail is polled while the work runs.
'use strict';
'require view';
'require rpc';
'require ui';
'require dom';
'require poll';
'require view.amlogic.shared as amlogicShared';
// Read the model database (parsed by the backend from
// /usr/share/amlogic/model_database.txt).
const callDB = rpc.declare({ object: 'luci.amlogic', method: 'model_database',
expect: { entries: [] } });
// Start install; the parameter is the composed string "id@dtb:soc:uboot",
// kept compatible with the original Lua plugin.
const callStartInst = rpc.declare({ object: 'luci.amlogic', method: 'start_install',
params: ['amlogic_install_sel'] });
// Read the last line of the named log (here we use the 'install' log).
const callLogTail = rpc.declare({ object: 'luci.amlogic', method: 'read_log_tail',
params: ['name'], expect: { line: '' } });
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
load: function () {
return callDB();
},
render: function (entries) {
amlogicShared.ensureCss();
// Model dropdown: 0 = placeholder, 99 = manual entry, anything in between
// is a model from the database.
const sel = E('select', { style: 'width:auto', name: 'amlogic_soc', id: 'amlogic_soc' });
sel.appendChild(E('option', { value: '0' }, _('Select List')));
(entries || []).forEach(function (e) {
sel.appendChild(E('option', { value: e.id },
'[ ' + e.id + ' ] ' + e.name));
});
sel.appendChild(E('option', { value: '99' }, _('Enter the dtb file name')));
// The three manual fields are only required when the user picks 99.
const dtbInput = E('input', { type: 'text', class: 'cbi-input-text',
style: 'width:235px', id: 'amlogic_dtb' });
const socInput = E('input', { type: 'text', class: 'cbi-input-text',
style: 'width:235px', id: 'amlogic_socname' });
const ubootInput = E('input', { type: 'text', class: 'cbi-input-text',
style: 'width:235px', id: 'amlogic_uboot' });
const trDtb = E('tr', { style: 'display:none' }, [
E('td', { width: '30%', align: 'right' }, _('Enter the dtb file name:')),
E('td', { width: '70%', align: 'left' }, dtbInput)
]);
const trSoc = E('tr', { style: 'display:none' }, [
E('td', { width: '30%', align: 'right' }, _('Enter the soc name:')),
E('td', { width: '70%', align: 'left' }, socInput)
]);
const trUboot = E('tr', { style: 'display:none' }, [
E('td', { width: '30%', align: 'right' }, _('Enter the uboot_overload name:')),
E('td', { width: '70%', align: 'left' }, ubootInput)
]);
sel.addEventListener('change', function () {
// On selection change, dynamically show/hide the three manual rows.
const show = (sel.value === '99') ? '' : 'none';
trDtb.style.display = show;
trSoc.style.display = show;
trUboot.style.display = show;
});
// Status / log line shown next to the install button while it runs.
// 'installing' tracks whether the background install is in progress.
const logSpan = E('span');
var installing = false;
var installDone = false;
const btn = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Install'),
click: ui.createHandlerFn(this, function (ev) {
if (installing) return;
const text = sel.options[sel.selectedIndex].text;
// Confirm twice: install only proceeds after the user clicks
// "Start install?".
if (!confirm(_('You have chosen:') + ' ' + text + ', ' + _('Start install?')))
return;
// When manual entries are blank, dtb falls back to auto_dtb so the
// backend can autodetect.
const dtbVal = dtbInput.value || 'auto_dtb';
const socVal = socInput.value || '';
const ubVal = ubootInput.value || '';
// Compose the "id@dtb:soc:uboot" string the backend expects.
const composed = sel.value + '@' + dtbVal + ':' + socVal + ':' + ubVal;
const target = ev.currentTarget;
target.disabled = true;
target.value = _('Installing...');
installing = true;
installDone = false;
// Fire the background install — result comes via log polling.
return callStartInst(composed).catch(function () {
target.value = _('Install Failed');
target.disabled = false;
installing = false;
});
})
});
// Poll the install log tail once per second to surface backend progress
// and detect terminal success/failure keywords.
poll.add(function () {
return callLogTail('install').then(function (line) {
if (!line || line === '\n') {
dom.content(logSpan, '');
return;
}
dom.content(logSpan, E('span', { class: 'amlogic-status-info' }, ' ' + line));
if (installing && !installDone) {
if (line.indexOf('Successfully') !== -1 || line.indexOf('successful') !== -1) {
installDone = true;
installing = false;
btn.value = _('Successful Install');
btn.disabled = false;
dom.content(logSpan, E('span', { class: 'amlogic-status-ok' }, ' ' + line));
} else if (line.indexOf('Failed') !== -1 || line.indexOf('failed') !== -1 ||
line.indexOf('Error') !== -1 || line.indexOf('error') !== -1) {
installDone = true;
installing = false;
btn.value = _('Install Failed');
btn.disabled = false;
dom.content(logSpan, E('span', { class: 'amlogic-status-err' }, ' ' + line));
}
}
});
}, 1);
return E('div', {}, [
E('h2', _('Install OpenWrt')),
E('p', _('Install OpenWrt to EMMC, Please select the device model, Or enter the dtb file name.')),
E('div', { class: 'cbi-section' }, [
E('table', { class: 'amlogic-row-table' }, [
E('tr', [
E('td', { width: '35%', align: 'right' }, _('Select the device model:')),
E('td', { width: '65%', align: 'left' }, sel)
]),
trDtb, trSoc, trUboot,
E('tr', [
E('td', { width: '30%', align: 'right' }, _('Install OpenWrt:')),
E('td', { width: '70%', align: 'left' }, [btn, ' ', logSpan])
])
])
])
]);
}
});
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: GPL-2.0
// Service log viewer
//
// Reads the full content of the amlogic service log (name=main) into a
// read-only textarea and refreshes it every 2 seconds. Provides controls to
// stop / restart polling, clear the log, and download the current contents
// as a Blob in the browser.
'use strict';
'require view';
'require rpc';
'require ui';
'require poll';
'require view.amlogic.shared as amlogicShared';
// Read the full log content (entire text).
const callLogFull = rpc.declare({ object: 'luci.amlogic', method: 'read_log_full',
params: ['name'], expect: { content: '' } });
// Clear the service main log.
const callDelLog = rpc.declare({ object: 'luci.amlogic', method: 'del_log' });
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
render: function () {
amlogicShared.ensureCss();
// Read-only, no-wrap, monospace log display area.
const ta = E('textarea', {
rows: '20', readonly: 'readonly', wrap: 'off',
style: 'width:100%; font-family:monospace; white-space:pre'
});
// Status hint: Running / Stopped.
const status = E('span', { class: 'amlogic-status-ok', style: 'margin-left:1em' });
// Polling is enabled by default when the page opens.
let polling = true;
// Refresh the full log and scroll to the bottom.
function refresh() {
return callLogFull('main').then(function (txt) {
ta.value = txt || '';
ta.scrollTop = ta.scrollHeight;
});
}
const view = this;
const stopBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-reset',
value: _('Stop Refresh'),
click: function () { polling = false; status.textContent = _('Stopped'); }
});
const startBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-apply',
value: _('Start Refresh'),
click: function () { polling = true; status.textContent = _('Running'); refresh(); }
});
const cleanBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-remove',
value: _('Clean Log'),
click: ui.createHandlerFn(view, function () {
return callDelLog().then(refresh);
})
});
const dlBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save',
value: _('Download Log'),
click: function () {
// Wrap the textarea content in a Blob and trigger a browser
// download via a temporary <a>; avoids a second server fetch.
const blob = new Blob([ta.value || ''], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'amlogic_service.log';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
});
// Polling: only refresh while polling is true; runs once every 2s.
poll.add(function () { if (polling) return refresh(); }, 2);
refresh();
status.textContent = _('Running');
return E('div', {}, [
E('h2', _('Service Log')),
E('p', _('Display the log of plug-in service operation.')),
E('div', { class: 'cbi-section' }, [ta]),
E('div', { class: 'cbi-page-actions' },
[stopBtn, ' ', startBtn, ' ', cleanBtn, ' ', dlBtn, status])
]);
}
});
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: GPL-2.0
// Safe Power Off page
//
// On click, calls the backend poweroff RPC (which finally executes the system
// `poweroff` command) and runs a 5-second on-page countdown so the user knows
// the device is shutting down. When the countdown ends, the status switches
// to "Powered off" and a power-off icon is shown.
'use strict';
'require view';
'require rpc';
'require ui';
'require view.amlogic.shared as amlogicShared';
// Trigger the backend poweroff RPC.
const callPoweroff = rpc.declare({ object: 'luci.amlogic', method: 'poweroff' });
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
render: function () {
amlogicShared.ensureCss();
// Status text: uses the warning color amlogic-status-err which adapts
// to dark mode automatically.
const status = E('span', { class: 'amlogic-status-err', style: 'margin-left:1em' });
// Power-off icon, hidden by default; shown only after the countdown ends.
const icon = E('img', {
src: L.resource('amlogic/poweroff.png'),
width: '32', height: '32',
style: 'width:32px; height:32px; max-width:32px; display:none; margin-left:1em; vertical-align:middle'
});
const btn = E('input', {
type: 'button', class: 'cbi-button cbi-button-remove',
value: _('Perform PowerOff'),
click: ui.createHandlerFn(this, function (ev) {
// Confirm twice to avoid accidental clicks.
if (!confirm(_('Are you sure you want to power off the device?')))
return;
ev.currentTarget.disabled = true;
return callPoweroff().then(function () {
// Backend has fired poweroff; show a 5-second countdown to the user.
status.textContent = _('Powering off, please wait...');
let n = 5;
const t = setInterval(function () {
if (--n <= 0) {
clearInterval(t);
status.textContent = _('Powered off.');
icon.style.display = 'inline-block';
} else {
status.textContent = _('Powering off, please wait...') + ' ' + n;
}
}, 1000);
});
})
});
return E('div', {}, [
E('h2', _('Power Off')),
E('p', _('Click the button below to safely power off the device.')),
E('div', { class: 'cbi-section' }, [btn, status, icon])
]);
}
});
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-2.0
//
// Shared helper module for all luci-app-amlogic JS views.
// Purpose: on first page load, inject the theme-aware stylesheet amlogic.css
// into <head> (which contains .amlogic-status-* status colors, .amlogic-snap-*
// snapshot cards, and prefers-color-scheme dark mode overrides) so every view
// can rely on the same set of classes instead of duplicating inline styles.
'use strict';
'require baseclass';
return baseclass.extend({
// Inject the shared stylesheet (idempotent).
// We tag the <link> element with a fixed id 'amlogic-shared-css' so that
// subsequent calls return immediately and avoid appending it twice. The
// stylesheet path is resolved via L.resource() to
// /luci-static/resources/view/amlogic/amlogic.css.
ensureCss: function () {
if (document.getElementById('amlogic-shared-css')) return;
const link = document.createElement('link');
link.id = 'amlogic-shared-css';
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = L.resource('view/amlogic/amlogic.css');
document.head.appendChild(link);
}
});
@@ -0,0 +1,315 @@
// SPDX-License-Identifier: GPL-2.0
// Manually Upload Update
//
// Features:
// 1. Use ui.uploadFile() to open the browser-native file picker and upload
// to the path returned by the backend (default /tmp/upload/);
// 2. Show a table of files in the upload directory (name / mtime / mode /
// size) with Remove, Install (ipk) and Restore (config backup) actions;
// 3. Dynamically reveal "Update firmware" / "Replace kernel" buttons based
// on which file types are currently present in the upload dir; all
// buttons share doButton() to keep disabled / text / status transitions
// consistent;
// 4. Concurrently poll the firmware/kernel log tails and display the
// current firmware version.
'use strict';
'require view';
'require rpc';
'require ui';
'require dom';
'require poll';
'require fs';
'require view.amlogic.shared as amlogicShared';
// Query the upload directory the backend allows (default /tmp/upload/, the
// backend ensures it exists).
const callUploadPath = rpc.declare({ object: 'luci.amlogic', method: 'upload_path',
expect: { path: '/tmp/upload/' } });
// List files in the upload dir plus flags about installable firmware/kernel/cfg.
const callList = rpc.declare({ object: 'luci.amlogic', method: 'list_uploads' });
// Delete a named file in the upload dir.
const callDelete = rpc.declare({ object: 'luci.amlogic', method: 'delete_upload',
params: ['name'] });
// Install one uploaded file (ipk) or restore a config backup; the backend
// dispatches by suffix.
const callInstall = rpc.declare({ object: 'luci.amlogic', method: 'install_upload',
params: ['name'] });
// Chunked upload via our own RPC (sidesteps cgi-upload's session ACL flow).
const callUploadChunk = rpc.declare({ object: 'luci.amlogic', method: 'upload_chunk',
params: ['name', 'data', 'append'] });
// Run the full firmware update flow with parameters auto@updated@/tmp.
const callStartUpd = rpc.declare({ object: 'luci.amlogic', method: 'start_update',
params: ['amlogic_update_sel'] });
// Run the kernel-only replace flow.
const callStartKnl = rpc.declare({ object: 'luci.amlogic', method: 'start_kernel' });
// Tail a named log to surface progress during install/update.
const callLogTail = rpc.declare({ object: 'luci.amlogic', method: 'read_log_tail',
params: ['name'], expect: { line: '' } });
// Read system state (this page only uses current_firmware_version).
const callState = rpc.declare({ object: 'luci.amlogic', method: 'state' });
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
load: function () {
amlogicShared.ensureCss();
return callUploadPath();
},
render: function (path) {
const view = this;
const tableContainer = E('div');
const descSpan = E('span', { class: 'amlogic-status-ok', style: 'font-weight:bold' });
const fwBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Update OpenWrt firmware'), style: 'display:none',
click: ui.createHandlerFn(this, function (ev) {
return doButton(ev.currentTarget,
_('Updating...'), _('Successful Update'), _('Update Failed'),
function () { return callStartUpd('auto@updated@/tmp'); });
})
});
const knBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Replace OpenWrt Kernel'), style: 'display:none; margin-left:5px',
click: ui.createHandlerFn(this, function (ev) {
return doButton(ev.currentTarget,
_('Updating...'), _('Successful Update'), _('Update Failed'),
function () { return callStartKnl(); });
})
});
const fwLog = E('span');
const knLog = E('span');
const fwVer = E('span', _('Collecting data...'));
function doButton(btn, busyText, ok, fail, fn) {
// Shared button state machine: disabled -> RPC -> success/fail label.
btn.disabled = true; btn.value = busyText;
return Promise.resolve(fn()).then(function (r) {
btn.value = (r && r.code == 0) ? ok : fail;
}).catch(function () { btn.value = fail; })
.finally(function () { btn.disabled = false; });
}
function refreshList() {
// Refresh the file table and the hint text; the firmware / kernel
// buttons are shown or hidden based on the has_firmware / has_kernel
// flags reported by the backend.
return callList().then(function (info) {
dom.content(tableContainer, buildTable(info.items || [], info.path));
let parts = [];
if (info.has_config)
parts.push(_('There are config file in the upload directory, and you can restore the config. '));
if (info.has_kernel)
parts.push(_('There are kernel files in the upload directory, and you can replace the kernel.'));
if (info.has_firmware)
parts.push(_('There are openwrt firmware file in the upload directory, and you can update the openwrt.'));
descSpan.textContent = parts.length ? ' Tip: ' + parts.join('') : '';
fwBtn.style.display = info.has_firmware ? '' : 'none';
knBtn.style.display = info.has_kernel ? '' : 'none';
});
}
function buildTable(items, dir) {
// We expose three actions per row: remove any file, install ipk, and
// restore a config backup. Other types (firmware, kernel archive)
// are listed but have no individual install entry.
const tbl = E('table', { class: 'table cbi-section-table' }, [
E('tr', { class: 'tr cbi-section-table-titles' }, [
E('th', { class: 'th' }, _('File name')),
E('th', { class: 'th' }, _('Modify time')),
E('th', { class: 'th' }, _('Attributes')),
E('th', { class: 'th' }, _('Size')),
E('th', { class: 'th' }, _('Remove')),
E('th', { class: 'th' }, _('Install'))
])
]);
items.forEach(function (it) {
const removeBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-remove',
value: _('Remove'),
click: ui.createHandlerFn(view, function (ev) {
return callDelete(it.name).then(refreshList);
})
});
let installCell;
if (it.ipk) {
// ipk: "Install" calls install_upload to install one package.
installCell = E('input', {
type: 'button', class: 'cbi-button cbi-button-apply',
value: _('Install'),
click: ui.createHandlerFn(view, function (ev) {
ev.currentTarget.disabled = true;
return callInstall(it.name).then(function (r) {
if (r && r.code !== 0 && r.output)
ui.addNotification(null,
E('p', { class: 'amlogic-status-err' }, [
r.output, E('br'),
E('b', _('Please refresh the page to see the changes.'))
]));
else if (r && r.code === 0)
ui.addNotification(null,
E('p', { class: 'amlogic-status-ok' },
_('Package installed successfully. Please refresh the page to see the changes.')));
return refreshList();
});
})
});
} else if (it.cfg) {
// Config backup (openwrt-config.tar.gz): "Restore" makes the
// backend roll the config back and reboot.
installCell = E('input', {
type: 'button', class: 'cbi-button cbi-button-apply',
value: _('Restore'),
click: ui.createHandlerFn(view, function (ev) {
ev.currentTarget.disabled = true;
ui.addNotification(null,
E('p', { class: 'amlogic-status-ok' },
_('Tip: The config is being restored, and it will automatically restart after completion.')));
return callInstall(it.name);
})
});
} else {
installCell = E('span', '-');
}
tbl.appendChild(E('tr', { class: 'tr cbi-rowstyle-1' }, [
E('td', { class: 'td' }, it.name),
E('td', { class: 'td' }, it.mtime),
E('td', { class: 'td' }, it.modestr),
E('td', { class: 'td' }, it.size),
E('td', { class: 'td' }, removeBtn),
E('td', { class: 'td' }, installCell)
]));
});
if (!items.length)
tbl.appendChild(E('tr', [E('td', { class: 'td', colspan: '6' }, _('No specify upload file.'))]));
return tbl;
}
// File upload widget. We deliberately avoid /cgi-bin/cgi-upload here
// because it requires the LuCI session to carry a `file` ACL entry
// for the chosen target path, which depends on rpcd ACL bookkeeping
// being live for this session — historically a fragile path.
// Instead we slice the picked file into ~256 KB chunks, base64-encode
// each chunk and POST them through our own ubus method
// `luci.amlogic.upload_chunk`. The ucode handler runs as root and
// owns the upload directory, so no separate file-write ACL is
// involved. Permission is governed solely by the existing
// `upload_chunk` entry in the rpcd ACL `write.ubus` block.
// Chunk size for ubus payload. ubus has a per-request size limit
// (~64 KB) — at 256 KB raw → ~344 KB base64 the request was silently
// dropped and luci returned "No related RPC reply". 32 KB raw →
// ~44 KB base64 leaves comfortable headroom.
const CHUNK_SIZE = 32 * 1024;
const uploadStatus = E('span');
const uploadFileName = E('span', { style: 'margin-right:1em' });
// Encode a Uint8Array as base64. btoa() expects a binary string, so
// we walk through the bytes in 32k slices to avoid argument-list
// limits in browsers.
function bytesToB64(buf) {
let s = '';
for (let i = 0; i < buf.length; i += 0x8000)
s += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000));
return btoa(s);
}
function uploadFile(file, target, basename) {
const total = file.size;
let offset = 0;
let first = true;
function step() {
if (offset >= total) {
uploadStatus.textContent = _('File saved to') + ' ' + target;
return refreshList();
}
const slice = file.slice(offset, offset + CHUNK_SIZE);
return slice.arrayBuffer().then(function (buf) {
const b64 = bytesToB64(new Uint8Array(buf));
const append = !first;
first = false;
return callUploadChunk(basename, b64, append).then(function (r) {
if (!r || !r.ok)
throw new Error((r && r.error) || _('upload chunk failed'));
offset += CHUNK_SIZE;
const pct = Math.min(100, (offset / total) * 100);
uploadStatus.textContent = _('Uploading file…') +
' ' + pct.toFixed(1) + '%';
return step();
});
});
}
return step();
}
const hiddenFileInput = E('input', {
type: 'file', style: 'display:none',
change: ui.createHandlerFn(this, function (ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
const basename = file.name.replace(/^.*[\\\/]/, '');
const target = path.replace(/\/+$/, '') + '/' + basename;
uploadFileName.textContent = basename;
uploadStatus.textContent = _('Uploading file…') + ' 0%';
return uploadFile(file, target, basename).catch(function (e) {
uploadStatus.textContent = _('Create upload file error.') +
' ' + (e && e.message ? e.message : e);
}).finally(function () {
hiddenFileInput.value = '';
});
})
});
const uploadBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-apply',
value: _('Upload file...'),
click: function () { hiddenFileInput.click(); }
});
// Polling: log tails + version
// Pull the last line of both firmware and kernel logs once per second.
poll.add(function () {
return Promise.all([
callLogTail('firmware').then(function (l) {
dom.content(fwLog, l && l !== '\n'
? E('span', { class: 'amlogic-status-info' }, ' ' + l) : '');
}),
callLogTail('kernel').then(function (l) {
dom.content(knLog, l && l !== '\n'
? E('span', { class: 'amlogic-status-info' }, ' ' + l) : '');
})
]);
}, 1);
callState().then(function (s) {
dom.content(fwVer, s.current_firmware_version
? E('span', { class: 'amlogic-status-ok' }, _('Current Version') + ' [ ' + s.current_firmware_version + ' ] ')
: E('span', { class: 'amlogic-status-err' }, _('Invalid value.')));
});
refreshList();
return E('div', {}, [
E('h2', _('Upload')),
E('p', _('Update plugins first, then update the kernel or firmware.')),
E('p', _('After uploading [Firmware], [Kernel], [IPK] or [Backup Config], the operation buttons will be displayed.')),
E('div', { class: 'cbi-section' }, [
E('label', { style: 'display:inline-block;width:150px' },
_('Choose local file:')),
uploadBtn, ' ', uploadFileName, hiddenFileInput,
E('br'), uploadStatus
]),
E('h3', _('Upload file list')),
E('div', { class: 'cbi-section' }, [descSpan, tableContainer]),
E('div', { class: 'cbi-section' }, [
E('p', { style: 'text-align:center' },
_('After uploading firmware (.img/.img.gz/.img.xz/.7z suffix) or kernel files (3 kernel files), the update button will be displayed.')),
// Single centered cell: when fwBtn / knBtn are hidden the row
// would otherwise show an empty left column.
E('div', { style: 'text-align:center' },
[fwBtn, knBtn, ' ', fwVer, ' ', fwLog, knLog])
])
]);
}
});