mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-31 23:35:13 +08:00
6647 lines
349 KiB
JavaScript
6647 lines
349 KiB
JavaScript
/**
|
||
* luci-app-quickactions
|
||
* All-in-one dashboard and system-control surface for OpenWrt / ImmortalWrt.
|
||
*
|
||
* Bundles 14 tabs: Dashboard, Essential, Tools, Logs, Services, Hotplug,
|
||
* Crontab, Guest WiFi, Terminal (ttyd), Task Plan, Network Setup, Command,
|
||
* Dependencies, Configuration.
|
||
*
|
||
* Licensed to the GNU General Public License v3.0.
|
||
*/
|
||
|
||
'use strict';
|
||
'require view';
|
||
'require ui';
|
||
'require uci';
|
||
'require rpc';
|
||
'require form';
|
||
'require fs';
|
||
'require poll';
|
||
'require dom';
|
||
'require uqr';
|
||
'require tools.widgets as widgets';
|
||
|
||
// =====================================================================
|
||
// LuCI 26.203 workaround — dom.data() crashes on stale data-idref nodes
|
||
//
|
||
// dom.content() deletes registry entries for every descendant with a
|
||
// data-idref attribute, but leaves the attribute on the node. Any later
|
||
// dom.data(node, key, value) on those nodes throws
|
||
// "Cannot set properties of undefined (setting '_class')"
|
||
// which crashes form.js's CBIMap.renderContents(), the last step of
|
||
// form.Map.save(). The writes reach the server first, but the promise
|
||
// chain rejects and the UI reports failure.
|
||
//
|
||
// Reinitialize the registry slot when we detect it's been dropped.
|
||
// =====================================================================
|
||
(function patchDOMData() {
|
||
try {
|
||
if (!dom || dom._qaDataPatched) return;
|
||
var _orig = dom.data;
|
||
dom.data = function(node, key, val) {
|
||
if (node && typeof node.getAttribute === 'function') {
|
||
var id = node.getAttribute('data-idref');
|
||
if (id != null && this.registry && !this.registry[id]) {
|
||
// Fires for BOTH the getter and setter paths. dom.content()
|
||
// deletes registry entries when nodes are detached but
|
||
// leaves the data-idref attribute on the node. The next
|
||
// dom.data(node, '_class') then hits
|
||
// this.registry[id][key]
|
||
// with registry[id] === undefined and throws
|
||
// "Cannot read properties of undefined (reading '_class')".
|
||
// Seed a fresh empty slot so the getter returns undefined.
|
||
this.registry[id] = {};
|
||
}
|
||
}
|
||
return _orig.apply(this, arguments);
|
||
};
|
||
dom._qaDataPatched = true;
|
||
console.log('[qa-fix] dom.data patched (getter+setter, data-idref)');
|
||
} catch(e) {
|
||
console.warn('[qa-fix] failed to patch dom.data:', e);
|
||
}
|
||
})();
|
||
|
||
|
||
|
||
function getCurrentLanIp() {
|
||
var lanIpRaw = uci.get('network', 'lan', 'ipaddr');
|
||
var lanIp = Array.isArray(lanIpRaw) ? (lanIpRaw[0] || '192.168.10.1') : (lanIpRaw || '192.168.10.1');
|
||
return String(lanIp).split(' ')[0];
|
||
}
|
||
|
||
var GuestWifiViewClass = (function() {
|
||
|
||
/*
|
||
Copyright 2026 Rafał Wabik - IceG - From eko.one.pl forum
|
||
|
||
Licensed to the GNU General Public License v3.0.
|
||
*/
|
||
|
||
let DEFAULTS = {
|
||
ssid: 'Guest-WiFi',
|
||
encryption: 'psk2',
|
||
password: '',
|
||
radio: 'radio0',
|
||
isolate: '1',
|
||
macaddr: '',
|
||
ip: '172.16.0.1',
|
||
netmask: '255.255.255.0',
|
||
dhcpStart: '100',
|
||
dhcpLimit: '150',
|
||
dhcpLease: '12h',
|
||
fwForwardDest: 'wan',
|
||
fwInput: 'REJECT',
|
||
fwOutput: 'ACCEPT',
|
||
fwForward: 'REJECT',
|
||
fwDhcpPorts: '67-68',
|
||
fwDnsPort: '53'
|
||
};
|
||
|
||
let OWNER_TAG = 'guest_owner';
|
||
|
||
function addGuestWifiStyles() {
|
||
if (document.getElementById('guest-wifi-styles')) return;
|
||
let style = document.createElement('style');
|
||
style.id = 'guest-wifi-styles';
|
||
style.type = 'text/css';
|
||
style.textContent = '\
|
||
:root {\
|
||
--gw-badge-on-bg: #34c759;\
|
||
--gw-badge-off-bg: #7f8c8d;\
|
||
--gw-badge-text: #ffffff;\
|
||
--gw-badge-shadow: 0 1px 2px rgba(0,0,0,.4), 0 2px 6px rgba(0,0,0,.25);\
|
||
--gw-badge-border-on: transparent;\
|
||
--gw-badge-border-off: transparent;\
|
||
}\
|
||
:root[data-darkmode="true"] {\
|
||
--gw-badge-on-bg: rgba(46,204,113,0.28);\
|
||
--gw-badge-off-bg: rgba(255,255,255,0.12);\
|
||
--gw-badge-text: #e5e7eb;\
|
||
--gw-badge-shadow: 0 1px 2px rgba(0,0,0,.35), 0 2px 6px rgba(0,0,0,.22);\
|
||
--gw-badge-border-on: rgba(46,204,113,0.5);\
|
||
--gw-badge-border-off: rgba(255,255,255,0.3);\
|
||
}\
|
||
.gw-badge {\
|
||
display:inline-block;\
|
||
padding:4px 10px;\
|
||
border-radius:4px;\
|
||
color:var(--gw-badge-text);\
|
||
font-size:13px;\
|
||
font-weight:500;\
|
||
white-space:nowrap;\
|
||
text-align:center;\
|
||
border:1px solid transparent;\
|
||
text-shadow:var(--gw-badge-shadow);\
|
||
}\
|
||
.gw-badge-on { background:var(--gw-badge-on-bg); border-color:var(--gw-badge-border-on); }\
|
||
.gw-badge-off { background:var(--gw-badge-off-bg); border-color:var(--gw-badge-border-off); }\
|
||
';
|
||
document.head.appendChild(style);
|
||
|
||
// Layout fixups for the narrow iframe context. The outer page
|
||
// reserves sidebar space, so the iframe viewport is smaller than
|
||
// a full page; these rules keep modals and controls usable.
|
||
if (!document.getElementById('guest-wifi-layout-styles')) {
|
||
let ls = document.createElement('style');
|
||
ls.id = 'guest-wifi-layout-styles';
|
||
ls.type = 'text/css';
|
||
ls.textContent = '\
|
||
.modal.cbi-modal { width: min(95vw, 1200px) !important; max-width: 95vw !important; min-width: 0 !important; }\
|
||
.modal.cbi-modal .cbi-value { display: flex !important; flex-wrap: nowrap !important; align-items: flex-start !important; }\
|
||
.modal.cbi-modal .cbi-value-title { flex: 0 0 200px !important; }\
|
||
.modal.cbi-modal .cbi-value-field { flex: 1 1 auto !important; min-width: 0 !important; }\
|
||
.cbi-value-field { min-width: 0 !important; }\
|
||
.cbi-value-field select,\
|
||
.cbi-value-field input,\
|
||
.cbi-value-field .cbi-dropdown { max-width: 100% !important; }\
|
||
.btn.qr-code { display: inline-flex; align-items: center; gap: .5em; }\
|
||
.btn.qr-code svg { display: block; height: 1.5em; width: auto; }\
|
||
html, body { background-color: var(--background-color-default, #141414) !important; background-image: none !important; }\
|
||
#view.spinning::before,\
|
||
#view.spinning::after,\
|
||
body > .spinning,\
|
||
body > .loading-overlay,\
|
||
body > .spinner { display: none !important; visibility: hidden !important; }\
|
||
.cbi-input-password { padding-right: 34px !important; }\
|
||
';
|
||
document.head.appendChild(ls);
|
||
}
|
||
}
|
||
|
||
// Un-cap .container inside the embed page. The embed view is a full
|
||
// LuCI page loaded at ?embed=guestwifi, so it has its own theme
|
||
// wrapper (.container) that caps width at ~940px. Un-cap here so the
|
||
// guest wifi form and its modals use the entire iframe viewport.
|
||
(function(){
|
||
if (document.getElementById('qa-embed-fullwidth')) return;
|
||
var s = document.createElement('style');
|
||
s.id = 'qa-embed-fullwidth';
|
||
s.type = 'text/css';
|
||
s.textContent = '.container,.main,.main-right,.cbi-map,.fs-content,.fs-main{' +
|
||
'max-width:none !important;width:100% !important;' +
|
||
'padding-left:0 !important;padding-right:0 !important;' +
|
||
'margin-left:0 !important;margin-right:0 !important;}' +
|
||
'html,body{height:auto !important;min-height:0 !important;}' +
|
||
'body>.container,body>#view,body>main{min-height:0 !important;}'
|
||
document.head.appendChild(s);
|
||
})();
|
||
|
||
function ssidBadge(section_id, text) {
|
||
let val = uci.get('guestwifi', section_id, 'enable');
|
||
let enabled = (val == null) ? true : (val === '1');
|
||
let cls = 'gw-badge ' + (enabled ? 'gw-badge-on' : 'gw-badge-off');
|
||
return E('span', { 'class': cls }, text || '');
|
||
}
|
||
|
||
function radioBadge(section_id, radioName, wifiDevices) {
|
||
let val = uci.get('guestwifi', section_id, 'enable');
|
||
let enabled = (val == null) ? true : (val === '1');
|
||
let label = radioLabel(radioName, wifiDevices);
|
||
|
||
return E('span', { 'class': 'ifacebadge' }, [
|
||
E('img', { 'src': L.resource('icons/wifi%s.svg').format(enabled ? '' : '_disabled') }),
|
||
' ',
|
||
label,
|
||
'\u00A0'
|
||
]);
|
||
}
|
||
|
||
let ENCRYPTION_MODES = [
|
||
['psk2', 'WPA2-PSK'],
|
||
['sae', 'WPA3-SAE'],
|
||
['sae-mixed', 'WPA2-PSK/WPA3-SAE ' + _('Mixed Mode')],
|
||
['psk-mixed', 'WPA-PSK/WPA2-PSK ' + _('Mixed Mode')],
|
||
['psk', 'WPA-PSK'],
|
||
['owe', 'OWE (' + _('Enhanced Open') + ')'],
|
||
['wep-open', _('WEP Open System')],
|
||
['wep-shared', _('WEP Shared Key')],
|
||
['none', _('No encryption (open network)')]
|
||
];
|
||
|
||
function encryptionLabel(key) {
|
||
let hit = ENCRYPTION_MODES.filter(function(m) { return m[0] === key; })[0];
|
||
return hit ? hit[1] : key;
|
||
}
|
||
|
||
let PASSWORD_CHARSETS = {
|
||
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||
lower: 'abcdefghijklmnopqrstuvwxyz',
|
||
digits: '1234567890',
|
||
special: '!@#$%^&*()-_=+[]{}?'
|
||
};
|
||
|
||
function secureRandomInt(max) {
|
||
let cryptoObj = window.crypto || window.msCrypto;
|
||
let arr = new Uint32Array(1);
|
||
|
||
if (cryptoObj && cryptoObj.getRandomValues) {
|
||
cryptoObj.getRandomValues(arr);
|
||
return arr[0] % max;
|
||
}
|
||
|
||
return Math.floor(Math.random() * max);
|
||
}
|
||
|
||
function shuffleArray(arr) {
|
||
for (let i = arr.length - 1; i > 0; i--) {
|
||
let j = secureRandomInt(i + 1);
|
||
let tmp = arr[i];
|
||
arr[i] = arr[j];
|
||
arr[j] = tmp;
|
||
}
|
||
return arr;
|
||
}
|
||
|
||
function generateStrongPassword(length, components) {
|
||
let chosenSets = (components || []).map(function(c) { return PASSWORD_CHARSETS[c]; }).filter(Boolean);
|
||
|
||
if (!chosenSets.length)
|
||
chosenSets = [PASSWORD_CHARSETS.lower, PASSWORD_CHARSETS.digits];
|
||
|
||
length = Math.max(parseInt(length, 10) || 0, chosenSets.length);
|
||
length = Math.min(Math.max(length, 8), 63);
|
||
|
||
let pool = chosenSets.join('');
|
||
let result = chosenSets.map(function(set) { return set.charAt(secureRandomInt(set.length)); });
|
||
|
||
for (let i = result.length; i < length; i++)
|
||
result.push(pool.charAt(secureRandomInt(pool.length)));
|
||
|
||
return shuffleArray(result).join('');
|
||
}
|
||
|
||
let cbiPasswordComponentsValue = form.ListValue.extend({
|
||
renderWidget: function(section_id, option_index, cfgvalue) {
|
||
let choices = this.transformChoices();
|
||
let widget = new ui.Dropdown(
|
||
(cfgvalue != null) ? cfgvalue : this.default,
|
||
choices,
|
||
{
|
||
id: this.cbid(section_id),
|
||
sort: this.keylist,
|
||
optional: true,
|
||
multiple: true,
|
||
display_items: 4,
|
||
dropdown_items: 4,
|
||
select_placeholder: this.placeholder,
|
||
validate: L.bind(this.validate, this, section_id),
|
||
disabled: (this.readonly != null) ? this.readonly : this.map.readonly
|
||
}
|
||
);
|
||
|
||
window.__guestPasswordComponentsDropdown = window.__guestPasswordComponentsDropdown || {};
|
||
window.__guestPasswordComponentsDropdown[section_id] = widget;
|
||
|
||
return widget.render();
|
||
}
|
||
});
|
||
|
||
function buildSVGQRCode(data, code, options, dummy) {
|
||
let opts = Object.assign({
|
||
pixelSize: 4,
|
||
whiteColor: 'white',
|
||
blackColor: 'black',
|
||
ecc: 'M'
|
||
}, options);
|
||
|
||
let svg = uqr.renderSVG(data, opts);
|
||
|
||
if (dummy)
|
||
return svg;
|
||
|
||
code.style.opacity = '';
|
||
dom.content(code, Object.assign(E(svg), { style: 'width:100%;height:auto' }));
|
||
}
|
||
|
||
function assignNetIndexes() {
|
||
let defs = uci.sections('guestwifi', 'guest');
|
||
let used = {};
|
||
|
||
defs.forEach(function(def) {
|
||
let idx = parseInt(def.net_idx, 10);
|
||
if (idx > 0)
|
||
used[idx] = true;
|
||
});
|
||
|
||
let nextIdx = 1;
|
||
function takeNextFreeIdx() {
|
||
while (used[nextIdx]) nextIdx++;
|
||
used[nextIdx] = true;
|
||
return nextIdx;
|
||
}
|
||
|
||
defs.forEach(function(def) {
|
||
let idx = parseInt(def.net_idx, 10);
|
||
if (!(idx > 0))
|
||
uci.set('guestwifi', def['.name'], 'net_idx', String(takeNextFreeIdx()));
|
||
});
|
||
}
|
||
|
||
function namesForNetIndex(idx) {
|
||
let suf = (idx > 1) ? String(idx) : '';
|
||
return {
|
||
networkName: 'guest' + (suf ? '_' + suf : ''),
|
||
deviceName: 'br-guest' + suf,
|
||
wifiName: 'guestwifi' + (suf ? '_' + suf : ''),
|
||
dhcpName: 'guest' + (suf ? '_' + suf : '')
|
||
};
|
||
}
|
||
|
||
let IP_POOL_FIRST_OCTET = 172;
|
||
let IP_POOL_SECOND_FIRST = 16;
|
||
let IP_POOL_SECOND_LAST = 31;
|
||
|
||
function usedInterfaceIps(excludeSid) {
|
||
let used = {};
|
||
|
||
uci.sections('guestwifi', 'guest').forEach(function(s) {
|
||
if (s['.name'] === excludeSid) return;
|
||
if (s.interface_ip) used[s.interface_ip] = true;
|
||
});
|
||
uci.sections('network', 'interface').forEach(function(s) {
|
||
if (excludeSid != null && s[OWNER_TAG] === excludeSid) return;
|
||
if (s.ipaddr) used[s.ipaddr] = true;
|
||
});
|
||
|
||
return used;
|
||
}
|
||
|
||
function randomFreeGuestIp(excludeSid) {
|
||
let used = usedInterfaceIps(excludeSid);
|
||
let candidates = [];
|
||
|
||
for (let o2 = IP_POOL_SECOND_FIRST; o2 <= IP_POOL_SECOND_LAST; o2++) {
|
||
let ip = IP_POOL_FIRST_OCTET + '.' + o2 + '.0.1';
|
||
if (!used[ip])
|
||
candidates.push(ip);
|
||
}
|
||
|
||
if (!candidates.length)
|
||
return null;
|
||
|
||
return candidates[Math.floor(Math.random() * candidates.length)];
|
||
}
|
||
|
||
function bandLabelForDevice(dev) {
|
||
if (!dev)
|
||
return '';
|
||
|
||
let band = dev.band;
|
||
if (band === '2g') return '2.4 GHz';
|
||
if (band === '5g') return '5 GHz';
|
||
if (band === '6g') return '6 GHz';
|
||
|
||
let hw = dev.hwmode || '';
|
||
if (/^11a/.test(hw) && !/^11ax/.test(hw)) {
|
||
return '5 GHz';
|
||
}
|
||
if (/^11(b|g)/.test(hw))
|
||
return '2.4 GHz';
|
||
|
||
let htmode = dev.htmode || '';
|
||
if (/^HE160|HE80|VHT/.test(htmode))
|
||
return '5 GHz';
|
||
|
||
return '';
|
||
}
|
||
|
||
function radioLabel(radioName, wifiDevices) {
|
||
let dev = wifiDevices.filter(function(d) { return d['.name'] === radioName; })[0];
|
||
let band = bandLabelForDevice(dev);
|
||
return band ? (radioName + ' (' + band + ')') : radioName;
|
||
}
|
||
|
||
function cleanupGuestConfig(validSids, ownerSid) {
|
||
function shouldRemove(s) {
|
||
let owner = s[OWNER_TAG];
|
||
if (!owner)
|
||
return false;
|
||
if (ownerSid != null)
|
||
return owner === ownerSid;
|
||
return validSids.indexOf(owner) === -1;
|
||
}
|
||
|
||
uci.sections('network', 'interface').forEach(function(s) {
|
||
if (shouldRemove(s)) uci.remove('network', s['.name']);
|
||
});
|
||
uci.sections('network', 'device').forEach(function(s) {
|
||
if (shouldRemove(s)) uci.remove('network', s['.name']);
|
||
});
|
||
uci.sections('wireless', 'wifi-iface').forEach(function(s) {
|
||
if (shouldRemove(s)) uci.remove('wireless', s['.name']);
|
||
});
|
||
uci.sections('dhcp', 'dhcp').forEach(function(s) {
|
||
if (shouldRemove(s)) uci.remove('dhcp', s['.name']);
|
||
});
|
||
uci.sections('firewall', 'zone').forEach(function(s) {
|
||
if (shouldRemove(s)) uci.remove('firewall', s['.name']);
|
||
});
|
||
uci.sections('firewall', 'forwarding').forEach(function(s) {
|
||
if (shouldRemove(s)) uci.remove('firewall', s['.name']);
|
||
});
|
||
uci.sections('firewall', 'rule').forEach(function(s) {
|
||
if (shouldRemove(s)) uci.remove('firewall', s['.name']);
|
||
});
|
||
}
|
||
|
||
function collectOwnerSids() {
|
||
let sids = {};
|
||
|
||
uci.sections('network', 'interface').forEach(function(s) { if (s[OWNER_TAG]) sids[s[OWNER_TAG]] = true; });
|
||
uci.sections('network', 'device').forEach(function(s) { if (s[OWNER_TAG]) sids[s[OWNER_TAG]] = true; });
|
||
uci.sections('wireless', 'wifi-iface').forEach(function(s) { if (s[OWNER_TAG]) sids[s[OWNER_TAG]] = true; });
|
||
uci.sections('dhcp', 'dhcp').forEach(function(s) { if (s[OWNER_TAG]) sids[s[OWNER_TAG]] = true; });
|
||
uci.sections('firewall', 'zone').forEach(function(s) { if (s[OWNER_TAG]) sids[s[OWNER_TAG]] = true; });
|
||
uci.sections('firewall', 'forwarding').forEach(function(s) { if (s[OWNER_TAG]) sids[s[OWNER_TAG]] = true; });
|
||
uci.sections('firewall', 'rule').forEach(function(s) { if (s[OWNER_TAG]) sids[s[OWNER_TAG]] = true; });
|
||
|
||
return Object.keys(sids);
|
||
}
|
||
|
||
function importGuestNetwork(sid) {
|
||
let iface = uci.sections('network', 'interface').filter(function(s) { return s[OWNER_TAG] === sid; })[0];
|
||
let dhcp = uci.sections('dhcp', 'dhcp').filter(function(s) { return s[OWNER_TAG] === sid; })[0];
|
||
let wifi = uci.sections('wireless', 'wifi-iface').filter(function(s) { return s[OWNER_TAG] === sid; })[0];
|
||
let zone = uci.sections('firewall', 'zone').filter(function(s) { return s[OWNER_TAG] === sid; })[0];
|
||
let fwd = uci.sections('firewall', 'forwarding').filter(function(s) { return s[OWNER_TAG] === sid; })[0];
|
||
let rules = uci.sections('firewall', 'rule').filter(function(s) { return s[OWNER_TAG] === sid; });
|
||
|
||
let dhcpRule = rules.filter(function(r) { return r.proto === 'udp'; })[0];
|
||
let dnsRule = rules.filter(function(r) { return r.proto === 'tcpudp'; })[0];
|
||
|
||
uci.add('guestwifi', 'guest', sid);
|
||
uci.set('guestwifi', sid, 'enable', (wifi && wifi.disabled === '1') ? '0' : '1');
|
||
|
||
if (wifi) {
|
||
uci.set('guestwifi', sid, 'ssid', wifi.ssid || DEFAULTS.ssid);
|
||
uci.set('guestwifi', sid, 'radio', wifi.device || DEFAULTS.radio);
|
||
uci.set('guestwifi', sid, 'isolate', wifi.isolate || DEFAULTS.isolate);
|
||
if (wifi.macaddr)
|
||
uci.set('guestwifi', sid, 'macaddr', wifi.macaddr);
|
||
|
||
if (!wifi.encryption || wifi.encryption === 'none' || wifi.encryption === 'owe') {
|
||
uci.set('guestwifi', sid, 'encryption', wifi.encryption || 'none');
|
||
} else if (wifi.encryption === 'wep-open' || wifi.encryption === 'wep-shared') {
|
||
uci.set('guestwifi', sid, 'encryption', wifi.encryption);
|
||
uci.set('guestwifi', sid, 'password', wifi.key1 || DEFAULTS.password);
|
||
} else {
|
||
uci.set('guestwifi', sid, 'encryption', wifi.encryption);
|
||
uci.set('guestwifi', sid, 'password', wifi.key || DEFAULTS.password);
|
||
}
|
||
}
|
||
|
||
if (iface) {
|
||
uci.set('guestwifi', sid, 'interface_ip', iface.ipaddr || DEFAULTS.ip);
|
||
uci.set('guestwifi', sid, 'netmask', iface.netmask || DEFAULTS.netmask);
|
||
}
|
||
|
||
if (dhcp) {
|
||
uci.set('guestwifi', sid, 'dhcp_start', dhcp.start || DEFAULTS.dhcpStart);
|
||
uci.set('guestwifi', sid, 'dhcp_limit', dhcp.limit || DEFAULTS.dhcpLimit);
|
||
uci.set('guestwifi', sid, 'dhcp_lease', dhcp.leasetime || DEFAULTS.dhcpLease);
|
||
}
|
||
|
||
if (zone) {
|
||
uci.set('guestwifi', sid, 'fw_input', zone.input || DEFAULTS.fwInput);
|
||
uci.set('guestwifi', sid, 'fw_output', zone.output || DEFAULTS.fwOutput);
|
||
uci.set('guestwifi', sid, 'fw_forward', zone.forward || DEFAULTS.fwForward);
|
||
}
|
||
|
||
if (fwd) {
|
||
uci.set('guestwifi', sid, 'fw_forward_dest', fwd.dest || DEFAULTS.fwForwardDest);
|
||
}
|
||
|
||
if (dhcpRule) {
|
||
uci.set('guestwifi', sid, 'fw_dhcp_ports', dhcpRule.dest_port || DEFAULTS.fwDhcpPorts);
|
||
}
|
||
|
||
if (dnsRule) {
|
||
uci.set('guestwifi', sid, 'fw_dns_port', dnsRule.dest_port || DEFAULTS.fwDnsPort);
|
||
}
|
||
}
|
||
|
||
function knownGuestSids() {
|
||
return uci.sections('guestwifi', 'guest').map(function(s) { return s['.name']; });
|
||
}
|
||
|
||
function resolveDuplicateInterfaceIps() {
|
||
let seen = {};
|
||
|
||
uci.sections('guestwifi', 'guest').forEach(function(def) {
|
||
let sid = def['.name'];
|
||
let ip = def.interface_ip;
|
||
if (!ip) return;
|
||
|
||
if (seen[ip]) {
|
||
let newIp = randomFreeGuestIp(sid);
|
||
if (newIp) {
|
||
uci.set('guestwifi', sid, 'interface_ip', newIp);
|
||
seen[newIp] = true;
|
||
}
|
||
} else {
|
||
seen[ip] = true;
|
||
}
|
||
});
|
||
}
|
||
|
||
function guessLegacyInterfaceNames() {
|
||
return uci.sections('network', 'interface').filter(function(s) {
|
||
if (s[OWNER_TAG]) return false;
|
||
if (/^cfg[0-9a-f]+$/i.test(s['.name'])) return false;
|
||
return /guest/i.test(s['.name']) || /guest/i.test(s.device || '');
|
||
}).map(function(s) { return s['.name']; });
|
||
}
|
||
|
||
function zoneMatchesNetwork(zone, ifname) {
|
||
let net = zone.network;
|
||
if (Array.isArray(net)) return net.indexOf(ifname) !== -1;
|
||
return net === ifname;
|
||
}
|
||
|
||
function importLegacyGuestNetwork(ifname) {
|
||
let sid = ifname;
|
||
|
||
uci.set('network', ifname, OWNER_TAG, sid);
|
||
|
||
let iface = uci.sections('network', 'interface').filter(function(s) { return s['.name'] === ifname; })[0];
|
||
let deviceName = iface ? iface.device : null;
|
||
|
||
if (deviceName) {
|
||
let dev = uci.sections('network', 'device').filter(function(d) { return d.name === deviceName; })[0];
|
||
if (dev) uci.set('network', dev['.name'], OWNER_TAG, sid);
|
||
}
|
||
|
||
let dhcp = uci.sections('dhcp', 'dhcp').filter(function(d) { return d.interface === ifname; })[0];
|
||
if (dhcp) uci.set('dhcp', dhcp['.name'], OWNER_TAG, sid);
|
||
|
||
let wifi = uci.sections('wireless', 'wifi-iface').filter(function(w) { return w.network === ifname; })[0];
|
||
if (wifi) uci.set('wireless', wifi['.name'], OWNER_TAG, sid);
|
||
|
||
let zone = uci.sections('firewall', 'zone').filter(function(z) { return zoneMatchesNetwork(z, ifname); })[0];
|
||
if (zone) {
|
||
uci.set('firewall', zone['.name'], OWNER_TAG, sid);
|
||
|
||
let zoneName = zone.name || zone['.name'];
|
||
uci.sections('firewall', 'forwarding').forEach(function(f) {
|
||
if (f.src === zoneName) uci.set('firewall', f['.name'], OWNER_TAG, sid);
|
||
});
|
||
uci.sections('firewall', 'rule').forEach(function(r) {
|
||
if (r.src === zoneName) uci.set('firewall', r['.name'], OWNER_TAG, sid);
|
||
});
|
||
}
|
||
|
||
importGuestNetwork(sid);
|
||
}
|
||
|
||
function importExistingGuestNetworks() {
|
||
collectOwnerSids().forEach(function(sid) {
|
||
if (knownGuestSids().indexOf(sid) === -1)
|
||
importGuestNetwork(sid);
|
||
});
|
||
|
||
guessLegacyInterfaceNames().forEach(function(ifname) {
|
||
if (knownGuestSids().indexOf(ifname) === -1)
|
||
importLegacyGuestNetwork(ifname);
|
||
});
|
||
}
|
||
|
||
return view.extend({
|
||
load: function() {
|
||
return Promise.all([
|
||
uci.load('network'),
|
||
uci.load('wireless'),
|
||
uci.load('dhcp'),
|
||
uci.load('firewall'),
|
||
uci.load('guestwifi')
|
||
]).then(function() {
|
||
importExistingGuestNetworks();
|
||
});
|
||
},
|
||
|
||
buildGuestNetwork: function(sid) {
|
||
let get = function(name, def) { return uci.get('guestwifi', sid, name) || def; };
|
||
|
||
let enabled = get('enable', '1') === '1';
|
||
// Persist enable explicitly — form.Flag.default is not written to
|
||
// UCI on its own, so without this the toggle renders off even when
|
||
// the app treats a missing enable key as enabled.
|
||
uci.set('guestwifi', sid, 'enable', enabled ? '1' : '0');
|
||
let ssid = get('ssid', DEFAULTS.ssid);
|
||
let encryption = get('encryption', DEFAULTS.encryption);
|
||
let password = get('password', DEFAULTS.password);
|
||
let radio = get('radio', DEFAULTS.radio);
|
||
let isolate = get('isolate', DEFAULTS.isolate);
|
||
let macaddr = get('macaddr', DEFAULTS.macaddr);
|
||
let ip = get('interface_ip', DEFAULTS.ip);
|
||
let netmask = get('netmask', DEFAULTS.netmask);
|
||
let dhcpStart = get('dhcp_start', DEFAULTS.dhcpStart);
|
||
let dhcpLimit = get('dhcp_limit', DEFAULTS.dhcpLimit);
|
||
let dhcpLease = get('dhcp_lease', DEFAULTS.dhcpLease);
|
||
let fwDest = get('fw_forward_dest', DEFAULTS.fwForwardDest);
|
||
let fwInput = get('fw_input', DEFAULTS.fwInput);
|
||
let fwOutput = get('fw_output', DEFAULTS.fwOutput);
|
||
let fwForward = get('fw_forward', DEFAULTS.fwForward);
|
||
let fwDhcpPorts= get('fw_dhcp_ports', DEFAULTS.fwDhcpPorts);
|
||
let fwDnsPort = get('fw_dns_port', DEFAULTS.fwDnsPort);
|
||
|
||
let netIdx = parseInt(get('net_idx', ''), 10);
|
||
let names = namesForNetIndex(netIdx > 0 ? netIdx : 1);
|
||
let networkName = names.networkName;
|
||
let deviceName = names.deviceName;
|
||
let wifiName = names.wifiName;
|
||
let dhcpName = names.dhcpName;
|
||
|
||
// Original 4IceG/luci-app-guest-wifi-js form — uci.add() unconditional
|
||
uci.add('network', 'interface', networkName);
|
||
uci.set('network', networkName, OWNER_TAG, sid);
|
||
uci.set('network', networkName, 'device', deviceName);
|
||
uci.set('network', networkName, 'proto', 'static');
|
||
uci.set('network', networkName, 'ipaddr', ip);
|
||
uci.set('network', networkName, 'netmask', netmask);
|
||
|
||
let devSid = uci.add('network', 'device');
|
||
uci.set('network', devSid, OWNER_TAG, sid);
|
||
uci.set('network', devSid, 'name', deviceName);
|
||
uci.set('network', devSid, 'type', 'bridge');
|
||
uci.set('network', devSid, 'bridge_empty', '1');
|
||
|
||
uci.add('dhcp', 'dhcp', dhcpName);
|
||
uci.set('dhcp', dhcpName, OWNER_TAG, sid);
|
||
uci.set('dhcp', dhcpName, 'start', dhcpStart);
|
||
uci.set('dhcp', dhcpName, 'limit', dhcpLimit);
|
||
uci.set('dhcp', dhcpName, 'leasetime', dhcpLease);
|
||
uci.set('dhcp', dhcpName, 'interface', networkName);
|
||
uci.set('dhcp', dhcpName, 'dhcpv4', 'server');
|
||
uci.set('dhcp', dhcpName, 'ignore', enabled ? '0' : '1');
|
||
|
||
uci.add('wireless', 'wifi-iface', wifiName);
|
||
uci.set('wireless', wifiName, OWNER_TAG, sid);
|
||
uci.set('wireless', wifiName, 'device', radio);
|
||
uci.set('wireless', wifiName, 'mode', 'ap');
|
||
uci.set('wireless', wifiName, 'network', networkName);
|
||
uci.set('wireless', wifiName, 'ssid', ssid);
|
||
uci.set('wireless', wifiName, 'isolate', isolate);
|
||
if (macaddr)
|
||
uci.set('wireless', wifiName, 'macaddr', macaddr);
|
||
uci.set('wireless', wifiName, 'disabled', enabled ? '0' : '1');
|
||
|
||
if (encryption === 'none' || encryption === 'owe') {
|
||
uci.set('wireless', wifiName, 'encryption', encryption);
|
||
} else if (encryption === 'wep-open' || encryption === 'wep-shared') {
|
||
uci.set('wireless', wifiName, 'encryption', encryption);
|
||
uci.set('wireless', wifiName, 'key', '1');
|
||
uci.set('wireless', wifiName, 'key1', password);
|
||
} else {
|
||
uci.set('wireless', wifiName, 'encryption', encryption);
|
||
uci.set('wireless', wifiName, 'key', password);
|
||
}
|
||
|
||
let zoneSid = uci.add('firewall', 'zone');
|
||
uci.set('firewall', zoneSid, OWNER_TAG, sid);
|
||
uci.set('firewall', zoneSid, 'name', networkName);
|
||
uci.set('firewall', zoneSid, 'network', [ networkName ]);
|
||
uci.set('firewall', zoneSid, 'input', fwInput);
|
||
uci.set('firewall', zoneSid, 'output', fwOutput);
|
||
uci.set('firewall', zoneSid, 'forward', fwForward);
|
||
|
||
let fwdSid = uci.add('firewall', 'forwarding');
|
||
uci.set('firewall', fwdSid, OWNER_TAG, sid);
|
||
uci.set('firewall', fwdSid, 'src', networkName);
|
||
uci.set('firewall', fwdSid, 'dest', fwDest);
|
||
|
||
let dhcpRuleSid = uci.add('firewall', 'rule');
|
||
uci.set('firewall', dhcpRuleSid, OWNER_TAG, sid);
|
||
uci.set('firewall', dhcpRuleSid, 'src', networkName);
|
||
uci.set('firewall', dhcpRuleSid, 'proto', 'udp');
|
||
uci.set('firewall', dhcpRuleSid, 'src_port', fwDhcpPorts);
|
||
uci.set('firewall', dhcpRuleSid, 'dest_port', fwDhcpPorts);
|
||
uci.set('firewall', dhcpRuleSid, 'target', 'ACCEPT');
|
||
uci.set('firewall', dhcpRuleSid, 'family', 'ipv4');
|
||
|
||
let dnsRuleSid = uci.add('firewall', 'rule');
|
||
uci.set('firewall', dnsRuleSid, OWNER_TAG, sid);
|
||
uci.set('firewall', dnsRuleSid, 'src', networkName);
|
||
uci.set('firewall', dnsRuleSid, 'dest_port', fwDnsPort);
|
||
uci.set('firewall', dnsRuleSid, 'target', 'ACCEPT');
|
||
uci.set('firewall', dnsRuleSid, 'family', 'ipv4');
|
||
uci.set('firewall', dnsRuleSid, 'proto', 'tcpudp');
|
||
},
|
||
|
||
applyAll: function(m) {
|
||
let self = this;
|
||
// form.Map.save() can throw synchronously (from checkDepends ->
|
||
// triggerValidation -> getUIElement) if a dependent option's DOM
|
||
// node lost its data-idref registry slot. Wrap in a resolved
|
||
// promise so the .catch() at the end of this chain actually fires.
|
||
return Promise.resolve().then(function() {
|
||
return m.save();
|
||
}).then(function() {
|
||
resolveDuplicateInterfaceIps();
|
||
assignNetIndexes();
|
||
let defs = uci.sections('guestwifi', 'guest');
|
||
let validSids = defs.map(function(s) { return s['.name']; });
|
||
cleanupGuestConfig(validSids, null);
|
||
defs.forEach(function(def) {
|
||
let sid = def['.name'];
|
||
cleanupGuestConfig(validSids, sid);
|
||
self.buildGuestNetwork(sid);
|
||
});
|
||
return uci.save();
|
||
}).then(function() {
|
||
// Auto-apply removed: only Save & Apply triggers it.
|
||
}).catch(function(err) {
|
||
var msg = (err && err.message) ? err.message : String(err);
|
||
if (/No data|code 5|Permission denied|code 6/i.test(msg)) {
|
||
return;
|
||
}
|
||
ui.addNotification(null, E('p', {}, _('Error saving configuration:') + ' ' + msg), 'error');
|
||
return Promise.reject(err);
|
||
});
|
||
},
|
||
|
||
render: function() {
|
||
let self = this;
|
||
|
||
addGuestWifiStyles();
|
||
|
||
// Modal sweep: LuCI pre-renders the modal once and toggles
|
||
// its display. No DOM insertion event fires on open, so we
|
||
// actively sweep for visible modals and force the width via
|
||
// inline !important. Runs on click, on interval, and once now.
|
||
if (!window._qaWidenModals) {
|
||
window._qaWidenModals = function() {
|
||
try {
|
||
var mods = document.querySelectorAll('.modal, .cbi-modal');
|
||
for (var i = 0; i < mods.length; i++) {
|
||
var m = mods[i];
|
||
var r = m.getBoundingClientRect();
|
||
if (r.width < 100 || r.height < 50) continue;
|
||
m.style.setProperty('width', 'min(95vw, 2200px)', 'important');
|
||
m.style.setProperty('max-width', 'calc(100vw - 32px)', 'important');
|
||
m.style.setProperty('min-width', '0', 'important');
|
||
}
|
||
} catch(e) {}
|
||
};
|
||
if (!window._qaModalSweepTimer) {
|
||
window._qaModalSweepTimer = setInterval(window._qaWidenModals, 400);
|
||
}
|
||
document.addEventListener('click', function() {
|
||
setTimeout(window._qaWidenModals, 30);
|
||
setTimeout(window._qaWidenModals, 150);
|
||
setTimeout(window._qaWidenModals, 400);
|
||
}, true);
|
||
}
|
||
setTimeout(window._qaWidenModals, 100);
|
||
|
||
let wifiDevices = uci.sections('wireless', 'wifi-device');
|
||
let radios = wifiDevices.map(function(d) { return d['.name']; });
|
||
if (!radios.length)
|
||
radios = ['radio0', 'radio1', 'radio2'];
|
||
|
||
let m = new form.Map('guestwifi', _('Guest Wi-Fi'),
|
||
_('A user interface for easily creating and management isolated Wi-Fi networks for guests, each with its own access point, DHCP, and firewall rules.'));
|
||
|
||
self.map = m;
|
||
|
||
// The map's own page-actions (Save / Save & Apply buttons)
|
||
// call map.handleSave / map.handleSaveApply — form.Map's
|
||
// defaults — which only run form.Map.save(). They never reach
|
||
// applyAll(), so the runtime network/wireless/dhcp/firewall
|
||
// sections tagged with guest_owner were never created.
|
||
// Route the map's handlers through applyAll() as well.
|
||
m.handleSave = function(ev) {
|
||
return self.applyAll(m);
|
||
};
|
||
m.handleSaveApply = function(ev, mode) {
|
||
return self.applyAll(m);
|
||
};
|
||
|
||
let s = m.section(form.GridSection, 'guest', _('Guest networks'));
|
||
s.anonymous = true;
|
||
s.addremove = true;
|
||
s.sortable = true;
|
||
s.nodescriptions = true;
|
||
s.addbtntitle = _('Add new guest network...');
|
||
|
||
let o = s.option(form.Flag, 'enable', _('Enabled'),
|
||
_('Enable guest network.'));
|
||
o.rmempty = false;
|
||
o.default = '1';
|
||
o.editable = true;
|
||
// Read side: show ON for a fresh section, else UCI value.
|
||
o.cfgvalue = function(section_id) {
|
||
let v = uci.get('guestwifi', section_id, 'enable');
|
||
return (v == null || v === '') ? '1' : v;
|
||
};
|
||
// Formvalue: read the checkbox from the DOM directly. LuCI's
|
||
// base formvalue() returns null when getUIElement() can't find
|
||
// the widget, and base save() then writes '0' (this.remove()).
|
||
o.formvalue = function(section_id) {
|
||
// The element at this.cbid() is often a wrapper div, not the
|
||
// actual <input type=checkbox>. Dig one level.
|
||
let cbid = this.cbid(section_id);
|
||
let el = document.getElementById(cbid);
|
||
let inp = el;
|
||
if (inp && inp.tagName !== 'INPUT')
|
||
inp = inp.querySelector('input[type="checkbox"]');
|
||
if (inp && typeof inp.checked === 'boolean')
|
||
return inp.checked ? '1' : '0';
|
||
// Fallback: search the whole row for the checkbox
|
||
if (el && el.closest) {
|
||
let rowCb = el.closest('tr, .cbi-rowstyle-1, .cbi-rowstyle-2, .tr');
|
||
if (rowCb) {
|
||
let rInput = rowCb.querySelector('input[type="checkbox"]');
|
||
if (rInput && typeof rInput.checked === 'boolean')
|
||
return rInput.checked ? '1' : '0';
|
||
}
|
||
}
|
||
let v = uci.get('guestwifi', section_id, 'enable');
|
||
return (v == null || v === '') ? '1' : v;
|
||
};
|
||
// Write: bypass Flag.save()'s default-skip and uci.set direct.
|
||
o.write = function(section_id, formvalue) {
|
||
if (formvalue == null || formvalue === '')
|
||
formvalue = '1';
|
||
let on = (formvalue === '1' || formvalue === true ||
|
||
formvalue === 1 || formvalue === 'on');
|
||
uci.set('guestwifi', section_id, 'enable', on ? '1' : '0');
|
||
};
|
||
|
||
o = s.option(form.Value, 'ssid', _('Network name (SSID)'));
|
||
o.rmempty = false;
|
||
o.default = DEFAULTS.ssid;
|
||
o.textvalue = function(section_id) {
|
||
let val = this.cfgvalue(section_id) || this.default;
|
||
return ssidBadge(section_id, val);
|
||
};
|
||
|
||
o = s.option(form.ListValue, 'radio', _('Radio'),
|
||
_('The radio on which the guest network will work.'));
|
||
radios.forEach(function(r) { o.value(r, radioLabel(r, wifiDevices)); });
|
||
o.default = DEFAULTS.radio;
|
||
o.rmempty = false;
|
||
o.textvalue = function(section_id) {
|
||
let val = this.cfgvalue(section_id) || this.default;
|
||
return radioBadge(section_id, val, wifiDevices);
|
||
};
|
||
|
||
o = s.option(form.ListValue, 'encryption', _('Encryption'),
|
||
_('WPA2-PSK is recommended for most devices. WPA3-SAE offers the strongest security but requires WPA3-capable clients.'));
|
||
ENCRYPTION_MODES.forEach(function(m) { o.value(m[0], m[1]); });
|
||
o.default = DEFAULTS.encryption;
|
||
o.rmempty = false;
|
||
o.textvalue = function(section_id) {
|
||
let val = this.cfgvalue(section_id) || this.default;
|
||
return encryptionLabel(val);
|
||
};
|
||
|
||
o = s.option(form.Value, 'password', _('Password'), ' ');
|
||
o.password = true;
|
||
o.depends('encryption', 'psk');
|
||
o.depends('encryption', 'psk2');
|
||
o.depends('encryption', 'psk-mixed');
|
||
o.depends('encryption', 'sae');
|
||
o.depends('encryption', 'sae-mixed');
|
||
o.depends('encryption', 'wep-open');
|
||
o.depends('encryption', 'wep-shared');
|
||
o.default = DEFAULTS.password;
|
||
o.modalonly = true;
|
||
o.validate = function(section_id, value) {
|
||
let enc = this.map.lookupOption('encryption', section_id)[0].formvalue(section_id);
|
||
|
||
let inputEl = document.getElementById(this.cbid(section_id));
|
||
let strength = inputEl ? inputEl.parentNode.querySelector('.cbi-value-description') : null;
|
||
let strongRegex = new RegExp("^(?=.{8,})(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*\\W).*$", "g"),
|
||
mediumRegex = new RegExp("^(?=.{7,})(((?=.*[A-Z])(?=.*[a-z]))|((?=.*[A-Z])(?=.*[0-9]))|((?=.*[a-z])(?=.*[0-9]))).*$", "g"),
|
||
enoughRegex = new RegExp("(?=.{6,}).*", "g");
|
||
|
||
if (strength) {
|
||
if (!value || !value.length)
|
||
strength.innerHTML = '';
|
||
else if (false == enoughRegex.test(value))
|
||
strength.innerHTML = '%s: <span style="color:red">%s</span>'.format(_('Password strength'), _('More Characters'));
|
||
else if (strongRegex.test(value))
|
||
strength.innerHTML = '%s: <span style="color:green">%s</span>'.format(_('Password strength'), _('Strong'));
|
||
else if (mediumRegex.test(value))
|
||
strength.innerHTML = '%s: <span style="color:orange">%s</span>'.format(_('Password strength'), _('Medium'));
|
||
else
|
||
strength.innerHTML = '%s: <span style="color:red">%s</span>'.format(_('Password strength'), _('Weak'));
|
||
}
|
||
|
||
if (!value)
|
||
return true;
|
||
|
||
if (enc === 'wep-open' || enc === 'wep-shared') {
|
||
let isHex = /^[0-9a-fA-F]+$/.test(value);
|
||
let validLength = isHex ? (value.length === 10 || value.length === 26)
|
||
: (value.length === 5 || value.length === 13);
|
||
if (!validLength)
|
||
return _('WEP key must be 5 or 13 ASCII characters, or 10 or 26 hexadecimal digits.');
|
||
return true;
|
||
}
|
||
|
||
if (enc !== 'none' && enc !== 'owe' && value.length < 8)
|
||
return _('Password must be at least 8 characters long (WPA-PSK/WPA2-PSK/WPA3-SAE).');
|
||
|
||
return true;
|
||
};
|
||
|
||
let passwordDependsOn = function(opt) {
|
||
opt.depends('encryption', 'psk');
|
||
opt.depends('encryption', 'psk2');
|
||
opt.depends('encryption', 'psk-mixed');
|
||
opt.depends('encryption', 'sae');
|
||
opt.depends('encryption', 'sae-mixed');
|
||
opt.depends('encryption', 'wep-open');
|
||
opt.depends('encryption', 'wep-shared');
|
||
};
|
||
|
||
o = s.option(form.Value, 'password_length', _('Password length'),
|
||
_('Minimum length is 8 characters, 13 characters recommended.'));
|
||
o.datatype = 'range(8,63)';
|
||
o.default = '13';
|
||
o.rmempty = true;
|
||
o.modalonly = true;
|
||
passwordDependsOn(o);
|
||
|
||
o = s.option(cbiPasswordComponentsValue, 'password_components', _('Password composition'),
|
||
_('Character types to use when generating a password.'));
|
||
o.value('upper', _('Uppercase letters'));
|
||
o.value('lower', _('Lowercase letters'));
|
||
o.value('digits', _('Digits'));
|
||
o.value('special', _('Special characters'));
|
||
o.default = ['upper', 'lower', 'digits', 'special'];
|
||
o.placeholder = _('Select password components...');
|
||
o.rmempty = true;
|
||
o.modalonly = true;
|
||
passwordDependsOn(o);
|
||
|
||
o = s.option(form.Button, '_password_generate', _('Generate password'));
|
||
o.inputtitle = _('Generate');
|
||
o.inputstyle = 'action';
|
||
o.modalonly = true;
|
||
passwordDependsOn(o);
|
||
o.onclick = function(ev, section_id) {
|
||
let section = this.section;
|
||
let lengthEl = section.getUIElement(section_id, 'password_length');
|
||
let componentsEl = section.getUIElement(section_id, 'password_components');
|
||
let passwordEl = section.getUIElement(section_id, 'password');
|
||
|
||
let length = lengthEl ? parseInt(lengthEl.getValue(), 10) : 16;
|
||
let components = componentsEl ? componentsEl.getValue() : [];
|
||
|
||
if (!Array.isArray(components))
|
||
components = components ? [components] : [];
|
||
|
||
let newPassword = generateStrongPassword(length, components);
|
||
|
||
if (passwordEl) {
|
||
passwordEl.setValue(newPassword);
|
||
if (typeof passwordEl.triggerValidation === 'function')
|
||
passwordEl.triggerValidation();
|
||
}
|
||
};
|
||
|
||
o = s.option(form.DummyValue, '_qrops', _('QR Code'),
|
||
_('Generates a QR code with the guest network data (SSID, encryption, password) so a client device can scan and connect.'));
|
||
o.modalonly = true;
|
||
|
||
o.createWiFiPassword = function(section_id) {
|
||
function pctEncode(str) {
|
||
let bytes = new TextEncoder().encode(str);
|
||
let out = '';
|
||
|
||
for (let i = 0; i < bytes.length; i++) {
|
||
let b = bytes[i];
|
||
let printable = (b >= 0x20 && b <= 0x3A && b !== 0x3B) || (b >= 0x3C && b <= 0x7E);
|
||
|
||
if (printable)
|
||
out += String.fromCharCode(b);
|
||
else
|
||
out += '%' + b.toString(16).toUpperCase().padStart(2, '0');
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
let wifiSSID = this.section.formvalue(section_id, 'ssid');
|
||
let wifiEncr = this.section.formvalue(section_id, 'encryption') || '';
|
||
let wifiKey = this.section.formvalue(section_id, 'password');
|
||
|
||
let trdisable = '';
|
||
if (wifiEncr === 'sae') trdisable = 0;
|
||
else if (wifiEncr === 'owe') trdisable = 3;
|
||
|
||
return [
|
||
'WIFI:',
|
||
(wifiKey) ? 'T:WPA;' : null,
|
||
(trdisable !== '') ? 'R:' + trdisable + ';' : null,
|
||
'S:' + wifiSSID + ';',
|
||
(wifiKey) ? 'P:' + pctEncode(wifiKey) + ';' : null
|
||
].filter(Boolean).join('') + ';';
|
||
};
|
||
|
||
o.handleGenerateQR = function(section_id, ev) {
|
||
let parent = s.map;
|
||
let mapNode = document.querySelector('body.modal-overlay-active > #modal_overlay > .modal.cbi-modal > .cbi-map:not(.hidden)');
|
||
let headNode = mapNode.parentNode.querySelector('h4');
|
||
let wifiQRGenerator = this.createWiFiPassword.bind(this, section_id);
|
||
|
||
return Promise.all([
|
||
parent.save(null, true)
|
||
]).then(function() {
|
||
let qrm, qrs, qro;
|
||
|
||
qrm = new form.JSONMap({ qrcode: {} }, null, _('Scan this QR code with the client device.'));
|
||
qrm.parent = parent;
|
||
|
||
qrs = qrm.section(form.NamedSection, 'qrcode');
|
||
|
||
function handleQRParamChange(ev, section_id, value) {
|
||
let code = this.map.findElement('.qr-code');
|
||
let conf = this.map.findElement('.wifi-qr-code-content');
|
||
let ecc = this.section.getUIElement(section_id, 'ecc');
|
||
|
||
if (this.isValid(section_id)) {
|
||
conf.firstChild.data = wifiQRGenerator(section_id);
|
||
code.style.opacity = '.5';
|
||
buildSVGQRCode(conf.firstChild.data, code, { ecc: ecc.getValue() });
|
||
}
|
||
}
|
||
|
||
qro = qrs.option(form.ListValue, 'ecc', _('QR Error Correction Code Level'));
|
||
qro.value('L', _('Low'));
|
||
qro.value('M', _('Medium'));
|
||
qro.value('Q', _('Quartile'));
|
||
qro.value('H', _('High'));
|
||
qro.onchange = handleQRParamChange;
|
||
|
||
qro = qrs.option(form.DummyValue, 'output');
|
||
qro.renderWidget = function() {
|
||
let wifi_qr = wifiQRGenerator(section_id);
|
||
let ecc = this.section.formvalue(section_id, 'ecc');
|
||
|
||
return E('div', {
|
||
'class': 'qr-code-display',
|
||
'style': 'display:flex; flex-wrap:wrap; align-items:center; gap:.5em'
|
||
}, [
|
||
E('div', { 'class': 'qr-code' }, [
|
||
E(buildSVGQRCode(wifi_qr, null, { ecc: ecc || undefined }, true))
|
||
]),
|
||
E('pre', {
|
||
'class': 'wifi-qr-code-content',
|
||
'style': 'flex:1; overflow:auto; word-break:break-all;',
|
||
'click': function(ev) {
|
||
let sel = window.getSelection();
|
||
let range = document.createRange();
|
||
|
||
range.selectNodeContents(ev.currentTarget);
|
||
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
}
|
||
}, [wifi_qr])
|
||
]);
|
||
};
|
||
|
||
return qrm.render().then(function(nodes) {
|
||
let dStyle = mapNode.style;
|
||
mapNode.style.display = 'none';
|
||
|
||
let bRowStyle = mapNode.nextElementSibling.style;
|
||
mapNode.nextElementSibling.style.display = 'none';
|
||
|
||
headNode.appendChild(E('span', [' » ', _('Generate guest WiFi QR…')]));
|
||
mapNode.parentNode.appendChild(E([], [
|
||
nodes,
|
||
E('div', { 'class': 'right' }, [
|
||
E('button', {
|
||
'class': 'btn',
|
||
'click': function() {
|
||
nodes.parentNode.removeChild(nodes.nextSibling);
|
||
nodes.parentNode.removeChild(nodes);
|
||
mapNode.style = dStyle;
|
||
mapNode.nextSibling.style = bRowStyle;
|
||
headNode.removeChild(headNode.lastChild);
|
||
}
|
||
}, [_('Back to settings')])
|
||
])
|
||
]));
|
||
});
|
||
});
|
||
};
|
||
|
||
o.cfgvalue = function(section_id) {
|
||
return E('button', {
|
||
'class': 'btn qr-code',
|
||
'style': 'display:inline-flex;align-items:center;gap:.5em',
|
||
'click': ui.createHandlerFn(this, 'handleGenerateQR', section_id)
|
||
}, [
|
||
E(buildSVGQRCode('openwrt.org', null, { pixelSize: 1, ecc: 'L' }, true)),
|
||
_('Generate QR…')
|
||
]);
|
||
};
|
||
|
||
o = s.option(form.Value, 'interface_ip', _('Interface IP address'));
|
||
o.datatype = 'ip4addr';
|
||
o.rmempty = false;
|
||
o.default = DEFAULTS.ip;
|
||
o.cfgvalue = function(section_id) {
|
||
let val = uci.get('guestwifi', section_id, 'interface_ip');
|
||
if (val) return val;
|
||
return randomFreeGuestIp(section_id) || DEFAULTS.ip;
|
||
};
|
||
|
||
o.textvalue = function(section_id) {
|
||
let val = this.cfgvalue(section_id) || this.default;
|
||
return E('code', {}, val);
|
||
};
|
||
o.validate = function(section_id, value) {
|
||
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(value))
|
||
return true;
|
||
|
||
let used = usedInterfaceIps(section_id);
|
||
if (!used[value])
|
||
return true;
|
||
|
||
let newIp = randomFreeGuestIp(section_id);
|
||
if (!newIp) {
|
||
return _('The address %s is already in use and no free address is available in the 172.16.0.1 - 172.31.0.1 range.').format(value);
|
||
}
|
||
|
||
let el = this.getUIElement(section_id);
|
||
if (el) el.setValue(newIp);
|
||
return true;
|
||
};
|
||
|
||
o = s.option(form.Value, 'netmask', _('Netmask'));
|
||
o.datatype = 'ip4addr';
|
||
o.rmempty = false;
|
||
o.default = DEFAULTS.netmask;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'dhcp_start', _('DHCP range start'),
|
||
_('Lowest leased address, as offset from the network address.'));
|
||
o.datatype = 'uinteger';
|
||
o.rmempty = false;
|
||
o.default = DEFAULTS.dhcpStart;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'dhcp_limit', _('DHCP client limit'),
|
||
_('Maximum number of leased addresses.'));
|
||
o.datatype = 'uinteger';
|
||
o.rmempty = false;
|
||
o.default = DEFAULTS.dhcpLimit;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'dhcp_lease', _('DHCP lease time'),
|
||
_('E.g. "1h", "30m", "12h". Minimum is "2m".'));
|
||
o.rmempty = false;
|
||
o.default = DEFAULTS.dhcpLease;
|
||
|
||
o = s.option(form.ListValue, 'isolate', _('Client isolation'),
|
||
_('Blocks communication between clients on this guest network.'));
|
||
o.value('1', _('Yes'));
|
||
o.value('0', _('No'));
|
||
o.default = DEFAULTS.isolate;
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'macaddr', _('MAC address'),
|
||
_('Override default MAC address - the range of usable addresses might be limited by the driver'));
|
||
o.value('', _('driver default'));
|
||
o.value('random', _('randomly generated'));
|
||
o.datatype = "or('random',macaddr)";
|
||
o.default = DEFAULTS.macaddr;
|
||
o.rmempty = true;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(widgets.ZoneSelect, 'fw_forward_dest', _('Forwarding destination zone'),
|
||
_('Firewall zone guest traffic is forwarded to (usually "wan"). Pick an existing zone from the list or fill out the <em>-- custom --</em> field to enter one manually.'));
|
||
o.rmempty = false;
|
||
o.default = DEFAULTS.fwForwardDest;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'fw_input', _('Input'),
|
||
_('Traffic from guests to the router itself (e.g. LuCI, SSH).'));
|
||
o.value('REJECT', _('reject')); o.value('ACCEPT', _('accept')); o.value('DROP', _('drop'));
|
||
o.default = DEFAULTS.fwInput;
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'fw_output', _('Output'),
|
||
_('Traffic from the router itself to guests. ACCEPT is recommended so router services (DHCP, DNS) keep working.'));
|
||
o.value('REJECT', _('reject')); o.value('ACCEPT', _('accept')); o.value('DROP', _('drop'));
|
||
o.default = DEFAULTS.fwOutput;
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.ListValue, 'fw_forward', _('Forward'),
|
||
_('Traffic passing between guests and other networks/zones (e.g. LAN). Keep this REJECT or DROP to isolate guests from your LAN. ACCEPT would allow guests to reach other networks.'));
|
||
o.value('REJECT', _('reject')); o.value('ACCEPT', _('accept')); o.value('DROP', _('drop'));
|
||
o.default = DEFAULTS.fwForward;
|
||
o.rmempty = false;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'fw_dhcp_ports', _('DHCP rule ports'),
|
||
_('UDP port range for DHCP traffic, e.g. "67-68".'));
|
||
o.rmempty = false;
|
||
o.default = DEFAULTS.fwDhcpPorts;
|
||
o.modalonly = true;
|
||
|
||
o = s.option(form.Value, 'fw_dns_port', _('DNS rule port'));
|
||
o.datatype = 'port';
|
||
o.rmempty = false;
|
||
o.default = DEFAULTS.fwDnsPort;
|
||
o.modalonly = true;
|
||
|
||
return m.render().then(function(node) {
|
||
// LuCI theme often wins specificity over our injected <style>.
|
||
// Set inline style with !important on every modal that opens.
|
||
if (!window._qaModalObserver) {
|
||
try {
|
||
window._qaModalObserver = new MutationObserver(function(muts) {
|
||
for (let i = 0; i < muts.length; i++) {
|
||
let added = muts[i].addedNodes;
|
||
for (let j = 0; j < added.length; j++) {
|
||
let n = added[j];
|
||
if (!n || n.nodeType !== 1) continue;
|
||
let targets = [];
|
||
if (n.classList && n.classList.contains('modal')) targets.push(n);
|
||
if (n.querySelectorAll) {
|
||
let inner = n.querySelectorAll('.modal, .cbi-modal');
|
||
for (let k = 0; k < inner.length; k++) targets.push(inner[k]);
|
||
}
|
||
for (let t = 0; t < targets.length; t++) {
|
||
targets[t].style.setProperty('width', 'min(95vw, 1200px)', 'important');
|
||
targets[t].style.setProperty('max-width', 'calc(100vw - 32px)', 'important');
|
||
}
|
||
}
|
||
}
|
||
});
|
||
window._qaModalObserver.observe(document.body, { childList: true, subtree: true });
|
||
} catch(e) {}
|
||
}
|
||
return node;
|
||
});
|
||
},
|
||
|
||
handleSave: function(ev) {
|
||
return this.applyAll(this.map);
|
||
},
|
||
handleSaveApply: null,
|
||
handleReset: null
|
||
});
|
||
|
||
})();
|
||
|
||
return view.extend({
|
||
pollInterval: 5,
|
||
designStyle: '3d',
|
||
timerId: null,
|
||
customMappings: {},
|
||
installedThemes: [],
|
||
callInitStatus: null,
|
||
callSystemCommand: null,
|
||
activeTab: 'dashboard',
|
||
|
||
load: function() {
|
||
var self = this;
|
||
this.callInitStatus = rpc.declare({ object: 'luci', method: 'getInitList', expect: { '': {} } });
|
||
this.callSystemCommand = rpc.declare({ object: 'file', method: 'exec', params: [ 'command', 'params' ], expect: { '': {} } });
|
||
return Promise.all([
|
||
uci.load('quickactions').catch(function(){}),
|
||
uci.load('system').catch(function(){}),
|
||
uci.load('wireless').catch(function(){}),
|
||
uci.load('network').catch(function(){}),
|
||
uci.load('guestwifi').catch(function(){})
|
||
]).then(function() {
|
||
var interval = uci.get('quickactions', 'global', 'poll_interval');
|
||
self.pollInterval = parseInt(interval, 10) || 5;
|
||
self.designStyle = uci.get('quickactions', 'global', 'design_style') || '3d';
|
||
var maps = uci.sections('quickactions', 'mapping') || [];
|
||
self.customMappings = {};
|
||
maps.forEach(function(sec) {
|
||
if (sec.keyword) {
|
||
self.customMappings[sec.keyword.toLowerCase().trim()] = {
|
||
service: sec.service ? sec.service.trim() : null,
|
||
dangerous: sec.dangerous === '1',
|
||
icon: sec.icon ? sec.icon.trim() : ''
|
||
};
|
||
}
|
||
});
|
||
return self.fetchThemeList().then(function(themes) {
|
||
self.installedThemes = themes || [];
|
||
return self.fetchLanguageList().then(function(langs) {
|
||
self.installedLanguages = langs || [];
|
||
return Promise.all([
|
||
uci.load('luci').then(function() { return uci.sections('luci', 'command'); }).catch(function(){ return []; }),
|
||
self.callInitStatus().catch(function(){ return {}; })
|
||
]);
|
||
});
|
||
});
|
||
});
|
||
},
|
||
|
||
refreshPendingCount: function() {
|
||
var self = this;
|
||
return Promise.resolve(uci.changes()).then(function(changes) {
|
||
var n = 0;
|
||
for (var k in changes) { n += Object.keys(changes[k]).length; }
|
||
var bar = document.getElementById('qa-pending-bar');
|
||
var cnt = document.getElementById('qa-pending-count');
|
||
if (!bar) return;
|
||
if (n === 0) {
|
||
bar.style.display = 'none';
|
||
} else {
|
||
bar.style.display = 'flex';
|
||
if (cnt) cnt.textContent = n + ' pending change' + (n === 1 ? '' : 's') + ' in memory (not committed)';
|
||
}
|
||
}).catch(function() {});
|
||
},
|
||
|
||
pendingApply: function() {
|
||
var self = this;
|
||
return Promise.resolve(uci.changes()).then(function(changes) {
|
||
var lines = [];
|
||
for (var conf in changes) {
|
||
for (var sid in changes[conf]) {
|
||
for (var opt in changes[conf][sid]) {
|
||
var v = changes[conf][sid][opt];
|
||
if (Array.isArray(v)) v = v.join(', ');
|
||
lines.push(conf + '.' + sid + '.' + opt + ' = ' + v);
|
||
}
|
||
}
|
||
}
|
||
if (lines.length === 0) {
|
||
ui.addNotification('Nothing to apply', 'No pending changes.', 'info');
|
||
return;
|
||
}
|
||
var preview = lines.slice(0, 20).join('\n') + (lines.length > 20 ? '\n... and ' + (lines.length - 20) + ' more' : '');
|
||
if (!confirm('Apply ALL pending changes system-wide?\n\n' + preview + '\n\nThis commits and applies everything, not just this page.')) return;
|
||
|
||
ui.addNotification('Applying', 'Committing ' + lines.length + ' change(s)...', 'info');
|
||
return uci.apply(10).then(function() {
|
||
ui.addNotification('Applied', 'All changes committed and applied.', 'info');
|
||
self.refreshPendingCount();
|
||
}).catch(function(err) {
|
||
ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger');
|
||
});
|
||
});
|
||
},
|
||
|
||
fetchThemeList: function() {
|
||
var self = this;
|
||
// Multi-source theme detection (four independent methods)
|
||
var sh = 'ls -1 /www/luci-static/ 2>/dev/null; ' +
|
||
'echo "---"; ' +
|
||
'ls -1 /usr/lib/opkg/info/ 2>/dev/null | grep "^luci-theme-"; ' +
|
||
'echo "---"; ' +
|
||
'opkg list-installed 2>/dev/null | awk "/^luci-theme-/ {print \$1}"; ' +
|
||
'apk list -I 2>/dev/null | grep -o "^luci-theme-[^ ]*"';
|
||
|
||
return this.callSystemCommand('/bin/sh', ['-c', sh]).then(function(r) {
|
||
var seen = {}, themes = [];
|
||
(r.stdout || '').split('\n').forEach(function(line) {
|
||
line = line.trim();
|
||
if (!line || line === '---') return;
|
||
var name = line
|
||
.replace(/^luci-theme-/, '')
|
||
.replace(/\.(list|control)$/, '')
|
||
.trim();
|
||
if (['resources', 'fonts', 'index.css', 'index.css.gz', 'menu', 'loading.svg'].indexOf(name) !== -1) return;
|
||
if (/^[a-zA-Z0-9_\-]+$/.test(name) === false) return;
|
||
if (name.length < 2 || name.length > 32) return;
|
||
if (seen[name]) return;
|
||
seen[name] = 1;
|
||
themes.push(name);
|
||
});
|
||
return themes;
|
||
}).catch(function() { return []; });
|
||
},
|
||
|
||
fetchLanguageList: function() {
|
||
var sh = "ls -1 /usr/lib/lua/luci/i18n/ 2>/dev/null | grep -E '^base\..*\.lmo$' | sed 's/^base\.//; s/\.lmo$//' | sort -u";
|
||
return this.callSystemCommand('/bin/sh', ['-c', sh]).then(function(r) {
|
||
var list = [];
|
||
(r.stdout || '').split('\n').forEach(function(line) {
|
||
line = line.trim();
|
||
if (line && /^[a-zA-Z_]+$/.test(line)) list.push(line);
|
||
});
|
||
return list;
|
||
}).catch(function() { return []; });
|
||
},
|
||
|
||
handleThemeSwitch: function(themeKey) {
|
||
uci.load('luci').then(function() {
|
||
var cur = uci.get('luci', 'main', 'mediaurlbase');
|
||
var nb = '/luci-static/' + themeKey;
|
||
if (cur === nb) { ui.addNotification('Theme', 'Already using "' + themeKey + '".', 'info'); return; }
|
||
ui.addNotification('Theme', 'Applying "' + themeKey + '"...', 'info');
|
||
uci.set('luci', 'main', 'mediaurlbase', nb);
|
||
uci.save().then(function() { return ui.changes.apply(false); }).then(function() { setTimeout(function() { window.location.reload(); }, 500); })
|
||
.catch(function(err) { ui.addNotification('Theme Error', (err && err.message) ? err.message : String(err), 'danger'); });
|
||
}).catch(function(err) { ui.addNotification('Theme Error', (err && err.message) ? err.message : String(err), 'danger'); });
|
||
},
|
||
|
||
handleLanguageSwitch: function(langCode) {
|
||
uci.load('luci').then(function() {
|
||
var cur = uci.get('luci', 'main', 'lang') || 'en';
|
||
if (cur === langCode) { ui.addNotification('Language', 'Already set.', 'info'); return; }
|
||
ui.addNotification('Language', 'Switching to ' + langCode + '...', 'info');
|
||
uci.set('luci', 'main', 'lang', langCode);
|
||
uci.save().then(function() { return ui.changes.apply(false); }).then(function() { setTimeout(function() { window.location.reload(); }, 500); })
|
||
.catch(function(err) { ui.addNotification('Language Error', (err && err.message) ? err.message : String(err), 'danger'); });
|
||
}).catch(function(err) { ui.addNotification('Language Error', (err && err.message) ? err.message : String(err), 'danger'); });
|
||
},
|
||
|
||
moveItem: function(section, name, direction) {
|
||
var self = this;
|
||
var secs = (uci.sections('quickactions', section) || []).slice();
|
||
if (secs.length < 2) return;
|
||
secs.sort(function(a, b) { return (parseInt(a.order, 10) || 999) - (parseInt(b.order, 10) || 999); });
|
||
var idx = -1;
|
||
for (var i = 0; i < secs.length; i++) { if (secs[i]['.name'] === name) { idx = i; break; } }
|
||
if (idx < 0) return;
|
||
var target = direction === 'up' ? idx - 1 : idx + 1;
|
||
if (target < 0 || target >= secs.length) return;
|
||
secs.forEach(function(s, i) { uci.set('quickactions', s['.name'], 'order', String((i + 1) * 10)); });
|
||
var aOrder = (idx + 1) * 10;
|
||
var bOrder = (target + 1) * 10;
|
||
uci.set('quickactions', secs[idx]['.name'], 'order', String(bOrder));
|
||
uci.set('quickactions', secs[target]['.name'], 'order', String(aOrder));
|
||
return uci.save().then(function() { ui.addNotification('Reordered', name + ' moved ' + direction, 'info'); });
|
||
},
|
||
|
||
getServiceInfo: function(buttonName) {
|
||
var name = buttonName.toLowerCase();
|
||
for (var kw in this.customMappings) { if (name.includes(kw)) return this.customMappings[kw]; }
|
||
return null;
|
||
},
|
||
|
||
getServiceStatus: function(buttonName, initList) {
|
||
if (!buttonName || !initList) return null;
|
||
var name = buttonName.toLowerCase();
|
||
var info = this.getServiceInfo(buttonName);
|
||
if (info && info.service && initList[info.service]) return initList[info.service].enabled && initList[info.service].running;
|
||
var fb = { 'dns':'dnsmasq','dhcp':'dnsmasq','firewall':'firewall','vpn':'openvpn','wireguard':'network' };
|
||
for (var k in fb) { if (name.includes(k) && initList[fb[k]]) return initList[fb[k]].enabled && initList[fb[k]].running; }
|
||
return null;
|
||
},
|
||
|
||
executeValidatedCommand: function(commandStr, buttonName, btnElement) {
|
||
var self = this;
|
||
|
||
// LuCI pseudo-command: @download:<url> means "open this URL in a new
|
||
// browser tab" (typically the cgi-backup endpoint). It cannot be run
|
||
// through /bin/sh; handle it here.
|
||
if (/^@download:/.test(commandStr)) {
|
||
var url = commandStr.substring(10);
|
||
btnElement.setAttribute('data-executing', 'true');
|
||
btnElement.style.opacity = '0.5';
|
||
try {
|
||
var link = document.createElement('a');
|
||
link.href = url;
|
||
link.target = '_blank';
|
||
link.rel = 'noopener';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
ui.addNotification(null,
|
||
E('p', {}, _('Downloading: ') + url), 'info');
|
||
} catch (e) {
|
||
ui.addNotification(null,
|
||
E('p', {}, _('Download failed: ') + (e.message || e)), 'danger');
|
||
}
|
||
setTimeout(function() {
|
||
btnElement.setAttribute('data-executing', 'false');
|
||
btnElement.style.opacity = '1';
|
||
}, 800);
|
||
return;
|
||
}
|
||
|
||
btnElement.setAttribute('data-executing', 'true');
|
||
btnElement.style.opacity = '0.5';
|
||
if (typeof ui.addTimeLimitedNotification === 'function') {
|
||
ui.addTimeLimitedNotification('Executing', buttonName + ' is running...', 8000, 'info');
|
||
} else {
|
||
ui.addNotification('Executing', buttonName + ' is running...', 'info');
|
||
}
|
||
this.callSystemCommand('/bin/sh', [ '-c', commandStr ]).then(function(result) {
|
||
btnElement.setAttribute('data-executing', 'false');
|
||
btnElement.style.opacity = '1';
|
||
var output = result.stdout || '';
|
||
if (result.stderr) output += '\n[stderr]\n' + result.stderr;
|
||
var __failed = (result.code != null && result.code !== 0);
|
||
if (!output.trim()) output = __failed ? '(no output)' : 'Done.';
|
||
if (__failed) output = '[exit code ' + result.code + ']\n' + output;
|
||
var uid = 'qa-out-' + Date.now();
|
||
var pre = document.createElement('pre');
|
||
pre.id = uid;
|
||
pre.style.cssText = 'text-align:left;white-space:pre-wrap;max-height:70vh;overflow-y:auto;background:#1e1e1e;color:#00ff00;padding:15px;border-radius:8px;font-family:monospace;font-size:0.9em;width:100%;box-sizing:border-box;margin:0;';
|
||
pre.textContent = output;
|
||
ui.addNotification(
|
||
__failed ? (buttonName + ' \u2014 exit ' + result.code) : (buttonName + ' Done'),
|
||
pre,
|
||
__failed ? 'warning' : 'info');
|
||
var el = document.getElementById(uid);
|
||
if (el && el.closest) { var c = el.closest('.alert'); if (c) { c.style.maxWidth = '95%'; c.style.width = 'auto'; } }
|
||
self.updateAllButtonsRealtime();
|
||
}).catch(function(err) {
|
||
btnElement.setAttribute('data-executing', 'false');
|
||
btnElement.style.opacity = '1';
|
||
ui.addNotification('Failed', (err && err.message) ? err.message : String(err), 'danger');
|
||
});
|
||
},
|
||
|
||
handleButtonClick: function(commandStr, buttonName, btnElement) {
|
||
if (btnElement.getAttribute('data-executing') === 'true') return;
|
||
var info = this.getServiceInfo(buttonName);
|
||
if (info && info.dangerous) {
|
||
if (confirm('Warning: high-risk operation (' + buttonName + ').\nProceed?')) this.executeValidatedCommand(commandStr, buttonName, btnElement);
|
||
} else this.executeValidatedCommand(commandStr, buttonName, btnElement);
|
||
},
|
||
|
||
applyLiveColor: function(element, isRunning) {
|
||
if (isRunning) { element.style.backgroundColor = '#2ed573'; if (this.designStyle === '3d') element.style.borderBottom = '5px solid #26b360'; }
|
||
else { element.style.backgroundColor = '#ff4757'; if (this.designStyle === '3d') element.style.borderBottom = '5px solid #d93d4b'; }
|
||
},
|
||
|
||
updateAllButtonsRealtime: function() {
|
||
var self = this;
|
||
var gridNode = document.getElementById('quickactions-grid-container');
|
||
if (!gridNode) return; // tab is not Dashboard - skip, keep interval alive
|
||
this.callInitStatus().then(function(initList) {
|
||
var buttons = gridNode.getElementsByClassName('custom-action-btn');
|
||
for (var i = 0; i < buttons.length; i++) {
|
||
var btn = buttons[i];
|
||
if (btn.getAttribute('data-executing') === 'true') continue;
|
||
var bName = btn.getAttribute('data-name');
|
||
var isRunning = self.getServiceStatus(bName, initList);
|
||
if (isRunning !== null) self.applyLiveColor(btn, isRunning);
|
||
}
|
||
}).catch(function(){});
|
||
},
|
||
|
||
runCommand: function(cmd, label) {
|
||
// Legacy LuCI pseudo-command. The old /cgi-bin/cgi-backup endpoint
|
||
// requires a POST with a CSRF token; opening it as a GET returns
|
||
// "Invalid form data". Guide the user to LuCI's working backup page.
|
||
if (/^@download:/.test(cmd)) {
|
||
var url = cmd.substring(10);
|
||
ui.addNotification(null, E('div', {},
|
||
E('p', {}, _('The legacy download endpoint no longer works directly.')),
|
||
E('p', {}, _('Please use System → Backup / Flash Firmware to download a config backup.')),
|
||
E('p', { 'style': 'margin-top:6px;' },
|
||
E('a', { 'href': '/cgi-bin/luci/admin/system/flash', 'target': '_blank' },
|
||
_('Open backup page →')))
|
||
), 'info');
|
||
return Promise.resolve({ code: 0, stdout: '', stderr: '' });
|
||
}
|
||
return this.callSystemCommand('/bin/sh', [ '-c', cmd ]).then(function(r) {
|
||
var out = r.stdout || '';
|
||
if (r.stderr) out += '\n[stderr]\n' + r.stderr;
|
||
var __failed = (r.code != null && r.code !== 0);
|
||
if (!out.trim()) out = __failed ? '(no output)' : 'Done.';
|
||
if (__failed) out = '[exit code ' + r.code + ']\n' + out;
|
||
var uid = 'qa-tool-out-' + Date.now();
|
||
var pre = document.createElement('pre');
|
||
pre.id = uid;
|
||
pre.style.cssText = 'text-align:left;white-space:pre-wrap;max-height:70vh;overflow-y:auto;background:#1e1e1e;color:#00ff00;padding:12px;border-radius:6px;font-family:monospace;font-size:0.9em;width:100%;box-sizing:border-box;margin:0;';
|
||
pre.textContent = out;
|
||
ui.addNotification(
|
||
__failed ? (label + ' \u2014 exit ' + r.code) : (label + ' Done'),
|
||
pre,
|
||
__failed ? 'warning' : 'info');
|
||
var el = document.getElementById(uid);
|
||
if (el && el.closest) { var c = el.closest('.alert'); if (c) { c.style.maxWidth = '95%'; c.style.width = 'auto'; } }
|
||
return r;
|
||
});
|
||
},
|
||
|
||
renderDashboard: function(cmds, initList) {
|
||
var self = this;
|
||
var currentLang = (uci.get('luci', 'main', 'lang') || 'en');
|
||
var themeBar = E('div', { 'style': 'background:rgba(0,0,0,0.04);padding:12px 14px;border-radius:8px;margin-bottom:12px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;' }, [E('strong', { 'style': 'min-width:130px;' }, 'Theme Switcher: ')]);
|
||
if (this.installedThemes.length === 0) {
|
||
themeBar.appendChild(E('span', { 'style': 'color:#777;font-style:italic;' }, 'No themes detected'));
|
||
themeBar.appendChild(E('button', { 'class': 'btn cbi-button', 'style': 'padding:2px 10px;font-size:0.8em;margin-left:6px;', 'click': function() {
|
||
var cmd = [
|
||
'echo "=== /www/luci-static/ ==="',
|
||
'ls -1 /www/luci-static/ 2>/dev/null',
|
||
'echo ""',
|
||
'echo "=== /usr/lib/opkg/info (luci-theme) ==="',
|
||
'ls -1 /usr/lib/opkg/info/ 2>/dev/null | grep "^luci-theme-"',
|
||
'echo ""',
|
||
'echo "=== opkg list-installed (luci-theme) ==="',
|
||
'opkg list-installed 2>/dev/null | grep "^luci-theme-"',
|
||
'echo ""',
|
||
'echo "=== apk list -I (luci-theme) ==="',
|
||
'apk list -I 2>/dev/null | grep -o "^luci-theme-[^ ]*"'
|
||
].join('\n');
|
||
self.callSystemCommand('/bin/sh', ['-c', cmd]).then(function(r) {
|
||
var out = (r.stdout || '') + (r.stderr ? '\n[stderr]\n' + r.stderr : '');
|
||
ui.addNotification('Theme Diagnostic',
|
||
E('pre', { 'style': 'text-align:left;white-space:pre-wrap;background:#1e1e1e;color:#0f0;padding:12px;border-radius:4px;font-family:monospace;font-size:0.85em;max-height:400px;overflow:auto;' }, out),
|
||
'info');
|
||
});
|
||
} }, 'Diagnose'));
|
||
}
|
||
else this.installedThemes.forEach(function(t) {
|
||
themeBar.appendChild(E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'style': 'padding:4px 12px;text-transform:capitalize;font-weight:bold;', 'click': function() { self.handleThemeSwitch(t); } }, t));
|
||
});
|
||
var langBar = E('div', { 'style': 'background:rgba(0,0,0,0.04);padding:12px 14px;border-radius:8px;margin-bottom:20px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;' }, [E('strong', { 'style': 'min-width:130px;' }, 'Language Switcher: ')]);
|
||
var LANG_NAMES = {
|
||
'en': 'English', 'bn': '\u09ac\u09be\u0982\u09b2\u09be', 'bn_BD': '\u09ac\u09be\u0982\u09b2\u09be',
|
||
'zh_Hans': '\u7b80\u4f53\u4e2d\u6587', 'zh_Hant': '\u7e41\u9ad4\u4e2d\u6587',
|
||
'ar': '\u0627\u0644\u0639\u0631\u0628\u064a\u0629', 'cs': '\u010ce\u0161tina', 'da': 'Dansk',
|
||
'de': 'Deutsch', 'el': '\u0395\u03bb\u03bb\u03b7\u03bd\u03b9\u03ba\u03ac', 'es': 'Espa\u00f1ol',
|
||
'fa': '\u0641\u0627\u0631\u0633\u06cc', 'fi': 'Suomi', 'fil': 'Filipino',
|
||
'fr': 'Fran\u00e7ais', 'he': '\u05e2\u05d1\u05e8\u05d9\u05ea', 'hi': '\u0939\u093f\u0928\u094d\u0926\u0940',
|
||
'hu': 'Magyar', 'it': 'Italiano', 'ja': '\u65e5\u672c\u8a9e',
|
||
'ko': '\ud55c\uad6d\uc5b4', 'ms': 'Bahasa Melayu', 'nb_NO': 'Norsk Bokm\u00e5l',
|
||
'nl': 'Nederlands', 'pl': 'Polski', 'pt': 'Portugu\u00eas',
|
||
'pt_BR': 'Portugu\u00eas (BR)', 'ro': 'Rom\u00e2n\u0103', 'ru': '\u0420\u0443\u0441\u0441\u043a\u0438\u0439',
|
||
'sk': 'Sloven\u010dina', 'sv': 'Svenska', 'tr': 'T\u00fcrk\u00e7e',
|
||
'uk': '\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430', 'vi': 'Ti\u1ebfng Vi\u1ec7t'
|
||
};
|
||
var langs = (self.installedLanguages && self.installedLanguages.length)
|
||
? self.installedLanguages.slice()
|
||
: ['en', 'bn_BD'];
|
||
if (langs.indexOf('en') === -1) langs.unshift('en');
|
||
langs.forEach(function(code) {
|
||
var label = LANG_NAMES[code] || code;
|
||
var active = (currentLang === code);
|
||
var btn = E('button', { 'class': 'btn cbi-button ' + (active ? 'cbi-button-action important' : 'cbi-button-neutral'), 'style': 'padding:4px 14px;font-weight:bold;', 'click': function() { self.handleLanguageSwitch(code); } }, label);
|
||
langBar.appendChild(btn);
|
||
});
|
||
var grid = E('div', { 'id': 'quickactions-grid-container', 'style': 'display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;' });
|
||
if (cmds.length === 0) grid.appendChild(E('p', { 'style': 'grid-column:1/-1;color:#888;' }, 'No custom commands. Add them in System > Custom Commands first.'));
|
||
else {
|
||
var is3d = this.designStyle === '3d';
|
||
cmds.forEach(function(c) {
|
||
var name = c.name || c.command;
|
||
var style = 'padding:14px 12px;font-size:0.9em;font-weight:bold;cursor:pointer;text-align:center;' +
|
||
'color:#ffffff !important;' +
|
||
'background:#747d8c !important;' +
|
||
'background-color:#747d8c !important;' +
|
||
'box-shadow:0 4px 6px rgba(0,0,0,0.1);' +
|
||
'word-wrap:break-word;white-space:normal;line-height:1.3;' +
|
||
'text-shadow:0 1px 1px rgba(0,0,0,0.25);';
|
||
style += is3d ? 'border-radius:8px;border:none;' : 'border-radius:4px;border:1px solid rgba(0,0,0,0.1);';
|
||
var info = self.getServiceInfo(name);
|
||
var label = (info && info.icon) ? info.icon + ' ' + name : name;
|
||
var btn = E('button', { 'class': 'btn cbi-button custom-action-btn', 'style': style + (is3d ? 'border-bottom:5px solid #57606f !important;' : ''), 'data-name': name, 'data-executing': 'false' }, label);
|
||
var r = self.getServiceStatus(name, initList);
|
||
if (r !== null) self.applyLiveColor(btn, r);
|
||
btn.addEventListener('click', function() { self.handleButtonClick(c.command, name, btn); });
|
||
grid.appendChild(btn);
|
||
});
|
||
}
|
||
return E('div', {}, [themeBar, langBar, grid]);
|
||
},
|
||
|
||
renderEssential: function() {
|
||
var self = this;
|
||
var secs = (uci.sections('quickactions', 'essential') || []).slice();
|
||
secs.sort(function(a, b) { return (parseInt(a.order, 10) || 999) - (parseInt(b.order, 10) || 999); });
|
||
var is3d = this.designStyle === '3d';
|
||
var grid = E('div', { 'style': 'display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;' });
|
||
if (secs.length === 0) grid.appendChild(E('p', { 'style': 'grid-column:1/-1;color:#888;' }, 'No essential buttons. Add them in the Configuration tab.'));
|
||
else secs.forEach(function(s, idx) {
|
||
var cmd = s.command || '';
|
||
var label = s.label || s['.name'];
|
||
var icon = s.icon || '';
|
||
var needsConfirm = s.confirm === '1';
|
||
var isFirst = idx === 0;
|
||
var isLast = idx === secs.length - 1;
|
||
var style = 'padding:14px 12px;font-size:0.92em;font-weight:bold;cursor:pointer;text-align:center;' +
|
||
'color:#ffffff !important;' +
|
||
'background:#1e90ff !important;' +
|
||
'background-color:#1e90ff !important;' +
|
||
'box-shadow:0 4px 6px rgba(0,0,0,0.1);' +
|
||
'word-wrap:break-word;white-space:normal;line-height:1.35;flex:1;';
|
||
style += is3d ? 'border-radius:8px;border:none;border-bottom:5px solid #197ad9 !important;' : 'border-radius:4px;border:1px solid rgba(0,0,0,0.1);';
|
||
var btn = E('button', { 'class': 'btn cbi-button', 'style': style, 'data-executing': 'false' }, (icon ? icon + ' ' : '') + label);
|
||
btn.addEventListener('click', function() {
|
||
if (btn.getAttribute('data-executing') === 'true') return;
|
||
if (!cmd) { ui.addNotification('Error', 'No command', 'danger'); return; }
|
||
if (needsConfirm && !confirm('Confirm: ' + label + '\n\nCommand:\n' + cmd + '\n\nProceed?')) return;
|
||
btn.setAttribute('data-executing', 'true'); btn.style.opacity = '0.5';
|
||
if (typeof ui.addTimeLimitedNotification === 'function') {
|
||
ui.addTimeLimitedNotification('Running', label + '...', 8000, 'info');
|
||
} else {
|
||
ui.addNotification('Running', label + '...', 'info');
|
||
}
|
||
self.runCommand(cmd, label).then(function() { btn.setAttribute('data-executing', 'false'); btn.style.opacity = '1'; })
|
||
.catch(function(err) { btn.setAttribute('data-executing', 'false'); btn.style.opacity = '1'; ui.addNotification('Failed', (err && err.message) ? err.message : String(err), 'danger'); });
|
||
});
|
||
var upBtn = E('button', { 'class': 'btn cbi-button', 'style': 'padding:2px 6px;font-size:0.7em;line-height:1;', 'title': 'Move up', 'click': function() { if (isFirst) return; self.moveItem('essential', s['.name'], 'up').then(function() { self.switchTab('essential'); }); } }, '▲');
|
||
var downBtn = E('button', { 'class': 'btn cbi-button', 'style': 'padding:2px 6px;font-size:0.7em;line-height:1;', 'title': 'Move down', 'click': function() { if (isLast) return; self.moveItem('essential', s['.name'], 'down').then(function() { self.switchTab('essential'); }); } }, '▼');
|
||
var arrows = E('div', { 'style': 'display:flex;flex-direction:column;gap:2px;' }, [upBtn, downBtn]);
|
||
var row = E('div', { 'style': 'display:flex;gap:6px;align-items:stretch;' }, [btn, arrows]);
|
||
grid.appendChild(row);
|
||
});
|
||
return E('div', {}, [
|
||
E('p', { 'style': 'color:#777;font-size:0.9em;margin-bottom:14px;' }, 'Essential operations. Use ▲▼ arrows to reorder. Edit in Configuration tab.'),
|
||
grid
|
||
]);
|
||
},
|
||
|
||
renderTools: function() {
|
||
var self = this;
|
||
function section(title, body) {
|
||
return E('div', { 'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);border-radius:8px;padding:16px;margin-bottom:18px;' }, [E('h3', { 'style': 'margin:0 0 12px 0;font-size:1.05em;' }, title), body]);
|
||
}
|
||
function inputRow(label, id, type) {
|
||
return E('div', { 'style': 'margin-bottom:10px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, label),
|
||
E('input', { 'id': id, 'type': type || 'text', 'style': 'width:100%;max-width:400px;padding:6px 10px;border:1px solid #ccc;border-radius:4px;font-size:0.95em;box-sizing:border-box;' })
|
||
]);
|
||
}
|
||
var pppoeSection = section('PPPoE Internet Setup', E('div', {}, [
|
||
E('p', { 'style': 'color:#666;font-size:0.9em;margin:0 0 12px 0;' }, 'Configure WAN as PPPoE. Backs up /etc/config/network first.'),
|
||
inputRow('PPPoE Username', 'qa-pppoe-user'),
|
||
inputRow('PPPoE Password', 'qa-pppoe-pass', 'password'),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action important', 'style': 'margin-top:8px;', 'click': function() {
|
||
var u = document.getElementById('qa-pppoe-user').value;
|
||
var p = document.getElementById('qa-pppoe-pass').value;
|
||
if (!u || !p) { ui.addNotification('Error', 'Username and password required.', 'danger'); return; }
|
||
if (!confirm('Reconfigure WAN as PPPoE with user "' + u + '"? Internet may drop briefly.')) return;
|
||
var cmd = 'cp /etc/config/network /etc/config/network.bak.$(date +%s) 2>/dev/null; uci -q delete network.wan 2>/dev/null; uci set network.wan=interface; uci set network.wan.proto=pppoe; uci set network.wan.username="' + u + '"; uci set network.wan.password="' + p + '"; uci commit network; /etc/init.d/network restart';
|
||
if (typeof ui.addTimeLimitedNotification === 'function') {
|
||
ui.addTimeLimitedNotification('Running', 'Configuring PPPoE...', 8000, 'info');
|
||
} else {
|
||
ui.addNotification('Running', 'Configuring PPPoE...', 'info');
|
||
}
|
||
self.runCommand(cmd, 'PPPoE Setup');
|
||
} }, 'Apply PPPoE')
|
||
]));
|
||
var pwSection = section('Change Root Password', E('div', {}, [
|
||
inputRow('New Password', 'qa-pw', 'password'),
|
||
inputRow('Confirm Password', 'qa-pw2', 'password'),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action important', 'style': 'margin-top:8px;', 'click': function() {
|
||
var p1 = document.getElementById('qa-pw').value;
|
||
var p2 = document.getElementById('qa-pw2').value;
|
||
if (!p1 || p1.length < 4) { ui.addNotification('Error', 'Password too short.', 'danger'); return; }
|
||
if (p1 !== p2) { ui.addNotification('Error', 'Passwords do not match.', 'danger'); return; }
|
||
if (!confirm('Change root password? You will be logged out.')) return;
|
||
var rpcSetPw = rpc.declare({ object: 'luci', method: 'setPassword', params: [ 'username', 'password' ], expect: { '': {} } });
|
||
rpcSetPw('root', p1).then(function() { ui.addNotification('Success', 'Password changed. Log in again.', 'info'); document.getElementById('qa-pw').value = ''; document.getElementById('qa-pw2').value = ''; })
|
||
.catch(function(err) { ui.addNotification('Failed', (err && err.message) ? err.message : String(err), 'danger'); });
|
||
} }, 'Change Password')
|
||
]));
|
||
var shortcutSecs = (uci.sections('quickactions', 'shortcut') || []).slice();
|
||
shortcutSecs.sort(function(a, b) { return (parseInt(a.order, 10) || 999) - (parseInt(b.order, 10) || 999); });
|
||
var shortcutBody;
|
||
if (shortcutSecs.length === 0) shortcutBody = E('p', { 'style': 'color:#888;' }, 'No shortcuts.');
|
||
else {
|
||
shortcutBody = E('div', { 'style': 'display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px;' });
|
||
shortcutSecs.forEach(function(s, idx) {
|
||
var path = s.path || '', label = s.label || s['.name'], icon = s.icon || '';
|
||
var isFirst = idx === 0, isLast = idx === shortcutSecs.length - 1;
|
||
var navBtn = E('button', { 'class': 'btn cbi-button', 'style': 'padding:12px 10px;font-weight:bold;flex:1;background:rgba(128,128,128,0.15) !important;background-color:rgba(128,128,128,0.15) !important;color:inherit !important;border:1px solid rgba(0,0,0,0.15) !important;', 'click': function() { if (path) window.location.href = '/cgi-bin/luci/' + path; } }, (icon ? icon + ' ' : '') + label);
|
||
var upBtn = E('button', { 'class': 'btn cbi-button', 'style': 'padding:2px 6px;font-size:0.7em;line-height:1;', 'click': function() { if (isFirst) return; self.moveItem('shortcut', s['.name'], 'up').then(function() { self.switchTab('tools'); }); } }, '▲');
|
||
var downBtn = E('button', { 'class': 'btn cbi-button', 'style': 'padding:2px 6px;font-size:0.7em;line-height:1;', 'click': function() { if (isLast) return; self.moveItem('shortcut', s['.name'], 'down').then(function() { self.switchTab('tools'); }); } }, '▼');
|
||
var arrows = E('div', { 'style': 'display:flex;flex-direction:column;gap:2px;' }, [upBtn, downBtn]);
|
||
shortcutBody.appendChild(E('div', { 'style': 'display:flex;gap:6px;align-items:stretch;' }, [navBtn, arrows]));
|
||
});
|
||
}
|
||
var shortcutSection = section('Quick Navigation Shortcuts', shortcutBody);
|
||
// ---- Wireless Quick Edit ----
|
||
var wifiBody = (function() {
|
||
var ifaces = uci.sections('wireless', 'wifi-iface') || [];
|
||
if (!ifaces.length)
|
||
return E('p', { 'style': 'color:#888;' }, _('No wireless interfaces found.'));
|
||
|
||
var table = E('table', { 'style': 'width:100%;border-collapse:collapse;font-size:0.9em;' });
|
||
table.appendChild(E('tr', { 'style': 'text-align:left;border-bottom:2px solid rgba(0,0,0,0.1);' }, [
|
||
E('th', { 'style': 'padding:8px;' }, _('Status')),
|
||
E('th', { 'style': 'padding:8px;' }, _('Radio')),
|
||
E('th', { 'style': 'padding:8px;' }, _('Mode')),
|
||
E('th', { 'style': 'padding:8px;' }, _('SSID')),
|
||
E('th', { 'style': 'padding:8px;' }, _('Security')),
|
||
E('th', { 'style': 'padding:8px;' }, _('Password')),
|
||
E('th', { 'style': 'padding:8px;' }, _('Network')),
|
||
E('th', { 'style': 'padding:8px;text-align:right;' }, _(''))
|
||
]));
|
||
|
||
ifaces.forEach(function(w) {
|
||
var sid = w['.name'];
|
||
var disabled = (w.disabled === '1');
|
||
var key = w.key || w.key1 || '';
|
||
var keyShown = key ? (key.length > 6 ? key.slice(0,3) + '…' + key.slice(-2) : '••••') : '';
|
||
var mode = w.mode || 'ap';
|
||
var row = E('tr', { 'style': 'border-bottom:1px solid rgba(0,0,0,0.06);' });
|
||
row.appendChild(E('td', { 'style': 'padding:8px;' },
|
||
E('span', { 'class': 'gw-badge ' + (disabled ? 'gw-badge-off' : 'gw-badge-on') },
|
||
disabled ? _('disabled') : _('enabled'))));
|
||
row.appendChild(E('td', { 'style': 'padding:8px;' }, w.device || '—'));
|
||
row.appendChild(E('td', { 'style': 'padding:8px;' }, mode));
|
||
row.appendChild(E('td', { 'style': 'padding:8px;' }, w.ssid || '—'));
|
||
row.appendChild(E('td', { 'style': 'padding:8px;' }, w.encryption || '—'));
|
||
row.appendChild(E('td', { 'style': 'padding:8px;font-family:monospace;' }, keyShown || '—'));
|
||
row.appendChild(E('td', { 'style': 'padding:8px;' }, w.network || '—'));
|
||
row.appendChild(E('td', { 'style': 'padding:8px;text-align:right;' },
|
||
E('button', { 'class': 'btn cbi-button',
|
||
'style': 'padding:4px 12px;font-size:0.85em;',
|
||
'click': function() { self.wirelessQuickEdit(sid); } }, _('Edit'))));
|
||
table.appendChild(row);
|
||
});
|
||
return table;
|
||
})();
|
||
var wifiSection = section('Wireless Quick Edit', E('div', {}, [
|
||
E('p', { 'style': 'color:#666;font-size:0.9em;margin:0 0 12px 0;' },
|
||
_('Change SSID, security, password, or attach a network interface — for every wireless interface on both radios (master / client / mesh / guest / iot).')),
|
||
wifiBody
|
||
]));
|
||
|
||
return E('div', {}, [pppoeSection, pwSection, wifiSection, shortcutSection]);
|
||
},
|
||
|
||
renderLogs: function() {
|
||
var self = this;
|
||
var levels = [
|
||
{ v: 8, label: 'Debug (8)' }, { v: 7, label: 'Info (7)' }, { v: 6, label: 'Notice (6)' },
|
||
{ v: 5, label: 'Warning (5)' }, { v: 4, label: 'Error (4)' }, { v: 3, label: 'Critical (3)' },
|
||
{ v: 2, label: 'Alert (2)' }, { v: 1, label: 'Emergency (1)' }, { v: 0, label: 'Silent (0)' }
|
||
];
|
||
var current = uci.get('system', '@system[0]', 'log_level') || '5';
|
||
var select = E('select', { 'id': 'qa-log-level', 'style': 'padding:8px 10px;border:1px solid #ccc;border-radius:4px;font-size:0.95em;min-width:220px;' });
|
||
levels.forEach(function(l) {
|
||
var opt = E('option', { 'value': String(l.v) }, l.label);
|
||
if (String(l.v) === String(current)) opt.selected = true;
|
||
select.appendChild(opt);
|
||
});
|
||
return E('div', {}, [
|
||
E('div', { 'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);border-radius:8px;padding:16px;margin-bottom:18px;' }, [
|
||
E('h3', { 'style': 'margin:0 0 12px 0;font-size:1.05em;' }, 'System Log Level'),
|
||
E('p', { 'style': 'color:#777;font-size:0.9em;margin:0 0 12px 0;' }, 'Current: ' + current + '. Lower = fewer logs.'),
|
||
select,
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action important', 'style': 'margin-top:12px;margin-left:8px;', 'click': function() {
|
||
var v = document.getElementById('qa-log-level').value;
|
||
if (!confirm('Change log level to ' + v + '?')) return;
|
||
var cmd = 'uci set system.@system[0].log_level=' + v + '; uci commit system; /etc/init.d/log restart';
|
||
self.runCommand(cmd, 'Set log level').then(function() { ui.addNotification('Done', 'Log level set to ' + v + ' — reloading...', 'info'); setTimeout(function() { window.location.reload(); }, 1200); });
|
||
} }, 'Apply')
|
||
]),
|
||
E('div', { 'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);border-radius:8px;padding:16px;margin-bottom:18px;' }, [
|
||
E('h3', { 'style': 'margin:0 0 12px 0;font-size:1.05em;' }, 'Cron Log Level'),
|
||
E('p', { 'style': 'color:#777;font-size:0.9em;margin:0 0 12px 0;' }, 'OpenWrt cron is BusyBox crond — logs go to syslog at current system level.'),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'click': function() {
|
||
self.runCommand('/etc/init.d/cron restart && logger -t cron "cron restarted"', 'Restart cron');
|
||
} }, 'Restart Cron'),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'style': 'margin-left:8px;', 'click': function() {
|
||
self.runCommand('logread | grep -i cron | tail -30', 'Recent cron log');
|
||
} }, 'Show Recent Cron Log')
|
||
]),
|
||
E('div', { 'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);border-radius:8px;padding:16px;' }, [
|
||
E('h3', { 'style': 'margin:0 0 12px 0;font-size:1.05em;' }, 'Log Viewer'),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'click': function() { self.runCommand('logread | tail -50', 'Last 50 log lines'); } }, 'Last 50 lines'),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'style': 'margin-left:8px;', 'click': function() { self.runCommand('logread | grep -i error | tail -30', 'Errors'); } }, 'Errors only'),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'style': 'margin-left:8px;', 'click': function() { self.runCommand('dmesg | tail -30', 'Kernel messages'); } }, 'Kernel (dmesg)')
|
||
])
|
||
]);
|
||
},
|
||
|
||
renderServices: function() {
|
||
var self = this;
|
||
function inputRow(label, id, placeholder) {
|
||
return E('div', { 'style': 'margin-bottom:10px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, label),
|
||
E('input', { 'id': id, 'placeholder': placeholder || '', 'style': 'width:100%;max-width:400px;padding:6px 10px;border:1px solid #ccc;border-radius:4px;font-size:0.95em;box-sizing:border-box;' })
|
||
]);
|
||
}
|
||
var actionSelect = E('select', { 'id': 'qa-svc-action', 'style': 'padding:8px 10px;border:1px solid #ccc;border-radius:4px;font-size:0.95em;min-width:220px;' }, [
|
||
E('option', { 'value': 'enable_start' }, 'Enable + Start'),
|
||
E('option', { 'value': 'disable_stop' }, 'Disable + Stop'),
|
||
E('option', { 'value': 'restart' }, 'Restart'),
|
||
E('option', { 'value': 'status' }, 'Show Status')
|
||
]);
|
||
var row2 = E('div', { 'style': 'margin-bottom:10px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, 'Action'),
|
||
actionSelect
|
||
]);
|
||
|
||
var applierSection = E('div', { 'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);border-radius:8px;padding:16px;margin-bottom:18px;' }, [
|
||
E('h3', { 'style': 'margin:0 0 12px 0;font-size:1.05em;' }, 'Service Mode Applier'),
|
||
E('p', { 'style': 'color:#777;font-size:0.9em;margin:0 0 12px 0;' }, 'Enable, disable, restart, or check any service on the router.'),
|
||
inputRow('Service Name', 'qa-svc-name', 'e.g. dnsmasq, firewall, uhttpd'),
|
||
row2,
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action important', 'style': 'margin-top:8px;', 'click': function() {
|
||
var name = document.getElementById('qa-svc-name').value.trim();
|
||
var action = document.getElementById('qa-svc-action').value;
|
||
if (!name) { ui.addNotification('Error', 'Enter service name', 'danger'); return; }
|
||
if (!/^[a-zA-Z0-9_\-]+$/.test(name)) { ui.addNotification('Error', 'Invalid service name', 'danger'); return; }
|
||
var cmd;
|
||
if (action === 'enable_start') cmd = '/etc/init.d/' + name + ' enable && /etc/init.d/' + name + ' start';
|
||
else if (action === 'disable_stop') cmd = '/etc/init.d/' + name + ' stop; /etc/init.d/' + name + ' disable';
|
||
else if (action === 'restart') cmd = '/etc/init.d/' + name + ' restart';
|
||
else cmd = '/etc/init.d/' + name + ' enabled; /etc/init.d/' + name + ' running 2>/dev/null; ubus call service list "{\\"name\\":\\"' + name + '\\"}" 2>/dev/null';
|
||
if (!confirm('Apply "' + action + '" to service "' + name + '"?\n\nCommand:\n' + cmd)) return;
|
||
ui.addNotification('Running', name + ' ' + action, 'info');
|
||
self.runCommand(cmd, name + ': ' + action);
|
||
} }, 'Apply')
|
||
]);
|
||
|
||
var listSection = E('div', { 'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);border-radius:8px;padding:16px;' }, [
|
||
E('h3', { 'style': 'margin:0 0 12px 0;font-size:1.05em;' }, 'Service Lists'),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'click': function() { self.runCommand('/etc/init.d/* enabled 2>/dev/null; echo "---"; ls /etc/init.d/', 'Enabled services'); } }, 'Enabled Services'),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'style': 'margin-left:8px;', 'click': function() { self.runCommand('for s in /etc/init.d/*; do n=$(basename $s); /etc/init.d/$n enabled 2>/dev/null || echo "DISABLED: $n"; done', 'Disabled services'); } }, 'Disabled Services'),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'style': 'margin-left:8px;', 'click': function() { self.runCommand('ubus list | sort', 'All ubus services'); } }, 'Active ubus Services')
|
||
]);
|
||
return E('div', {}, [applierSection, listSection]);
|
||
},
|
||
|
||
renderDependencies: function() {
|
||
var self = this;
|
||
var inputRow = E('div', { 'style': 'margin-bottom:10px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, 'Package name (leave blank to list all)'),
|
||
E('input', { 'id': 'qa-dep-pkg', 'placeholder': 'e.g. luci-app-firewall', 'style': 'width:100%;max-width:400px;padding:6px 10px;border:1px solid #ccc;border-radius:4px;font-size:0.95em;box-sizing:border-box;' })
|
||
]);
|
||
|
||
function runSingle() {
|
||
var pkg = document.getElementById('qa-dep-pkg').value.trim();
|
||
if (!pkg) { ui.addNotification('Error', 'Enter a package name', 'danger'); return; }
|
||
if (!/^[a-zA-Z0-9_\-\+\.]+$/.test(pkg)) { ui.addNotification('Error', 'Invalid package name', 'danger'); return; }
|
||
var cmd = 'if command -v apk >/dev/null 2>&1; then echo "=== apk info --depends ' + pkg + ' ==="; apk info --depends ' + pkg + ' 2>&1 || apk info -R ' + pkg + ' 2>&1; else echo "=== opkg info ' + pkg + ' ==="; opkg info ' + pkg + ' 2>&1 | grep -A50 "^Package:"; fi';
|
||
ui.addNotification('Running', 'Fetching ' + pkg + ' dependencies...', 'info');
|
||
self.runCommand(cmd, 'Dependencies: ' + pkg);
|
||
}
|
||
|
||
var singleBox = E('div', { 'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);border-radius:8px;padding:16px;margin-bottom:18px;' }, [
|
||
E('h3', { 'style': 'margin:0 0 12px 0;font-size:1.05em;' }, 'Package Dependency Inspector'),
|
||
E('p', { 'style': 'color:#777;font-size:0.9em;margin:0 0 12px 0;' }, 'Auto-detects apk (OpenWrt 25.12+) vs opkg (older). Enter a package name to view its dependencies.'),
|
||
inputRow,
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action important', 'click': runSingle }, 'Show Dependencies')
|
||
]);
|
||
|
||
var allBox = E('div', { 'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);border-radius:8px;padding:16px;' }, [
|
||
E('h3', { 'style': 'margin:0 0 12px 0;font-size:1.05em;' }, 'All Installed Packages with Dependencies'),
|
||
E('p', { 'style': 'color:#777;font-size:0.9em;margin:0 0 12px 0;' }, 'Lists every installed package and its full dependency tree. Output may be large.'),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'click': function() {
|
||
var cmd = 'if command -v apk >/dev/null 2>&1; then apk list --installed -I 2>/dev/null | head -200; echo ""; echo "=== Total installed ==="; apk list -I 2>/dev/null | wc -l; else opkg list-installed; fi';
|
||
ui.addNotification('Running', 'Listing all packages...', 'info');
|
||
self.runCommand(cmd, 'All packages');
|
||
} }, 'List All Packages'),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'style': 'margin-left:8px;', 'click': function() {
|
||
var cmd = 'if command -v apk >/dev/null 2>&1; then apk list -I --depends 2>/dev/null | head -300; else awk \'/^Package:/{p=$2} /^Depends:/{print p " -> " $0}\' /usr/lib/opkg/status; fi';
|
||
ui.addNotification('Running', 'Listing dependencies...', 'info');
|
||
self.runCommand(cmd, 'Package dependencies');
|
||
} }, 'List All Dependencies'),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'style': 'margin-left:8px;', 'click': function() {
|
||
var cmd = 'if command -v apk >/dev/null 2>&1; then apk info --depends 2>/dev/null | head -200; else opkg status | grep -E "^(Package|Depends):"; fi';
|
||
ui.addNotification('Running', 'Fetching dependency pairs...', 'info');
|
||
self.runCommand(cmd, 'Dependency pairs');
|
||
} }, 'Compact View')
|
||
]);
|
||
|
||
var totalsBox = E('div', { 'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);border-radius:8px;padding:16px;margin-top:18px;' }, [
|
||
E('h3', { 'style': 'margin:0 0 12px 0;font-size:1.05em;' }, 'System Package Summary'),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'click': function() {
|
||
var cmd = 'echo "Package manager:"; command -v apk >/dev/null 2>&1 && echo "apk (OpenWrt 25.12+)" || echo "opkg (OpenWrt <=24.10)"; echo ""; echo "Total installed packages:"; if command -v apk >/dev/null 2>&1; then apk list -I 2>/dev/null | wc -l; else opkg list-installed | wc -l; fi; echo ""; echo "Recent installs:"; if command -v apk >/dev/null 2>&1; then apk list -I 2>/dev/null | tail -20; else opkg list-installed | tail -20; fi';
|
||
self.runCommand(cmd, 'Package summary');
|
||
} }, 'System Package Summary')
|
||
]);
|
||
|
||
return E('div', {}, [singleBox, allBox, totalsBox]);
|
||
},
|
||
|
||
renderCommand: function() {
|
||
var self = this;
|
||
function docBox(title, content) {
|
||
var details = E('details', { 'style': 'margin-bottom:12px;background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.08);border-radius:8px;padding:12px 16px;' });
|
||
details.appendChild(E('summary', { 'style': 'cursor:pointer;font-weight:bold;color:#1e90ff;font-size:1em;' }, title));
|
||
details.appendChild(E('div', { 'style': 'padding-top:10px;font-size:0.92em;line-height:1.5;' }, content));
|
||
return details;
|
||
}
|
||
function codeBlock(text) {
|
||
return E('pre', { 'style': 'background:#1e1e1e;color:#00ff00;padding:10px;border-radius:6px;font-family:monospace;font-size:0.85em;overflow-x:auto;margin:8px 0;white-space:pre-wrap;' }, text);
|
||
}
|
||
var runnerBox = E('div', { 'style': 'background:rgba(30,144,255,0.08);border:1px solid rgba(30,144,255,0.2);border-radius:8px;padding:16px;margin-bottom:20px;' }, [
|
||
E('h3', { 'style': 'margin:0 0 10px 0;' }, 'Direct Command Execution'),
|
||
E('p', { 'style': 'color:#666;font-size:0.9em;margin:0 0 12px 0;' }, 'Run shell commands as root. Output in notification.'),
|
||
E('textarea', { 'id': 'qa-cmd-input', 'placeholder': 'e.g. df -h', 'style': 'width:100%;min-height:90px;padding:10px;border:1px solid #ccc;border-radius:4px;font-family:monospace;font-size:0.9em;box-sizing:border-box;' }),
|
||
E('div', { 'style': 'margin-top:10px;display:flex;gap:10px;flex-wrap:wrap;' }, [
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action important', 'click': function() {
|
||
var cmd = document.getElementById('qa-cmd-input').value.trim();
|
||
if (!cmd) return;
|
||
if (!confirm('Run this command?\n\n' + cmd)) return;
|
||
ui.addNotification('Running', 'Command executing...', 'info');
|
||
self.runCommand(cmd, 'Command');
|
||
} }, 'Run Command'),
|
||
E('button', { 'class': 'btn cbi-button', 'click': function() { document.getElementById('qa-cmd-input').value = ''; } }, 'Clear')
|
||
])
|
||
]);
|
||
var docs = E('div', {}, [
|
||
E('h3', { 'style': 'margin:0 0 12px 0;' }, 'OpenWrt Command Reference'),
|
||
docBox('System Information', E('div', {}, [codeBlock('uname -a\ncat /etc/openwrt_release\ncat /proc/cpuinfo\nfree -h\ndf -h\nuptime')])),
|
||
docBox('Network Diagnostics', E('div', {}, [codeBlock('ip addr show\nip route show\niwinfo\nping -c 4 8.8.8.8\nnslookup google.com\ntraceroute 8.8.8.8')])),
|
||
docBox('WiFi Management', E('div', {}, [codeBlock('/sbin/wifi down\n/sbin/wifi up\nwifi status\niwinfo wlan0 scan')])),
|
||
docBox('Firewall / Routing', E('div', {}, [codeBlock('nft list ruleset\nuci show firewall\nuci show network\nfw4 reload')])),
|
||
docBox('Package Management', E('div', {}, [codeBlock('opkg update && opkg list-installed\napk update && apk list -I')])),
|
||
docBox('Service Control', E('div', {}, [codeBlock('/etc/init.d/network restart\nservice <name> enable\nservice <name> disable')])),
|
||
docBox('Logs and Debugging', E('div', {}, [codeBlock('logread\nlogread -f\ndmesg\nlogread | grep error')])),
|
||
docBox('UCI Configuration', E('div', {}, [codeBlock('uci show\nuci get network.wan.proto\nuci set network.wan.proto=dhcp\nuci commit network')])),
|
||
docBox('Process Management', E('div', {}, [codeBlock('ps w\ntop -n 1\nkill <pid>\nlsmod')])),
|
||
docBox('GPIO / Hardware Buttons', E('div', {}, [codeBlock('ubus call system board\nls /sys/class/gpio/\n/etc/rc.button/reset')])),
|
||
docBox('Backup and Restore', E('div', {}, [codeBlock('sysupgrade -b /tmp/backup.tar.gz\nsysupgrade -r /tmp/backup.tar.gz\nfirstboot -y')])),
|
||
docBox('Useful One-Liners', E('div', {}, [
|
||
E('p', {}, 'Clear cache:'), codeBlock('rm -rf /tmp/luci-* && /etc/init.d/rpcd restart && /etc/init.d/uhttpd restart'),
|
||
E('p', {}, 'LEDs off:'), codeBlock('for f in /sys/class/leds/*/brightness; do echo 0 > $f; done'),
|
||
E('p', {}, 'Reboot in 5 min:'), codeBlock('(sleep 300 && reboot) &')
|
||
]))
|
||
]);
|
||
return E('div', {}, [runnerBox, docs]);
|
||
},
|
||
|
||
renderHotplug: function() {
|
||
var self = this;
|
||
|
||
var regenBar = E('div', { 'style': 'background:rgba(30,144,255,0.08);border:1px solid rgba(30,144,255,0.2);border-radius:8px;padding:12px;margin-bottom:16px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;' }, [
|
||
E('div', { 'style': 'font-size:0.9em;' }, [
|
||
E('strong', {}, 'Generator: '),
|
||
E('span', {}, 'Rules are stored in UCI. After Save & Apply, click Regenerate to write the handler scripts into /etc/hotplug.d/iface/.')
|
||
]),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action', 'click': function() {
|
||
if (!confirm('Regenerate hotplug handler scripts from /etc/config/hotplug?')) return;
|
||
ui.addNotification('Running', 'Regenerating handlers...', 'info');
|
||
self.callSystemCommand('/bin/sh', [ '-c', '/etc/init.d/quickactions-hotplug restart' ]).then(function(r) {
|
||
var out = r.stdout || '';
|
||
if (r.stderr) out += '\n' + r.stderr;
|
||
var uid = 'qa-hp-gen-' + Date.now();
|
||
var pre = document.createElement('pre');
|
||
pre.id = uid;
|
||
pre.style.cssText = 'text-align:left;white-space:pre-wrap;background:#1e1e1e;color:#00ff00;padding:12px;border-radius:6px;font-family:monospace;font-size:0.85em;width:100%;box-sizing:border-box;margin:0;';
|
||
pre.textContent = out || 'Handlers regenerated.';
|
||
ui.addNotification('Regenerated', pre, 'info');
|
||
var el = document.getElementById(uid);
|
||
if (el && el.closest) { var c = el.closest('.alert'); if (c) { c.style.maxWidth = '95%'; c.style.width = 'auto'; } }
|
||
}).catch(function(err) {
|
||
ui.addNotification('Failed', (err && err.message) ? err.message : String(err), 'danger');
|
||
});
|
||
} }, 'Regenerate Handlers')
|
||
]);
|
||
|
||
var m = new form.Map('hotplug', _('Hotplug Rules'),
|
||
_('Run commands on network interface events or when connectivity monitors fail. Replaces luci-app-hotplug.'));
|
||
|
||
var s = m.section(form.GridSection, 'iface', _('Interface Events'),
|
||
_('Execute commands when a logical interface goes up, down, or changes state.'));
|
||
s.addremove = true;
|
||
s.nodescriptions = true;
|
||
var o = s.option(form.TextValue, 'description', _('Description'));
|
||
o.placeholder = _('e.g. Restart firewall on WAN up');
|
||
o = s.option(form.Flag, 'enabled', _('Enabled'));
|
||
o.default = '1';
|
||
o.rmempty = false;
|
||
o = s.option(form.ListValue, 'action', _('Trigger event'));
|
||
o.default = 'ifup';
|
||
o.value('ifup', _('Interface up'));
|
||
o.value('ifdown', _('Interface down'));
|
||
o.value('ifup-failed', _('Interface up failed'));
|
||
o.value('ifupdate', _('Interface changed'));
|
||
o.value('free', _('Interface removed'));
|
||
o.value('reload', _('Interface reload'));
|
||
o.value('iflink', _('Interface received link'));
|
||
o.value('create', _('Interface created'));
|
||
o = s.option(form.Value, 'interface', _('Interface'),
|
||
_('The logical interface to watch (e.g. wan, lan)'));
|
||
o.rmempty = false;
|
||
o = s.option(form.DynamicList, 'command', _('Commands to run'));
|
||
o.datatype = 'string';
|
||
o.placeholder = _('/etc/init.d/firewall restart');
|
||
|
||
var s2 = m.section(form.GridSection, 'hotplug', _('Connectivity Monitors'),
|
||
_('Ping test hosts through an interface. After consecutive failures, reset the interface or reboot.'));
|
||
s2.addremove = true;
|
||
s2.nodescriptions = true;
|
||
o = s2.option(form.Flag, 'enabled', _('Enabled'));
|
||
o.default = '1';
|
||
o.rmempty = false;
|
||
o = s2.option(form.Value, 'iface', _('Ping interface'));
|
||
o.rmempty = false;
|
||
o = s2.option(form.DynamicList, 'testip', _('Test hosts'));
|
||
o.datatype = 'or(hostname,ipaddr("nomask"))';
|
||
o.placeholder = '1.1.1.1';
|
||
o = s2.option(form.Value, 'check_period', _('Check period (seconds)'));
|
||
o.default = '60';
|
||
o.datatype = 'and(uinteger,min(20))';
|
||
o.rmempty = false;
|
||
o = s2.option(form.Value, 'sw_before_modres', _('Failures before interface reset'));
|
||
o.default = '3';
|
||
o.datatype = 'and(uinteger,min(0),max(100))';
|
||
o.rmempty = false;
|
||
o = s2.option(form.Value, 'sw_before_sysres', _('Failures before reboot'));
|
||
o.default = '0';
|
||
o.datatype = 'and(uinteger,min(0),max(100))';
|
||
o.rmempty = false;
|
||
|
||
return m.render().then(function(formNode) {
|
||
var saveBtn = E('button', { 'class': 'btn cbi-button cbi-button-action important', 'style': 'margin-top:14px;', 'click': function() {
|
||
if (!confirm('Save hotplug config and regenerate handlers?')) return;
|
||
ui.addNotification('Saving', 'Applying hotplug config...', 'info');
|
||
m.save().then(function() {
|
||
return uci.apply(10);
|
||
}).then(function() {
|
||
return self.callSystemCommand('/bin/sh', ['-c', '/etc/init.d/quickactions-hotplug restart']).catch(function(){ return {}; });
|
||
}).then(function() {
|
||
ui.addNotification('Saved', 'Hotplug config saved, applied, and handlers regenerated.', 'info');
|
||
self.switchTab('hotplug');
|
||
}).catch(function(err) {
|
||
ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger');
|
||
});
|
||
} }, 'Save & Apply Hotplug');
|
||
return E('div', {}, [regenBar, formNode, saveBtn]);
|
||
});
|
||
},
|
||
|
||
cwAddStyles: function() {
|
||
if (document.getElementById('cw-styles')) return;
|
||
var style = document.createElement('style');
|
||
style.id = 'cw-styles';
|
||
style.type = 'text/css';
|
||
style.textContent = `
|
||
.cw-root .cron-multiselect {
|
||
width: 100%; min-height: 140px;
|
||
background: rgba(0,0,0,0.03);
|
||
color: inherit; border: 1px solid rgba(0,0,0,0.15);
|
||
border-radius: 4px; padding: 4px; font-family: inherit;
|
||
font-size: 13px; line-height: 1.4; outline: none;
|
||
}
|
||
:root[data-darkmode="true"] .cw-root .cron-multiselect {
|
||
background: #2a2f34; color: #e5e7eb; border-color: #3a4146;
|
||
}
|
||
.cw-root .cron-minute-head { background: #fdd2d6; color: #263238; font-weight: bold; padding: 8px; }
|
||
.cw-root .cron-hour-head { background: #cfead1; color: #263238; font-weight: bold; padding: 8px; }
|
||
.cw-root .cron-day-head { background: #ffd8ad; color: #263238; font-weight: bold; padding: 8px; }
|
||
.cw-root .cron-month-head { background: #bbdefb; color: #263238; font-weight: bold; padding: 8px; }
|
||
.cw-root .cron-weekday-head { background: #e1bee7; color: #263238; font-weight: bold; padding: 8px; }
|
||
:root[data-darkmode="true"] .cw-root .cron-minute-head { background: #8b4f5a; color: #ffcdd2; }
|
||
:root[data-darkmode="true"] .cw-root .cron-hour-head { background: #4f6b52; color: #c8e6c9; }
|
||
:root[data-darkmode="true"] .cw-root .cron-day-head { background: #7a5838; color: #ffe0b2; }
|
||
:root[data-darkmode="true"] .cw-root .cron-month-head { background: #4a6b8e; color: #bbdefb; }
|
||
:root[data-darkmode="true"] .cw-root .cron-weekday-head { background: #5a4f71; color: #e1bee7; }
|
||
.cw-root .cron-badge {
|
||
display: inline-block; padding: 3px 6px; border-radius: 6px;
|
||
border: 1px solid rgba(0,0,0,0.15); font-weight: 600;
|
||
}
|
||
:root[data-darkmode="true"] .cw-root .cron-badge { border-color: rgba(255,255,255,0.2); }
|
||
.cw-root #cw_preview {
|
||
min-height: 40px; padding: 8px; border-radius: 6px;
|
||
border: 1px solid rgba(0,0,0,0.15); font-family: monospace;
|
||
background: rgba(0,0,0,0.03);
|
||
}
|
||
:root[data-darkmode="true"] .cw-root #cw_preview { background: #2a2f34; border-color: #3a4146; }
|
||
.cw-root .cw-section { background: rgba(0,0,0,0.02); border: 1px solid rgba(0,0,0,0.06); border-radius: 8px; padding: 16px; margin-bottom: 18px; }
|
||
:root[data-darkmode="true"] .cw-root .cw-section { background: rgba(255,255,255,0.03); border-color: rgba(255,255,255,0.08); }
|
||
`;
|
||
document.head.appendChild(style);
|
||
},
|
||
|
||
cwColors: {
|
||
light: { minute:'#fdd2d6', hour:'#cfead1', day:'#ffd8ad', month:'#bbdefb', weekday:'#e1bee7' },
|
||
dark: { minute:'#5b2f35', hour:'#2f4732', day:'#4a3828', month:'#30475e', weekday:'#3a2f41' }
|
||
},
|
||
cwGetCurrentColors: function() {
|
||
return document.documentElement.getAttribute('data-darkmode') === 'true' ? this.cwColors.dark : this.cwColors.light;
|
||
},
|
||
cwGetBadgeTextColor: function() {
|
||
return document.documentElement.getAttribute('data-darkmode') === 'true' ? '#ffffff' : '#111111';
|
||
},
|
||
cwWeekdayNames: function() {
|
||
return [ _('Sunday'), _('Monday'), _('Tuesday'), _('Wednesday'), _('Thursday'), _('Friday'), _('Saturday') ];
|
||
},
|
||
cwMonthNames: function() {
|
||
return [ '', _('January'), _('February'), _('March'), _('April'), _('May'), _('June'),
|
||
_('July'), _('August'), _('September'), _('October'), _('November'), _('December') ];
|
||
},
|
||
cwDescribeCron: function(minute, hour, day, month, weekday, command) {
|
||
var parts = [];
|
||
if (minute !== '*') parts.push(_('minute: %s').format(minute));
|
||
if (hour !== '*') parts.push(_('hour: %s').format(hour));
|
||
if (day !== '*') parts.push(_('day: %s').format(day));
|
||
if (month !== '*') parts.push(_('month: %s').format(month));
|
||
if (weekday !== '*') parts.push(_('weekday: %s').format(weekday));
|
||
if (parts.length === 0) return command ? _('Command "%s" will run every minute.').format(command) : '';
|
||
return command ? _('Command "%s" will run at').format(command) + ': ' + parts.join('; ')
|
||
: _('Runs at') + ': ' + parts.join('; ');
|
||
},
|
||
|
||
cwBadge: function(bg, content) {
|
||
var color = this.cwGetBadgeTextColor();
|
||
return '<span class="cron-badge" style="background:' + bg + ';color:' + color + '">' + content + '</span>';
|
||
},
|
||
cwGetSelectedValues: function(selectId) {
|
||
var sel = document.getElementById(selectId);
|
||
if (!sel) return [];
|
||
return Array.prototype.slice.call(sel.selectedOptions || []).map(function(o){ return o.value; });
|
||
},
|
||
cwNormalizeSelection: function(values) {
|
||
if (!values || values.length === 0) return ['*'];
|
||
if (values.indexOf('*') >= 0) return ['*'];
|
||
var seen = {};
|
||
values.forEach(function(v){ seen[parseInt(v,10)] = 1; });
|
||
return Object.keys(seen).map(Number).sort(function(a,b){ return a-b; }).map(String);
|
||
},
|
||
cwCompressValues: function(values) {
|
||
if (!values || values.length === 0) return '*';
|
||
if (values.length === 1) return values[0];
|
||
var nums = values.map(Number), parts = [], start = nums[0], prev = nums[0];
|
||
for (var i = 1; i < nums.length; i++) {
|
||
var cur = nums[i];
|
||
if (cur === prev + 1) { prev = cur; continue; }
|
||
parts.push(start === prev ? String(start) : (start + '-' + prev));
|
||
start = prev = cur;
|
||
}
|
||
parts.push(start === prev ? String(start) : (start + '-' + prev));
|
||
return parts.join(',');
|
||
},
|
||
cwBuildField: function(selectId, checkboxId) {
|
||
var vals = this.cwNormalizeSelection(this.cwGetSelectedValues(selectId));
|
||
if (checkboxId) {
|
||
var cb = document.getElementById(checkboxId);
|
||
if (cb && cb.checked) {
|
||
var sel = vals[0];
|
||
return (sel === '*' || sel === undefined) ? '*' : '*/' + sel;
|
||
}
|
||
}
|
||
if (vals.length === 0 || vals[0] === '*') return '*';
|
||
return this.cwCompressValues(vals);
|
||
},
|
||
|
||
cwUpdatePreview: function() {
|
||
var minute = this.cwBuildField('cw_minute', 'cw_minute_cb');
|
||
var hour = this.cwBuildField('cw_hour', 'cw_hour_cb');
|
||
var day = this.cwBuildField('cw_day', 'cw_day_cb');
|
||
var month = this.cwBuildField('cw_month');
|
||
var weekday = this.cwBuildField('cw_weekday');
|
||
var cmdEl = document.getElementById('cw_command');
|
||
var command = cmdEl ? cmdEl.value : '';
|
||
|
||
var colors = this.cwGetCurrentColors();
|
||
var html = '';
|
||
html += (minute !== '*') ? this.cwBadge(colors.minute, minute) : minute; html += ' ';
|
||
html += (hour !== '*') ? this.cwBadge(colors.hour, hour) : hour; html += ' ';
|
||
html += (day !== '*') ? this.cwBadge(colors.day, day) : day; html += ' ';
|
||
html += (month !== '*') ? this.cwBadge(colors.month, month) : month; html += ' ';
|
||
html += (weekday !== '*') ? this.cwBadge(colors.weekday, weekday) : weekday; html += ' ';
|
||
html += command ? '<span class="cron-badge" style="background:#90a4ae">' + command + '</span>' : '';
|
||
|
||
var prev = document.getElementById('cw_preview');
|
||
if (prev) prev.innerHTML = html;
|
||
var txt = document.getElementById('cw_preview_text');
|
||
if (txt) txt.value = minute + ' ' + hour + ' ' + day + ' ' + month + ' ' + weekday + ' ' + command;
|
||
var hum = document.getElementById('cw_human');
|
||
if (hum) hum.value = this.cwDescribeCron(minute, hour, day, month, weekday, command);
|
||
},
|
||
|
||
cwAppend: function() {
|
||
var input = document.getElementById('cw_preview_text');
|
||
if (!input) return;
|
||
var line = (input.value || '').trim();
|
||
if (!line || line === '* * * * *') { ui.addNotification('Error', _('Check the cron entry - it must contain time + command'), 'danger'); return; }
|
||
if (line.split(/\s+/).length < 6) { ui.addNotification('Error', _('Please enter the command'), 'danger'); return; }
|
||
if (!confirm('Add this cron entry?\n\n' + line)) return;
|
||
|
||
fs.read('/etc/crontabs/root').catch(function(){ return ''; }).then(function(content) {
|
||
var cur = (content || '').replace(/\r\n/g, '\n');
|
||
var haystack = '\n' + cur + (cur.slice(-1) === '\n' ? '' : '\n');
|
||
if (haystack.indexOf('\n' + line + '\n') >= 0) {
|
||
ui.addNotification('Duplicate', _('This entry already exists'), 'info');
|
||
return null;
|
||
}
|
||
if (cur && cur.slice(-1) !== '\n') cur += '\n';
|
||
cur += line + '\n';
|
||
return fs.write('/etc/crontabs/root', cur).then(function() {
|
||
return fs.exec('/etc/init.d/cron', ['restart']);
|
||
}).then(function() {
|
||
ui.addNotification('Added', E('p', {}, line), 'info');
|
||
});
|
||
}).catch(function(e) {
|
||
ui.addNotification('Error', (e && e.message) ? e.message : String(e), 'danger');
|
||
});
|
||
},
|
||
|
||
cwGenerateOptions: function(start, end, labels) {
|
||
var opts = [ E('option', { value: '*', selected: true }, '*') ];
|
||
if (labels) {
|
||
for (var i = 0; i < labels.length; i++) opts.push(E('option', { value: String(start + i) }, labels[i]));
|
||
} else {
|
||
for (var j = start; j <= end; j++) opts.push(E('option', { value: String(j) }, String(j)));
|
||
}
|
||
return opts;
|
||
},
|
||
cwResetMulti: function(id) {
|
||
var sel = document.getElementById(id);
|
||
if (!sel) return;
|
||
Array.prototype.slice.call(sel.options).forEach(function(o){ o.selected = (o.value === '*'); });
|
||
},
|
||
|
||
renderCrontabWizard: function() {
|
||
var self = this;
|
||
this.cwAddStyles();
|
||
|
||
var grid = E('div', { 'style': 'display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1em;margin-bottom:1em;' });
|
||
|
||
function cwSelect(id, opts, cbId, cbLabel) {
|
||
var sel = E('select', { 'id': id, 'class': 'cron-multiselect', 'multiple': true, 'size': 8, 'change': function(){ self.cwUpdatePreview(); } }, opts);
|
||
var kids = [ sel ];
|
||
if (cbId) {
|
||
kids.push(E('div', { 'style': 'display:flex;align-items:center;gap:5px;margin-top:6px;' }, [
|
||
E('input', { 'type': 'checkbox', 'id': cbId, 'change': function(){ self.cwUpdatePreview(); } }),
|
||
E('label', { 'for': cbId, 'style': 'font-size:12px;' }, cbLabel)
|
||
]));
|
||
}
|
||
return E('div', { 'class': 'cw-section', 'style': 'padding:0;' }, [
|
||
E('div', { 'class': id.replace('cw_', 'cron-') + '-head' }, cbLabel || id),
|
||
E('div', { 'style': 'padding:8px;' }, kids)
|
||
]);
|
||
}
|
||
|
||
grid.appendChild(cwSelect('cw_minute', this.cwGenerateOptions(0, 59), 'cw_minute_cb', _('Minute')));
|
||
grid.appendChild(cwSelect('cw_hour', this.cwGenerateOptions(0, 23), 'cw_hour_cb', _('Hour')));
|
||
grid.appendChild(cwSelect('cw_day', this.cwGenerateOptions(1, 31), 'cw_day_cb', _('Day')));
|
||
grid.appendChild(cwSelect('cw_month', this.cwGenerateOptions(1, 12, this.cwMonthNames().slice(1)), null, _('Month')));
|
||
grid.appendChild(cwSelect('cw_weekday', this.cwGenerateOptions(0, 6, this.cwWeekdayNames()), null, _('Weekday')));
|
||
|
||
var root = E('div', { 'class': 'cw-root' }, [
|
||
E('h3', { 'style': 'margin:0 0 4px 0;' }, _('Graphical Crontab Configurator')),
|
||
E('p', { 'style': 'color:#888;font-size:0.9em;margin:0 0 16px 0;' }, _('Pick time units, type a command, preview the generated cron line, then add it.')),
|
||
grid,
|
||
E('div', { 'class': 'cw-section' }, [
|
||
E('label', { 'style': 'font-weight:bold;display:block;margin-bottom:5px;' }, _('Command to execute:')),
|
||
E('input', { 'id': 'cw_command', 'class': 'cbi-input-text', 'style': 'width:100%;margin-bottom:12px;', 'placeholder': 'echo hello', 'keyup': function(){ self.cwUpdatePreview(); }, 'change': function(){ self.cwUpdatePreview(); } }),
|
||
E('label', { 'style': 'font-weight:bold;display:block;margin-bottom:5px;' }, _('Preview:')),
|
||
E('div', { 'id': 'cw_preview' }),
|
||
E('label', { 'style': 'font-weight:bold;display:block;margin:12px 0 5px 0;' }, _('Description:')),
|
||
E('textarea', { 'id': 'cw_human', 'readonly': true, 'style': 'width:100%;min-height:50px;font-size:0.85em;resize:vertical;' }),
|
||
E('label', { 'style': 'font-weight:bold;display:block;margin:12px 0 5px 0;' }, _('Generated cron entry:')),
|
||
E('input', { 'id': 'cw_preview_text', 'readonly': true, 'style': 'width:100%;font-family:monospace;' }),
|
||
E('div', { 'style': 'margin-top:12px;display:flex;gap:10px;justify-content:flex-end;' }, [
|
||
E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'click': function() {
|
||
self.cwResetMulti('cw_minute'); self.cwResetMulti('cw_hour'); self.cwResetMulti('cw_day');
|
||
self.cwResetMulti('cw_month'); self.cwResetMulti('cw_weekday');
|
||
document.getElementById('cw_command').value = '';
|
||
['cw_minute_cb','cw_hour_cb','cw_day_cb'].forEach(function(id){ var e = document.getElementById(id); if (e) e.checked = false; });
|
||
self.cwUpdatePreview();
|
||
} }, _('Reset')),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action important', 'click': function(){ self.cwAppend(); } }, _('Add to Cron'))
|
||
])
|
||
])
|
||
]);
|
||
|
||
setTimeout(function(){ self.cwUpdatePreview(); }, 0);
|
||
return root;
|
||
},
|
||
|
||
renderGuestWifi: function() {
|
||
var self = this;
|
||
var gw = new GuestWifiViewClass();
|
||
return gw.load().then(function() {
|
||
return gw.render();
|
||
}).then(function(node) {
|
||
var actions = node.querySelector('.cbi-page-actions');
|
||
if (actions) actions.style.display = 'none';
|
||
var saveBtn = E('button', {
|
||
'class': 'btn cbi-button cbi-button-action important',
|
||
'click': function() {
|
||
if (!confirm('Save and apply guest WiFi configuration?')) return;
|
||
ui.addNotification('Saving', 'Applying guest WiFi configuration...', 'info');
|
||
Promise.resolve(gw.handleSave()).then(function() {
|
||
ui.addNotification('Success', 'Guest WiFi configuration saved and applied.', 'info');
|
||
}).catch(function(err) {
|
||
ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger');
|
||
});
|
||
}
|
||
}, 'Save Guest WiFi');
|
||
return E('div', {}, [node, E('div', { 'style': 'margin-top:20px;text-align:right;' }, [saveBtn])]);
|
||
});
|
||
},
|
||
|
||
renderTtyd: function() {
|
||
var self = this;
|
||
var enabled = uci.get('ttyd', 'ttyd', 'enable') !== '0';
|
||
var port = uci.get('ttyd', 'ttyd', 'port') || '7681';
|
||
var ssl = uci.get('ttyd', 'ttyd', 'ssl') || '0';
|
||
var override = uci.get('ttyd', 'ttyd', 'url_override');
|
||
// ttyd config may use an anonymous section (@ttyd[0])
|
||
var currentCmd = uci.get_first('ttyd', 'ttyd', 'command')
|
||
|| uci.get('ttyd', 'ttyd', 'command')
|
||
|| '/bin/login';
|
||
var autoLogin = (currentCmd === '/bin/sh' || currentCmd === '/bin/ash');
|
||
|
||
// --- Auto-login: status badge + explicit Enable / Disable buttons ---
|
||
function setTtydCommand(newCmd, label) {
|
||
uci.load('ttyd').then(function() {
|
||
var secs = uci.sections('ttyd', 'ttyd');
|
||
var targetSec = (secs && secs.length > 0) ? secs[0]['.name'] : null;
|
||
if (!targetSec) {
|
||
uci.set('ttyd', 'ttyd', 'ttyd');
|
||
targetSec = 'ttyd';
|
||
}
|
||
uci.set('ttyd', targetSec, 'command', newCmd);
|
||
return uci.save();
|
||
}).then(function() {
|
||
return self.callSystemCommand('/bin/sh', ['-c', '/etc/init.d/ttyd restart']);
|
||
}).then(function() {
|
||
ui.addNotification('Success', label + ' applied. ttyd restarted.', 'info');
|
||
self.switchTab('ttyd');
|
||
}).catch(function(err) {
|
||
ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger');
|
||
});
|
||
}
|
||
|
||
var loginStatusBadge = autoLogin
|
||
? E('span', { 'style': 'padding:4px 10px;border-radius:4px;background:rgba(220,53,69,0.18);color:#dc3545;font-weight:bold;font-size:0.85em;' }, '🔴 Auto-Login: ON')
|
||
: E('span', { 'style': 'padding:4px 10px;border-radius:4px;background:rgba(40,167,69,0.15);color:#28a745;font-weight:bold;font-size:0.85em;' }, '🔒 Login Required');
|
||
|
||
// Active button gets a solid background + thick border.
|
||
// Inactive button stays clickable but dimmed.
|
||
var ENABLE_ACTIVE_STYLE = 'padding:5px 14px;font-size:0.85em;font-weight:bold;background-color:#28a745 !important;background:#28a745 !important;color:#fff !important;border:2px solid #1e7e34 !important;';
|
||
var ENABLE_INACTIVE_STYLE = 'padding:5px 14px;font-size:0.85em;opacity:0.6;border:2px solid transparent;';
|
||
var DISABLE_ACTIVE_STYLE = 'padding:5px 14px;font-size:0.85em;font-weight:bold;background-color:#dc3545 !important;background:#dc3545 !important;color:#fff !important;border:2px solid #a71d2a !important;';
|
||
var DISABLE_INACTIVE_STYLE= 'padding:5px 14px;font-size:0.85em;opacity:0.6;border:2px solid transparent;';
|
||
|
||
var enableLoginBtn = E('button', {
|
||
'class': 'btn cbi-button',
|
||
'title': autoLogin ? 'Currently active' : 'Click to enable root auto-login',
|
||
'style': autoLogin ? ENABLE_ACTIVE_STYLE : ENABLE_INACTIVE_STYLE,
|
||
'click': function() {
|
||
if (autoLogin) {
|
||
ui.addNotification('Already Enabled', 'Auto-login is already active. Terminal runs ' + currentCmd + '.', 'info');
|
||
return;
|
||
}
|
||
var msg = '⚠️ WARNING: Enable root auto-login?\n\nThis changes ttyd command to /bin/sh. Anyone who can reach port ' + port + ' gets a root shell without credentials.\n\nEnsure ttyd is bound to loopback or LAN-only and your network is trusted.\n\nProceed?';
|
||
if (!confirm(msg)) return;
|
||
setTtydCommand('/bin/sh', 'Auto-login enabled');
|
||
}
|
||
}, 'Enable Auto-Login');
|
||
|
||
var disableLoginBtn = E('button', {
|
||
'class': 'btn cbi-button',
|
||
'title': !autoLogin ? 'Currently active' : 'Click to disable root auto-login',
|
||
'style': !autoLogin ? DISABLE_ACTIVE_STYLE : DISABLE_INACTIVE_STYLE,
|
||
'click': function() {
|
||
if (!autoLogin) {
|
||
ui.addNotification('Already Disabled', 'Terminal already requires login (' + currentCmd + ').', 'info');
|
||
return;
|
||
}
|
||
if (!confirm('Disable root auto-login and return to /bin/login prompt?')) return;
|
||
setTtydCommand('/bin/login', 'Auto-login disabled');
|
||
}
|
||
}, 'Disable Auto-Login');
|
||
|
||
var statusBar = E('div', { 'style': 'background:rgba(30,144,255,0.08);border:1px solid rgba(30,144,255,0.2);border-radius:8px;padding:12px;margin-bottom:14px;font-size:0.9em;' }, [
|
||
E('div', { 'style': 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;' }, [
|
||
E('strong', {}, 'Terminal (ttyd):'),
|
||
E('code', { 'style': 'background:rgba(0,0,0,0.15);padding:2px 6px;border-radius:3px;' }, currentCmd),
|
||
loginStatusBadge,
|
||
E('span', { 'style': 'flex:1;' }),
|
||
enableLoginBtn,
|
||
disableLoginBtn
|
||
])
|
||
]);
|
||
|
||
var parts = [statusBar];
|
||
|
||
if (!enabled) {
|
||
parts.push(E('div', { 'class': 'alert-message warning' }, 'ttyd is disabled. Enable it below and save.'));
|
||
} else if (port === '0') {
|
||
parts.push(E('div', { 'class': 'alert-message warning' }, 'Random ttyd port not supported. Set a fixed port below.'));
|
||
} else {
|
||
var url = override || ((ssl === '1' ? 'https' : 'http') + '://' + window.location.hostname + ':' + port);
|
||
// Mixed content check: if SSL is on but page is HTTP, iframe will be blocked
|
||
var isPageHTTPS = window.location.protocol === 'https:';
|
||
var isIframeHTTPS = (ssl === '1');
|
||
var mixedContentBlocked = (!isPageHTTPS && isIframeHTTPS);
|
||
|
||
if (mixedContentBlocked) {
|
||
parts.push(E('div', { 'class': 'alert-message warning', 'style': 'margin-bottom:12px;' }, [
|
||
E('strong', {}, '⚠️ Mixed content block: '),
|
||
E('span', {}, 'ttyd is configured with SSL but you access LuCI over HTTP. The browser will refuse to load the terminal. '),
|
||
E('span', {}, 'Either access LuCI over HTTPS, or disable SSL in the ttyd config below.')
|
||
]));
|
||
}
|
||
|
||
var iframeEl = E('iframe', {
|
||
'id': 'qa-ttyd-iframe',
|
||
'src': url,
|
||
'style': 'width:100%;min-height:60vh;border:1px solid rgba(0,0,0,0.15);border-radius:6px;resize:vertical;background:#000;'
|
||
});
|
||
parts.push(iframeEl);
|
||
|
||
// Add reconnect buttons
|
||
parts.push(E('div', { 'style': 'margin-top:10px;display:flex;gap:10px;flex-wrap:wrap;' }, [
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action', 'click': function() {
|
||
if (!confirm('Force reconnect?\n\nThis kills all ttyd sessions and restarts the service. Any open terminal in another tab will be disconnected.')) return;
|
||
ui.addNotification('Reconnect', 'Killing stale ttyd sessions...', 'info');
|
||
self.callSystemCommand('/bin/sh', ['-c', 'killall ttyd 2>/dev/null; sleep 1; /etc/init.d/ttyd restart 2>/dev/null; sleep 1']).then(function() {
|
||
var f = document.getElementById('qa-ttyd-iframe');
|
||
if (f) {
|
||
var baseSrc = f.src.split('?')[0];
|
||
f.src = 'about:blank';
|
||
setTimeout(function(){ f.src = baseSrc + '?_t=' + Date.now(); }, 300);
|
||
}
|
||
ui.addNotification('Reconnected', 'Terminal service restarted. If blank, click Reconnect again.', 'info');
|
||
}).catch(function(err) {
|
||
ui.addNotification('Error', 'Restart failed: ' + ((err && err.message) ? err.message : String(err)), 'danger');
|
||
});
|
||
} }, _('↻ Force Reconnect')),
|
||
E('a', {
|
||
'class': 'btn cbi-button cbi-button-neutral',
|
||
'href': url,
|
||
'target': '_blank',
|
||
'rel': 'noopener noreferrer',
|
||
'style': 'text-decoration:none;'
|
||
}, _('↗ Open in New Tab')),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'click': function() {
|
||
var f = document.getElementById('qa-ttyd-iframe');
|
||
if (!f) { ui.addNotification('Focus', 'Terminal iframe not found.', 'warning'); return; }
|
||
try {
|
||
// 1. Focus the iframe element itself
|
||
f.focus();
|
||
if (f.contentWindow) f.contentWindow.focus();
|
||
|
||
// 2. ttyd uses xterm.js — its hidden helper-textarea captures keys
|
||
var doc = f.contentDocument;
|
||
if (doc) {
|
||
var ta = doc.querySelector('.xterm-helper-textarea');
|
||
if (ta) { ta.focus(); }
|
||
else {
|
||
// Fallback: any input/textarea/canvas inside
|
||
var el = doc.querySelector('textarea, input, .xterm canvas, canvas');
|
||
if (el) {
|
||
el.focus();
|
||
// Simulate a click to activate xterm
|
||
try {
|
||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||
} catch(e) {}
|
||
}
|
||
}
|
||
}
|
||
ui.addNotification('Focused', 'Terminal focused. Start typing to interact.', 'info');
|
||
} catch(e) {
|
||
ui.addNotification('Focus failed', (e && e.message) ? e.message : String(e), 'warning');
|
||
}
|
||
} }, _('Focus Terminal'))
|
||
]));
|
||
|
||
// Auto-check if terminal went blank (WebSocket timeout)
|
||
// Tracked on self to prevent stacking intervals on tab re-entry
|
||
if (self._ttydWatchdogTimeout) { clearTimeout(self._ttydWatchdogTimeout); self._ttydWatchdogTimeout = null; }
|
||
if (self._ttydWatchdogInterval) { clearInterval(self._ttydWatchdogInterval); self._ttydWatchdogInterval = null; }
|
||
var lastCheck = Date.now();
|
||
self._ttydWatchdogTimeout = setTimeout(function() {
|
||
self._ttydWatchdogInterval = setInterval(function() {
|
||
var f = document.getElementById('qa-ttyd-iframe');
|
||
if (!f || !f.contentWindow) {
|
||
if (self._ttydWatchdogInterval) { clearInterval(self._ttydWatchdogInterval); self._ttydWatchdogInterval = null; }
|
||
return;
|
||
}
|
||
try {
|
||
var doc = f.contentDocument;
|
||
if (!doc) return;
|
||
var bodyText = doc.body ? doc.body.textContent : '';
|
||
var looksDead = bodyText.indexOf('to Reconnect') !== -1
|
||
|| bodyText.indexOf('Connection closed') !== -1
|
||
|| bodyText.indexOf('Disconnected') !== -1
|
||
|| (bodyText.length < 20 && doc.querySelector('canvas') === null);
|
||
if (looksDead && (Date.now() - lastCheck) > 20000) {
|
||
lastCheck = Date.now();
|
||
ui.addNotification('Terminal idle',
|
||
E('p', {}, 'The terminal session expired. Click "Reconnect Terminal" above to resume.'),
|
||
'info');
|
||
}
|
||
} catch(e) { /* cross-origin */ }
|
||
}, 15000);
|
||
}, 5000);
|
||
}
|
||
|
||
// --- Full config form via LuCI form.Map ---
|
||
var m = new form.Map('ttyd', _('ttyd Configuration'), _('All backend options. Save applies and restarts ttyd.'));
|
||
var s = m.section(form.TypedSection, 'ttyd', _('ttyd Instance'));
|
||
s.anonymous = true;
|
||
s.addremove = false;
|
||
|
||
var o;
|
||
o = s.option(form.Flag, 'enable', _('Enable'));
|
||
o.default = true;
|
||
s.option(form.Flag, 'unix_sock', _('UNIX socket'), _('Bind to UNIX domain socket instead of IP port'));
|
||
o = s.option(form.Value, 'port', _('Port'), _('Port to listen (default: 7681, use 0 for random)'));
|
||
o.depends('unix_sock', '0');
|
||
o.datatype = 'port';
|
||
o.placeholder = '7681';
|
||
o = s.option(form.Value, 'interface', _('Interface'), _('Network interface to bind (e.g. br-lan, lo)'));
|
||
o.depends('unix_sock', '0');
|
||
o = s.option(form.Value, 'credential', _('Credential'), _('Basic auth user:password'));
|
||
o.placeholder = 'username:password';
|
||
o = s.option(form.Value, 'uid', _('User ID'));
|
||
o.datatype = 'uinteger';
|
||
o = s.option(form.Value, 'gid', _('Group ID'));
|
||
o.datatype = 'uinteger';
|
||
o = s.option(form.Value, 'signal', _('Signal'), _('Signal to send to command on exit (default: 1)'));
|
||
o.datatype = 'uinteger';
|
||
s.option(form.Flag, 'url_arg', _('Allow URL args'));
|
||
s.option(form.Flag, 'readonly', _('Read-only'), _('Deny client write access to TTY'));
|
||
o = s.option(form.DynamicList, 'client_option', _('Client option'), _('Send option to client'));
|
||
o.placeholder = 'key=value';
|
||
o = s.option(form.Value, 'terminal_type', _('Terminal type'));
|
||
o.placeholder = 'xterm-256color';
|
||
s.option(form.Flag, 'check_origin', _('Check origin'), _('Block cross-origin WebSocket'));
|
||
o = s.option(form.Value, 'max_clients', _('Max clients'));
|
||
o.datatype = 'uinteger';
|
||
o.placeholder = '0';
|
||
s.option(form.Flag, 'once', _('Once'), _('Accept one client then exit'));
|
||
o = s.option(form.Value, 'index', _('Index'), _('Custom index.html path'));
|
||
s.option(form.Flag, 'ipv6', _('IPv6'));
|
||
s.option(form.Flag, 'ssl', _('SSL'));
|
||
o = s.option(form.Value, 'ssl_cert', _('SSL cert'));
|
||
o.depends('ssl', '1');
|
||
o = s.option(form.Value, 'ssl_key', _('SSL key'));
|
||
o.depends('ssl', '1');
|
||
o = s.option(form.Value, 'ssl_ca', _('SSL CA'));
|
||
o.depends('ssl', '1');
|
||
o = s.option(form.ListValue, 'debug', _('Debug'), _('Log level'));
|
||
o.value('1', _('Error'));
|
||
o.value('3', _('Warning'));
|
||
o.value('7', _('Notice'));
|
||
o.value('15', _('Info'));
|
||
o.default = '7';
|
||
o = s.option(form.Value, 'command', _('Command'), _('Command to run in the shell'));
|
||
o.placeholder = '/bin/login';
|
||
s.option(form.Value, 'url_override', _('URL override'), _('Override URL in Terminal tab (for reverse proxy)'));
|
||
|
||
return m.render().then(function(formNode) {
|
||
// Strip page actions so LuCI doesn't hijack the outer view
|
||
var actions = formNode.querySelectorAll('.cbi-page-actions');
|
||
for (var i = 0; i < actions.length; i++) {
|
||
if (actions[i].parentNode) actions[i].parentNode.removeChild(actions[i]);
|
||
}
|
||
|
||
var saveBtn = E('button', {
|
||
'class': 'btn cbi-button cbi-button-action important',
|
||
'style': 'margin-top:14px;',
|
||
'click': function() {
|
||
if (!confirm('Save ttyd config and restart the service?')) return;
|
||
ui.addNotification('Saving', 'Applying ttyd config...', 'info');
|
||
m.save().then(function() {
|
||
return self.callSystemCommand('/bin/sh', ['-c', '/etc/init.d/ttyd restart']);
|
||
}).then(function() {
|
||
ui.addNotification('Saved', 'ttyd config updated and service restarted.', 'info');
|
||
self.switchTab('ttyd');
|
||
}).catch(function(err) {
|
||
ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger');
|
||
});
|
||
}
|
||
}, _('Save & Restart ttyd'));
|
||
|
||
var cfgBox = E('details', { 'style': 'margin-top:20px;background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);border-radius:8px;padding:12px 16px;' }, [
|
||
E('summary', { 'style': 'cursor:pointer;font-weight:bold;color:#1e90ff;font-size:1em;' }, _('ttyd Configuration — full settings')),
|
||
E('div', { 'style': 'padding-top:12px;' }, [formNode, saveBtn])
|
||
]);
|
||
|
||
parts.push(cfgBox);
|
||
return E('div', {}, parts);
|
||
}).catch(function(err) {
|
||
parts.push(E('div', { 'class': 'alert-message error' }, 'Form error: ' + (err && err.message ? err.message : String(err))));
|
||
return E('div', {}, parts);
|
||
});
|
||
},
|
||
|
||
renderTaskPlanScheduled: function() {
|
||
var self = this;
|
||
return uci.load('taskplan').then(function() {
|
||
var m = new form.Map('taskplan', _('Scheduled Tasks'),
|
||
_('Scheduled and startup tasks. Presets include reboot, shutdown, network restart, memory cleanup, custom scripts.'));
|
||
|
||
var s = m.section(form.TypedSection, 'global');
|
||
s.anonymous = true;
|
||
var e = s.option(form.TextValue, 'customscript', _('Edit Custom Script'));
|
||
e.description = _('Shell commands for [Customscript] task type');
|
||
e.rows = 5;
|
||
e.optional = false;
|
||
e = s.option(form.TextValue, 'customscript2', _('Edit Custom Script2'));
|
||
e.description = _('Shell commands for [Customscript2] task type');
|
||
e.rows = 5;
|
||
e.optional = false;
|
||
|
||
var ss = m.section(form.TypedSection, 'stime', '');
|
||
ss.addremove = true;
|
||
ss.anonymous = true;
|
||
ss.sortable = true;
|
||
ss.template = 'cbi/tblsection';
|
||
var remarks = ss.option(form.Value, 'remarks', _('Remarks')); remarks.optional = false;
|
||
var enable = ss.option(form.Flag, 'enable', _('Enable')); enable.rmempty = false; enable.default = 1;
|
||
var stype = ss.option(form.ListValue, 'stype', _('Scheduled Type'));
|
||
stype.value(1, _('Scheduled Reboot'));
|
||
stype.value(2, _('Scheduled Poweroff'));
|
||
stype.value(3, _('Scheduled ReNetwork'));
|
||
stype.value(4, _('Scheduled RestartSamba'));
|
||
stype.value(5, _('Scheduled Restartwan'));
|
||
stype.value(6, _('Scheduled Closewan'));
|
||
stype.value(7, _('Scheduled Clearmem'));
|
||
stype.value(8, _('Scheduled Sysfree'));
|
||
stype.value(9, _('Scheduled DisReconn'));
|
||
stype.value(10, _('Scheduled DisRereboot'));
|
||
stype.value(11, _('Scheduled Restartmwan3'));
|
||
stype.value(13, _('Scheduled Wifiup'));
|
||
stype.value(14, _('Scheduled Wifidown'));
|
||
stype.value(12, _('Scheduled Customscript'));
|
||
stype.value(15, _('Scheduled Customscript2'));
|
||
stype.default = 1;
|
||
var month = ss.option(form.Value, 'month', _('Month(0~11)')); month.rmempty = false; month.default = '*'; month.datatype = 'string';
|
||
var week = ss.option(form.Value, 'week', _('Week Day(0~6)')); week.rmempty = true; week.default = '*'; week.datatype = 'string';
|
||
var hour = ss.option(form.Value, 'hour', _('Hour(0~23)')); hour.rmempty = false; hour.default = 0; hour.datatype = 'string';
|
||
var minute = ss.option(form.Value, 'minute', _('Minute(0~59)')); minute.rmempty = false; minute.default = 0; minute.datatype = 'string';
|
||
|
||
// Note: apply_on_parse intentionally NOT set — the Save & Apply button
|
||
// below is the sole writer. Setting apply_on_parse caused the form to
|
||
// commit immediately on open and again on Save, doubling the writes.
|
||
return m.render().then(function(formNode) {
|
||
var saveBtn = E('button', { 'class': 'btn cbi-button cbi-button-action important', 'style': 'margin-top:14px;', 'click': function() {
|
||
if (!confirm('Save scheduled tasks and apply?')) return;
|
||
ui.addNotification('Saving', 'Applying scheduled tasks...', 'info');
|
||
m.save().then(function() {
|
||
return uci.apply(10);
|
||
}).then(function() {
|
||
return self.callSystemCommand('/bin/sh', ['-c', '/etc/init.d/quickactions-taskplan start']).catch(function(){ return {}; });
|
||
}).then(function() {
|
||
ui.addNotification('Saved', 'Scheduled tasks applied.', 'info');
|
||
self.switchTab('taskplan');
|
||
}).catch(function(err) {
|
||
ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger');
|
||
});
|
||
} }, 'Save & Apply Scheduled Tasks');
|
||
return E('div', {}, [formNode, saveBtn]);
|
||
});
|
||
});
|
||
},
|
||
|
||
renderTaskPlanStartup: function() {
|
||
var self = this;
|
||
return uci.load('taskplan').then(function() {
|
||
var m = new form.Map('taskplan', _('Startup Tasks'),
|
||
_('Tasks to run after boot, with a delay in seconds.'));
|
||
|
||
var s = m.section(form.TypedSection, 'global');
|
||
s.anonymous = true;
|
||
var e = s.option(form.TextValue, 'customscript', _('Edit Custom Script')); e.rows = 5; e.optional = false;
|
||
e = s.option(form.TextValue, 'customscript2', _('Edit Custom Script2')); e.rows = 5; e.optional = false;
|
||
|
||
var ls = m.section(form.TypedSection, 'ltime', '');
|
||
ls.addremove = true; ls.anonymous = true; ls.sortable = true; ls.template = 'cbi/tblsection';
|
||
var remarks = ls.option(form.Value, 'remarks', _('Remarks')); remarks.optional = false;
|
||
var enable = ls.option(form.Flag, 'enable', _('Enable')); enable.rmempty = false; enable.default = 1;
|
||
var stype = ls.option(form.ListValue, 'stype', _('Startup Type'));
|
||
stype.value(1, _('Scheduled Reboot'));
|
||
stype.value(2, _('Scheduled Poweroff'));
|
||
stype.value(3, _('Scheduled ReNetwork'));
|
||
stype.value(4, _('Scheduled RestartSamba'));
|
||
stype.value(5, _('Scheduled Restartwan'));
|
||
stype.value(6, _('Scheduled Closewan'));
|
||
stype.value(7, _('Scheduled Clearmem'));
|
||
stype.value(8, _('Scheduled Sysfree'));
|
||
stype.value(9, _('Scheduled DisReconn'));
|
||
stype.value(10, _('Scheduled DisRereboot'));
|
||
stype.value(11, _('Scheduled Restartmwan3'));
|
||
stype.value(13, _('Scheduled Wifiup'));
|
||
stype.value(14, _('Scheduled Wifidown'));
|
||
stype.value(12, _('Scheduled Customscript'));
|
||
stype.value(15, _('Scheduled Customscript2'));
|
||
stype.default = 12;
|
||
var delay = ls.option(form.Value, 'delay', _('Delayed Start(seconds)')); delay.datatype = 'uinteger'; delay.default = 10; delay.optional = false;
|
||
|
||
// Note: apply_on_parse intentionally NOT set — the Save & Apply button
|
||
// below is the sole writer.
|
||
return m.render().then(function(formNode) {
|
||
var saveBtn = E('button', { 'class': 'btn cbi-button cbi-button-action important', 'style': 'margin-top:14px;', 'click': function() {
|
||
if (!confirm('Save startup tasks and apply?')) return;
|
||
ui.addNotification('Saving', 'Applying startup tasks...', 'info');
|
||
m.save().then(function() {
|
||
return uci.apply(10);
|
||
}).then(function() {
|
||
return self.callSystemCommand('/bin/sh', ['-c', '/etc/init.d/quickactions-taskplan start']).catch(function(){ return {}; });
|
||
}).then(function() {
|
||
ui.addNotification('Saved', 'Startup tasks applied.', 'info');
|
||
self.switchTab('taskplan');
|
||
}).catch(function(err) {
|
||
ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger');
|
||
});
|
||
} }, 'Save & Apply Startup Tasks');
|
||
return E('div', {}, [formNode, saveBtn]);
|
||
});
|
||
});
|
||
},
|
||
|
||
renderTaskPlanLog: function() {
|
||
var self = this;
|
||
var logPath = '/etc/quickactions-taskplan/taskplan.log';
|
||
var logArea = E('textarea', {
|
||
'id': 'qa-tp-log',
|
||
'readonly': 'readonly',
|
||
'wrap': 'off',
|
||
'style': 'width:100%;height:500px;font-family:monospace;font-size:12px;padding:10px;border-radius:4px;background:#1e1e1e;color:#00ff00;box-sizing:border-box;'
|
||
});
|
||
var reversed = false;
|
||
|
||
function refreshLog() {
|
||
return fs.read(logPath).catch(function() { return ''; }).then(function(content) {
|
||
if (reversed) content = content.split('\n').reverse().join('\n');
|
||
logArea.value = content;
|
||
if (!reversed) logArea.scrollTop = logArea.scrollHeight;
|
||
});
|
||
}
|
||
|
||
if (self._tpLogTimeout) { clearTimeout(self._tpLogTimeout); self._tpLogTimeout = null; }
|
||
if (self._tpLogInterval) { clearInterval(self._tpLogInterval); self._tpLogInterval = null; }
|
||
self._tpLogTimeout = setTimeout(function() {
|
||
refreshLog();
|
||
self._tpLogInterval = setInterval(function() {
|
||
if (!document.getElementById('qa-tp-log')) {
|
||
if (self._tpLogInterval) { clearInterval(self._tpLogInterval); self._tpLogInterval = null; }
|
||
return;
|
||
}
|
||
refreshLog();
|
||
}, 5000);
|
||
}, 100);
|
||
|
||
var btnClear = E('button', { 'class': 'btn cbi-button cbi-button-action', 'click': function() {
|
||
if (!confirm('Clear the task plan log?')) return;
|
||
fs.write(logPath, '').then(function() { ui.addNotification('Cleared', 'Log cleared.', 'info'); refreshLog(); }).catch(function(e) { ui.addNotification('Error', e.message, 'danger'); });
|
||
} }, _('Clear Log'));
|
||
var btnReverse = E('button', { 'class': 'btn cbi-button cbi-button-edit', 'click': function() { reversed = !reversed; refreshLog(); } }, _('Reverse Order'));
|
||
var btnDownload = E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'click': function() {
|
||
fs.read(logPath).then(function(c) {
|
||
if (!c) { ui.addNotification('Empty', 'Log is empty.', 'info'); return; }
|
||
var blob = new Blob([c], { type: 'text/plain' });
|
||
var a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'quickactions-taskplan.log';
|
||
a.click();
|
||
});
|
||
} }, _('Download Log'));
|
||
|
||
return E('div', {}, [
|
||
E('div', { 'style': 'margin-bottom:10px;display:flex;gap:8px;flex-wrap:wrap;' }, [btnClear, btnReverse, btnDownload]),
|
||
logArea
|
||
]);
|
||
},
|
||
|
||
renderTaskPlan: function() {
|
||
var self = this;
|
||
var current = 'scheduled';
|
||
var subButtons = {};
|
||
|
||
var subBar = E('div', { 'style': 'display:flex;gap:4px;border-bottom:2px solid rgba(0,0,0,0.08);margin:0 0 16px 0;' });
|
||
var content = E('div');
|
||
|
||
function showSub(id) {
|
||
current = id;
|
||
Object.keys(subButtons).forEach(function(k) {
|
||
var b = subButtons[k];
|
||
if (k === id) { b.style.borderBottomColor = '#1e90ff'; b.style.color = '#1e90ff'; b.style.fontWeight = 'bold'; }
|
||
else { b.style.borderBottomColor = 'transparent'; b.style.color = '#666'; b.style.fontWeight = '500'; }
|
||
});
|
||
content.innerHTML = '';
|
||
content.appendChild(E('p', { 'style': 'color:#888;padding:20px;' }, 'Loading ' + id + '...'));
|
||
Promise.resolve().then(function() {
|
||
if (id === 'scheduled') return self.renderTaskPlanScheduled();
|
||
if (id === 'startup') return self.renderTaskPlanStartup();
|
||
if (id === 'log') return self.renderTaskPlanLog();
|
||
}).then(function(node) {
|
||
content.innerHTML = '';
|
||
content.appendChild(node);
|
||
}).catch(function(err) {
|
||
content.innerHTML = '';
|
||
content.appendChild(E('div', { 'class': 'alert-message error' }, 'Task Plan error: ' + (err && err.message ? err.message : String(err))));
|
||
});
|
||
}
|
||
|
||
[ { id: 'scheduled', label: _('Scheduled Tasks') },
|
||
{ id: 'startup', label: _('Startup Tasks') },
|
||
{ id: 'log', label: _('Log Viewer') } ].forEach(function(t) {
|
||
var b = E('div', { 'style': 'padding:8px 18px;cursor:pointer;border-bottom:3px solid transparent;color:#666;font-weight:500;font-size:0.9em;user-select:none;', 'click': function() { showSub(t.id); } }, t.label);
|
||
subButtons[t.id] = b;
|
||
subBar.appendChild(b);
|
||
});
|
||
|
||
setTimeout(function() { showSub('scheduled'); }, 0);
|
||
return E('div', {}, [subBar, content]);
|
||
},
|
||
|
||
nsFormSave: function(changes) {
|
||
// Changes is a flat list of [config, section, option, value]
|
||
var self = this;
|
||
return self.callSystemCommand('/usr/libexec/quickactions-netwizard/backup.sh', [])
|
||
.catch(function(){ return {}; })
|
||
.then(function() {
|
||
return uci.load('network').catch(function(){});
|
||
})
|
||
.then(function() { return uci.load('firewall').catch(function(){}); })
|
||
.then(function() { return uci.load('dhcp').catch(function(){}); })
|
||
.then(function() { return uci.load('wireless').catch(function(){}); })
|
||
.then(function() { return uci.load('wizard').catch(function(){}); })
|
||
.then(function() { return uci.load('netwizard').catch(function(){}); })
|
||
.then(function() {
|
||
changes.forEach(function(c) {
|
||
try { uci.set(c[0], c[1], c[2], c[3]); } catch(e) {}
|
||
});
|
||
return uci.save();
|
||
});
|
||
},
|
||
|
||
nsShow: function(id) {
|
||
var self = this;
|
||
this.nsCurrent = id;
|
||
var btns = document.querySelectorAll('.qa-ns-sub');
|
||
for (var i = 0; i < btns.length; i++) {
|
||
var b = btns[i];
|
||
var bid = b.getAttribute('data-ns');
|
||
if (bid === id) { b.style.borderBottomColor = '#1e90ff'; b.style.color = '#1e90ff'; b.style.fontWeight = 'bold'; }
|
||
else { b.style.borderBottomColor = 'transparent'; b.style.color = '#666'; b.style.fontWeight = '500'; }
|
||
}
|
||
this.nsContent.innerHTML = '';
|
||
this.nsContent.appendChild(E('p', { 'style': 'color:#888;padding:20px;' }, 'Loading ' + id + '...'));
|
||
var builder = { mode: 'renderNsMode', wan: 'renderNsWan', wireless: 'renderNsWireless',
|
||
firmware: 'renderNsFirmware', shortcuts: 'renderNsShortcuts', advanced: 'renderNsAdvanced' }[id];
|
||
Promise.resolve(self[builder]()).then(function(node) {
|
||
self.nsContent.innerHTML = '';
|
||
self.nsContent.appendChild(node);
|
||
}).catch(function(err) {
|
||
self.nsContent.innerHTML = '';
|
||
self.nsContent.appendChild(E('div', { 'class': 'alert-message error' }, 'Error: ' + (err && err.message ? err.message : String(err))));
|
||
});
|
||
},
|
||
|
||
renderNetworkSetup: function() {
|
||
var self = this;
|
||
// Load all configs this tab touches
|
||
var pkgs = ['network', 'wireless', 'dhcp', 'firewall', 'wizard', 'netwizard', 'uhttpd', 'nginx'];
|
||
var loads = pkgs.map(function(p) { return uci.load(p).catch(function(){ return null; }); });
|
||
return Promise.all(loads).then(function() {
|
||
return self.renderNetworkSetupInner();
|
||
});
|
||
},
|
||
|
||
renderNetworkSetupInner: function() {
|
||
var self = this;
|
||
var subs = [
|
||
{ id: 'mode', label: 'Mode Selection' },
|
||
{ id: 'wan', label: 'WAN Settings' },
|
||
{ id: 'wireless', label: 'Wireless' },
|
||
{ id: 'firmware', label: 'Firmware' },
|
||
{ id: 'shortcuts', label: 'Shortcuts' },
|
||
{ id: 'advanced', label: 'Advanced' }
|
||
];
|
||
var subBar = E('div', { 'style': 'display:flex;gap:4px;border-bottom:2px solid rgba(0,0,0,0.08);margin:0 0 16px 0;flex-wrap:wrap;' });
|
||
subs.forEach(function(t) {
|
||
subBar.appendChild(E('div', {
|
||
'class': 'qa-ns-sub', 'data-ns': t.id,
|
||
'style': 'padding:8px 16px;cursor:pointer;border-bottom:3px solid transparent;color:#666;font-weight:500;font-size:0.9em;user-select:none;',
|
||
'click': function() { self.nsShow(t.id); }
|
||
}, t.label));
|
||
});
|
||
this.nsSubBar = subBar;
|
||
this.nsContent = E('div');
|
||
|
||
var intro = E('div', { 'style': 'background:rgba(30,144,255,0.08);border:1px solid rgba(30,144,255,0.2);border-radius:8px;padding:12px;margin-bottom:14px;font-size:0.9em;' }, [
|
||
E('strong', {}, 'Smart mode: '),
|
||
E('span', {}, 'Only fields you change are written. Backup taken before every apply. Aggressive rebuild available in Advanced.')
|
||
]);
|
||
|
||
setTimeout(function() { self.nsShow('mode'); }, 0);
|
||
return E('div', {}, [intro, subBar, this.nsContent]);
|
||
},
|
||
|
||
renderNsMode: function() {
|
||
var self = this;
|
||
var currentProto = uci.get('wizard', 'default', 'wan_proto');
|
||
if (!currentProto) {
|
||
// Scan all network interfaces for wan-like protos
|
||
var wanSecs = uci.sections('network', 'interface') || [];
|
||
var hasPppoe = false, hasDhcp = false, hasStatic = false, hasSiderouter = false;
|
||
wanSecs.forEach(function(s) {
|
||
var p = s.proto || '';
|
||
if (p === 'pppoe') hasPppoe = true;
|
||
else if (p === 'dhcp' || p === 'dhcpv6') hasDhcp = true;
|
||
else if (p === 'static' && s.ipaddr && s.gateway) hasSiderouter = true;
|
||
});
|
||
// Detect siderouter: lan has explicit gateway but wan is missing or auto=0
|
||
var lanGw = uci.get('network', 'lan', 'gateway');
|
||
var wanAuto = uci.get('network', 'wan', 'auto');
|
||
if (lanGw && (wanAuto === '0' || !uci.get('network', 'wan'))) {
|
||
currentProto = 'siderouter';
|
||
} else if (hasPppoe) {
|
||
currentProto = 'pppoe';
|
||
} else if (hasDhcp) {
|
||
currentProto = 'dhcp';
|
||
} else if (hasSiderouter) {
|
||
currentProto = 'siderouter';
|
||
} else {
|
||
currentProto = 'dhcp';
|
||
}
|
||
}
|
||
|
||
function card(mode, label, desc, color) {
|
||
var active = (currentProto === mode);
|
||
var c = E('div', {
|
||
'style': 'flex:1;min-width:200px;max-width:280px;border:2px solid ' + (active ? color : 'transparent') + ';border-radius:10px;padding:22px 16px;cursor:pointer;text-align:center;background:rgba(0,0,0,' + (active ? '0.08' : '0.02') + ');transition:all 0.2s;',
|
||
'onmouseover': function() { this.style.transform = 'translateY(-2px)'; },
|
||
'onmouseout': function() { this.style.transform = 'translateY(0)'; },
|
||
'click': function() {
|
||
if (!confirm('Switch mode to "' + label + '"?\n\nFinish configuration in WAN Settings.')) return;
|
||
uci.load('wizard').then(function() {
|
||
uci.set('wizard', 'default', 'wan_proto', mode);
|
||
return uci.save();
|
||
}).then(function() {
|
||
ui.addNotification('Mode set', 'Now fill in WAN Settings.', 'info');
|
||
self.nsShow('wan');
|
||
}).catch(function(err) {
|
||
ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger');
|
||
});
|
||
}
|
||
}, [
|
||
E('div', { 'style': 'font-size:2.5em;margin-bottom:8px;' }, mode === 'pppoe' ? '🛰️' : (mode === 'dhcp' ? '🔌' : '🔀')),
|
||
E('div', { 'style': 'font-weight:bold;font-size:1.1em;color:' + color + ';margin-bottom:6px;' }, label),
|
||
E('div', { 'style': 'font-size:0.85em;color:#888;line-height:1.4;' }, desc),
|
||
active ? E('div', { 'style': 'margin-top:10px;font-size:0.8em;font-weight:bold;color:' + color + ';' }, '● CURRENT') : ''
|
||
]);
|
||
return c;
|
||
}
|
||
|
||
return E('div', {}, [
|
||
E('h3', { 'style': 'margin-top:0;' }, 'Select Network Mode'),
|
||
E('p', { 'style': 'color:#888;font-size:0.9em;' }, 'Choose the mode that matches your setup.'),
|
||
E('div', { 'style': 'display:flex;gap:16px;flex-wrap:wrap;margin-top:14px;' }, [
|
||
card('dhcp', 'DHCP Client', 'Default router. WAN gets IP from upstream.', '#339af0'),
|
||
card('pppoe', 'PPPoE Dial-up', 'Fiber/DSL. Requires username & password.', '#ff6b6b'),
|
||
card('siderouter', 'Side-Router', 'Bypass gateway. LAN stays, WAN unused.', '#51cf66')
|
||
])
|
||
]);
|
||
},
|
||
|
||
renderNsWan: function() {
|
||
var self = this;
|
||
var proto = uci.get('wizard', 'default', 'wan_proto') || uci.get('network', 'wan', 'proto') || 'dhcp';
|
||
var wanIf = uci.get('network', 'wan', 'device') || uci.get('network', 'wan', 'ifname') || 'eth1';
|
||
var setlan = uci.get('wizard', 'default', 'setlan') || '0';
|
||
|
||
// Current network values
|
||
var lanIp = getCurrentLanIp();
|
||
var lanMask = uci.get('network', 'lan', 'netmask') || '255.255.255.0';
|
||
var lanGw = uci.get('network', 'lan', 'gateway') || '';
|
||
var lanProto = uci.get('network', 'lan', 'proto') || 'static';
|
||
var lanDns = uci.get('network', 'lan', 'dns');
|
||
var dhcpProto = uci.get('network', 'wan', 'proto') === 'static' ? 'static' : 'dhcp';
|
||
var wanIp = uci.get('network', 'wan', 'ipaddr') || '';
|
||
var wanMask = uci.get('network', 'wan', 'netmask') || '255.255.255.0';
|
||
var wanGw = uci.get('network', 'wan', 'gateway') || '';
|
||
var wanDns = uci.get('network', 'wan', 'dns');
|
||
var pppoeU = uci.get('network', 'wan', 'username') || '';
|
||
var pppoeP = uci.get('network', 'wan', 'password') || '';
|
||
var ipv6On = (uci.get('network', 'wan', 'ipv6') || 'auto') !== '0';
|
||
var lanDhcpOff = uci.get('dhcp', 'lan', 'ignore') === '1';
|
||
var dnsset = uci.get('wizard', 'default', 'dnsset') || '0';
|
||
var dnsTables = uci.get('wizard', 'default', 'dns_tables') || '1';
|
||
var synflood = uci.get('wizard', 'default', 'synflood') || '0';
|
||
|
||
function vRow(label, id, val, type, ph) {
|
||
return E('div', { 'style': 'margin-bottom:10px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#888;margin-bottom:4px;' }, label),
|
||
E('input', { 'id': 'ns-wan-' + id, 'type': type || 'text', 'value': val, 'placeholder': ph || '', 'style': 'width:100%;max-width:400px;padding:6px 10px;border:1px solid #ccc;border-radius:4px;font-size:0.95em;box-sizing:border-box;' })
|
||
]);
|
||
}
|
||
function fRow(label, id, val, hint) {
|
||
var cb = E('input', { 'id': 'ns-wan-' + id, 'type': 'checkbox' });
|
||
cb.checked = !!val;
|
||
return E('div', { 'style': 'margin-bottom:10px;display:flex;align-items:center;gap:8px;' }, [
|
||
cb, E('label', { 'style': 'font-size:0.9em;' }, label),
|
||
hint ? E('span', { 'style': 'font-size:0.78em;color:#888;' }, hint) : null
|
||
]);
|
||
}
|
||
function selRow(label, id, val, options) {
|
||
var s = E('select', { 'id': 'ns-wan-' + id, 'style': 'width:100%;max-width:400px;padding:6px 10px;border:1px solid #ccc;border-radius:4px;font-size:0.95em;' });
|
||
options.forEach(function(o) {
|
||
var opt = E('option', { 'value': o[0] }, o[1]);
|
||
if (o[0] === val) opt.selected = true;
|
||
s.appendChild(opt);
|
||
});
|
||
return E('div', { 'style': 'margin-bottom:10px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#888;margin-bottom:4px;' }, label), s
|
||
]);
|
||
}
|
||
function dnsRow(label, id, val) {
|
||
var v = (Array.isArray(val) ? val.join(', ') : (val || ''));
|
||
return E('div', { 'style': 'margin-bottom:10px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#888;margin-bottom:4px;' }, label),
|
||
E('input', { 'id': 'ns-wan-' + id, 'type': 'text', 'value': v, 'placeholder': '1.1.1.1, 8.8.8.8', 'style': 'width:100%;max-width:400px;padding:6px 10px;border:1px solid #ccc;border-radius:4px;font-size:0.95em;' })
|
||
]);
|
||
}
|
||
|
||
// device list
|
||
var netDevs = [];
|
||
try {
|
||
var devices = uci.sections('network', 'device') || [];
|
||
devices.forEach(function(d) { if (d.name && !/^br-|^@/.test(d.name)) netDevs.push(d.name); });
|
||
} catch(e){}
|
||
// fallback
|
||
['eth0','eth1','eth2','wan'].forEach(function(d) { if (netDevs.indexOf(d) === -1) netDevs.push(d); });
|
||
|
||
var rows = [];
|
||
|
||
rows.push(selRow('WAN Protocol / Mode', 'proto', proto, [
|
||
['dhcp', 'DHCP Client'],
|
||
['pppoe', 'PPPoE Dial-up'],
|
||
['siderouter', 'Side-Router']
|
||
]));
|
||
|
||
if (proto !== 'siderouter') {
|
||
rows.push(selRow('WAN interface device', 'iface', wanIf, netDevs.map(function(d){ return [d, d]; })));
|
||
rows.push(fRow('Add LAN port configuration (setlan)', 'setlan', setlan === '1'));
|
||
}
|
||
|
||
if (proto === 'pppoe') {
|
||
rows.push(vRow('PPPoE Username', 'pppoe_u', pppoeU));
|
||
rows.push(vRow('PPPoE Password', 'pppoe_p', pppoeP, 'password'));
|
||
}
|
||
|
||
if (proto === 'dhcp') {
|
||
rows.push(selRow('WAN IP address mode', 'dhcp_proto', dhcpProto, [
|
||
['dhcp', 'DHCP (automatic)'],
|
||
['static', 'Static IP']
|
||
]));
|
||
if (dhcpProto === 'static') {
|
||
rows.push(vRow('WAN IPv4 Address', 'wan_ip', wanIp));
|
||
rows.push(vRow('WAN IPv4 Netmask', 'wan_mask', wanMask));
|
||
rows.push(vRow('WAN IPv4 Gateway', 'wan_gw', wanGw));
|
||
}
|
||
}
|
||
|
||
if (proto === 'dhcp' || proto === 'pppoe') {
|
||
rows.push(dnsRow('WAN custom DNS (comma-separated, empty = auto)', 'wan_dns', wanDns));
|
||
}
|
||
|
||
if (proto === 'siderouter') {
|
||
rows.push(selRow('LAN IP address mode', 'lan_proto', lanProto, [
|
||
['static', 'Static IP'],
|
||
['dhcp', 'DHCP client']
|
||
]));
|
||
}
|
||
|
||
// LAN IP fields
|
||
var showLan = (proto === 'siderouter') || (proto !== 'siderouter' && setlan === '1');
|
||
if (showLan) {
|
||
rows.push(vRow('LAN IPv4 Address', 'lan_ip', lanIp));
|
||
rows.push(selRow('LAN IPv4 Netmask', 'lan_mask', lanMask, [
|
||
['255.255.255.0', '255.255.255.0 (/24)'],
|
||
['255.255.0.0', '255.255.0.0 (/16)'],
|
||
['255.0.0.0', '255.0.0.0 (/8)']
|
||
]));
|
||
if (proto === 'siderouter') {
|
||
rows.push(vRow('LAN Gateway (upstream router IP)', 'lan_gw', lanGw));
|
||
rows.push(dnsRow('LAN custom DNS (comma-separated)', 'lan_dns', lanDns));
|
||
}
|
||
}
|
||
|
||
rows.push(fRow('Enable IPv6', 'ipv6', ipv6On));
|
||
rows.push(fRow('Disable DHCP Server on LAN', 'lan_dhcp_off', lanDhcpOff,
|
||
'Recommended for siderouter mode.'));
|
||
rows.push(fRow('Enable DNS notification to clients', 'dnsset', dnsset === '1'));
|
||
if (dnsset === '1') {
|
||
rows.push(selRow('DNS to push to clients', 'dns_tables', dnsTables, [
|
||
['1', 'Use router LAN IP (default)'],
|
||
['223.5.5.5', 'Ali DNS: 223.5.5.5'],
|
||
['8.8.8.8', 'Google DNS: 8.8.8.8'],
|
||
['1.1.1.1', 'Cloudflare DNS: 1.1.1.1']
|
||
]));
|
||
}
|
||
rows.push(fRow('Enable SYN-flood defense', 'synflood', synflood === '1',
|
||
'Adds firewall.syn_flood=1, synflood_protect=1.'));
|
||
|
||
var saveBtn = E('button', { 'class': 'btn cbi-button cbi-button-action important', 'style': 'margin-top:14px;', 'click': function() {
|
||
var changes = [];
|
||
var list = [];
|
||
|
||
var get = function(id) { var e = document.getElementById('ns-wan-' + id); return e ? e.value : ''; };
|
||
var getCb = function(id) { var e = document.getElementById('ns-wan-' + id); return e && e.checked; };
|
||
|
||
var nProto = get('proto');
|
||
var nIface = get('iface');
|
||
var nSetlan = getCb('setlan') ? '1' : '0';
|
||
|
||
// Mode change
|
||
if (nProto !== proto) {
|
||
changes.push(['wizard', 'default', 'wan_proto', nProto]);
|
||
list.push('Mode → ' + nProto);
|
||
|
||
// Mode-specific network writes
|
||
if (nProto === 'siderouter') {
|
||
changes.push(['network', 'wan', 'auto', '0']);
|
||
changes.push(['firewall', 'lan', 'masq', '1']);
|
||
} else {
|
||
changes.push(['network', 'wan', 'proto', nProto]);
|
||
changes.push(['network', 'wan', 'auto', '1']);
|
||
changes.push(['firewall', 'lan', 'masq', '0']);
|
||
}
|
||
}
|
||
|
||
if (nProto !== 'siderouter' && nIface && nIface !== wanIf) {
|
||
changes.push(['network', 'wan', 'device', nIface]);
|
||
list.push('WAN device → ' + nIface);
|
||
}
|
||
|
||
if (nSetlan !== setlan) {
|
||
changes.push(['wizard', 'default', 'setlan', nSetlan]);
|
||
list.push('setlan → ' + nSetlan);
|
||
}
|
||
|
||
if (nProto === 'pppoe') {
|
||
var nu = get('pppoe_u'), np = get('pppoe_p');
|
||
if (nu !== pppoeU) { changes.push(['network', 'wan', 'username', nu]); list.push('PPPoE username'); }
|
||
if (np !== pppoeP) { changes.push(['network', 'wan', 'password', np]); list.push('PPPoE password'); }
|
||
}
|
||
|
||
if (nProto === 'dhcp') {
|
||
var ndp = get('dhcp_proto');
|
||
if (ndp === 'static') {
|
||
if (get('wan_ip')) { changes.push(['network', 'wan', 'ipaddr', get('wan_ip')]); list.push('WAN IP → ' + get('wan_ip')); }
|
||
if (get('wan_mask')) { changes.push(['network', 'wan', 'netmask', get('wan_mask')]); list.push('WAN mask'); }
|
||
if (get('wan_gw')) { changes.push(['network', 'wan', 'gateway', get('wan_gw')]); list.push('WAN GW'); }
|
||
changes.push(['network', 'wan', 'proto', 'static']);
|
||
}
|
||
}
|
||
|
||
if (nProto === 'dhcp' || nProto === 'pppoe') {
|
||
var wdns = get('wan_dns').split(',').map(function(s){ return s.trim(); }).filter(Boolean);
|
||
if (wdns.length) { changes.push(['network', 'wan', 'dns', wdns]); list.push('WAN DNS set'); }
|
||
}
|
||
|
||
// LAN fields (recompute showLan from the NEW values, not render-time state)
|
||
var nShowLan = (nProto === 'siderouter') || (nProto !== 'siderouter' && nSetlan === '1');
|
||
if (nShowLan) {
|
||
var nLanIp = get('lan_ip');
|
||
var nLanMask = get('lan_mask');
|
||
if (nLanIp && nLanIp !== lanIp) { changes.push(['network', 'lan', 'ipaddr', nLanIp]); list.push('LAN IP → ' + nLanIp); }
|
||
if (nLanMask && nLanMask !== lanMask) { changes.push(['network', 'lan', 'netmask', nLanMask]); list.push('LAN mask → ' + nLanMask); }
|
||
if (nProto === 'siderouter') {
|
||
var nGw = get('lan_gw');
|
||
if (nGw !== lanGw) { changes.push(['network', 'lan', 'gateway', nGw || '']); list.push('LAN GW → ' + (nGw || '(none)')); }
|
||
var ldns = get('lan_dns').split(',').map(function(s){ return s.trim(); }).filter(Boolean);
|
||
changes.push(['network', 'lan', 'dns', ldns]);
|
||
if (ldns.length) list.push('LAN DNS set');
|
||
|
||
var nlp = get('lan_proto');
|
||
if (nlp !== lanProto) { changes.push(['network', 'lan', 'proto', nlp]); list.push('LAN proto → ' + nlp); }
|
||
}
|
||
}
|
||
|
||
var nIpv6 = getCb('ipv6');
|
||
if (nIpv6 !== ipv6On) {
|
||
changes.push(['network', 'wan', 'ipv6', nIpv6 ? 'auto' : '0']);
|
||
list.push('IPv6 → ' + (nIpv6 ? 'on' : 'off'));
|
||
}
|
||
|
||
var nDhcpOff = getCb('lan_dhcp_off');
|
||
if (nDhcpOff !== lanDhcpOff) {
|
||
changes.push(['dhcp', 'lan', 'ignore', nDhcpOff ? '1' : '0']);
|
||
list.push('LAN DHCP → ' + (nDhcpOff ? 'off' : 'on'));
|
||
}
|
||
|
||
var nDnsset = getCb('dnsset') ? '1' : '0';
|
||
if (nDnsset !== dnsset) {
|
||
changes.push(['wizard', 'default', 'dnsset', nDnsset]);
|
||
list.push('DNS notify → ' + nDnsset);
|
||
}
|
||
if (nDnsset === '1') {
|
||
var ndt = get('dns_tables');
|
||
if (ndt !== dnsTables) { changes.push(['wizard', 'default', 'dns_tables', ndt]); list.push('DNS push → ' + ndt); }
|
||
}
|
||
|
||
var nSf = getCb('synflood') ? '1' : '0';
|
||
if (nSf !== synflood) {
|
||
changes.push(['wizard', 'default', 'synflood', nSf]);
|
||
if (nSf === '1') { changes.push(['firewall', '@defaults[0]', 'syn_flood', '1']); changes.push(['firewall', '@defaults[0]', 'synflood_protect', '1']); }
|
||
else { changes.push(['firewall', '@defaults[0]', 'syn_flood', '0']); changes.push(['firewall', '@defaults[0]', 'synflood_protect', '0']); }
|
||
list.push('SYN-flood → ' + nSf);
|
||
}
|
||
|
||
if (list.length === 0) { ui.addNotification('No changes', 'Nothing to save.', 'info'); return; }
|
||
if (!confirm('Save WAN changes?\n\n• ' + list.join('\n• ') + '\n\nBackup will be taken first.')) return;
|
||
|
||
ui.addNotification('Saving', list.length + ' change(s) applying...', 'info');
|
||
var lanIpChanged = (nShowLan && get('lan_ip') && get('lan_ip') !== lanIp);
|
||
|
||
self.nsFormSave(changes).then(function() {
|
||
return self.callSystemCommand('/bin/sh', ['-c',
|
||
'/etc/init.d/network reload; /etc/init.d/dhcp reload; /etc/init.d/firewall reload'
|
||
]).catch(function(){ return {}; });
|
||
}).then(function() {
|
||
if (lanIpChanged) {
|
||
// Redirect modal (matches wizard source)
|
||
var newIp = get('lan_ip');
|
||
var sec = 15;
|
||
var countSpan = E('strong', {}, String(sec));
|
||
ui.showModal(_('LAN IP Address Changed'), [
|
||
E('p', {}, ['New LAN IP: ', E('strong', {}, newIp)]),
|
||
E('p', {}, ['Redirecting in ', countSpan, ' seconds...']),
|
||
E('div', { 'style': 'margin-top:10px;text-align:right;' }, [
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action', 'click': function() { window.location.href = window.location.protocol + '//' + newIp + '/cgi-bin/luci/'; } }, 'Redirect Now')
|
||
])
|
||
]);
|
||
var timer = setInterval(function() {
|
||
sec--;
|
||
countSpan.textContent = String(sec);
|
||
if (sec <= 0) { clearInterval(timer); window.location.href = window.location.protocol + '//' + newIp + '/cgi-bin/luci/'; }
|
||
}, 1000);
|
||
} else {
|
||
ui.addNotification('Saved', 'Network settings updated.', 'info');
|
||
self.nsShow('wan');
|
||
}
|
||
}).catch(function(err) {
|
||
ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger');
|
||
});
|
||
} }, _('Save WAN Settings (smart diff)'));
|
||
|
||
return E('div', {}, [
|
||
E('h3', { 'style': 'margin-top:0;' }, 'WAN Settings — ' + proto),
|
||
E('div', { 'style': 'background:rgba(0,0,0,0.03);border-radius:6px;padding:10px;margin-bottom:14px;font-size:0.85em;' }, [
|
||
E('div', {}, ['Current mode: ', E('strong', {}, proto)]),
|
||
E('div', {}, ['WAN device: ', E('code', {}, wanIf)]),
|
||
E('div', {}, ['LAN IP: ', E('code', {}, lanIp + '/' + lanMask)])
|
||
]),
|
||
E('div', {}, rows),
|
||
saveBtn
|
||
]);
|
||
},
|
||
|
||
renderNsWireless: function() {
|
||
var self = this;
|
||
var devices = uci.sections('wireless', 'wifi-device') || [];
|
||
if (devices.length === 0) return E('div', { 'class': 'alert-message warning' }, 'No wireless devices detected.');
|
||
|
||
var ifaces = uci.sections('wireless', 'wifi-iface') || [];
|
||
var apIfaces = ifaces.filter(function(i){ return i.mode === 'ap' || !i.mode; });
|
||
if (apIfaces.length === 0) return E('div', { 'class': 'alert-message warning' }, 'No AP wireless interfaces found.');
|
||
|
||
// Get base SSID (strip band suffix from first AP)
|
||
var rawSsid = apIfaces[0].ssid || '';
|
||
var baseSsid = rawSsid.replace(/_(2\.4G|5G|6G)$/i, '').trim();
|
||
var key = apIfaces[0].key || '';
|
||
var enc = apIfaces[0].encryption || 'psk2';
|
||
|
||
var ssidIn = E('input', { 'id': 'ns-wl-ssid', 'type': 'text', 'value': baseSsid, 'style': 'width:100%;max-width:400px;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
var keyIn = E('input', { 'id': 'ns-wl-key', 'type': 'password', 'value': key, 'style': 'width:100%;max-width:400px;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
var encSel = E('select', { 'id': 'ns-wl-enc', 'style': 'width:100%;max-width:400px;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
[['psk2','WPA2-PSK'],['sae','WPA3-SAE'],['sae-mixed','WPA2/WPA3 Mixed'],['psk','WPA-PSK'],['none','Open']].forEach(function(o) {
|
||
var opt = E('option', { 'value': o[0] }, o[1]);
|
||
if (o[0] === enc) opt.selected = true;
|
||
encSel.appendChild(opt);
|
||
});
|
||
|
||
var bandInfo = apIfaces.map(function(i) {
|
||
var dev = devices.filter(function(d){ return d['.name'] === i.device; })[0];
|
||
var band = '2.4G';
|
||
if (dev) {
|
||
if (dev.band === '5g') band = '5G';
|
||
else if (dev.band === '6g') band = '6G';
|
||
else if (/^11a/.test(dev.hwmode || '') && !/^11ax/.test(dev.hwmode || '')) band = '5G';
|
||
}
|
||
return { iface: i['.name'], device: i.device, band: band };
|
||
});
|
||
|
||
// Detect if APs currently have DIFFERENT passwords — saving would squash them all
|
||
var distinctKeys = {};
|
||
apIfaces.forEach(function(i) { if (i.key) distinctKeys[i.key] = true; });
|
||
var keysDiffer = Object.keys(distinctKeys).length > 1;
|
||
|
||
return E('div', {}, [
|
||
E('h3', { 'style': 'margin-top:0;' }, 'Wireless Settings'),
|
||
E('p', { 'style': 'color:#888;font-size:0.9em;' }, 'Base SSID will auto-append _2.4G, _5G, _6G per radio.'),
|
||
E('div', { 'style': 'margin-bottom:12px;font-size:0.85em;' }, bandInfo.map(function(b) {
|
||
return E('div', {}, [b.device + ' (' + b.band + ') → AP: ', E('code', {}, b.iface)]);
|
||
})),
|
||
E('div', { 'style': 'margin-bottom:12px;' }, [E('label', { 'style': 'display:block;font-size:0.9em;color:#888;margin-bottom:4px;' }, 'Base SSID'), ssidIn]),
|
||
E('div', { 'style': 'margin-bottom:12px;' }, [E('label', { 'style': 'display:block;font-size:0.9em;color:#888;margin-bottom:4px;' }, 'Encryption'), encSel]),
|
||
E('div', { 'style': 'margin-bottom:12px;' }, [E('label', { 'style': 'display:block;font-size:0.9em;color:#888;margin-bottom:4px;' }, 'Password'), keyIn]),
|
||
keysDiffer ? E('div', { 'class': 'alert-message warning', 'style': 'margin-bottom:12px;' },
|
||
_('Your APs currently have different passwords. Saving will overwrite every AP with the password entered above.')) : null,
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action important', 'style': 'margin-top:8px;', 'click': function() {
|
||
var ns = document.getElementById('ns-wl-ssid').value.trim();
|
||
var nk = document.getElementById('ns-wl-key').value;
|
||
var ne = document.getElementById('ns-wl-enc').value;
|
||
if (!ns) { ui.addNotification('Error', 'SSID required.', 'danger'); return; }
|
||
if (ne !== 'none' && nk && nk.length < 8) { ui.addNotification('Error', 'Password must be ≥ 8 chars.', 'danger'); return; }
|
||
if (keysDiffer && nk && !confirm('WARNING: APs currently have DIFFERENT passwords. Saving will overwrite all of them with the new one.\n\nContinue?')) return;
|
||
if (!confirm('Apply wireless changes? SSID: ' + ns + ', encryption: ' + ne)) return;
|
||
|
||
var changes = [];
|
||
bandInfo.forEach(function(b) {
|
||
var newSsid = ns + '_' + b.band;
|
||
changes.push(['wireless', b.iface, 'ssid', newSsid]);
|
||
changes.push(['wireless', b.iface, 'encryption', ne]);
|
||
if (ne !== 'none' && nk) changes.push(['wireless', b.iface, 'key', nk]);
|
||
changes.push(['wireless', b.iface, 'disabled', '0']);
|
||
});
|
||
// Also update wizard store
|
||
changes.push(['wizard', 'default', 'wifi_ssid', ns]);
|
||
changes.push(['wizard', 'default', 'wifi_key', nk]);
|
||
|
||
self.nsFormSave(changes).then(function() {
|
||
return self.callSystemCommand('/bin/sh', ['-c', '/sbin/wifi reload']);
|
||
}).then(function() {
|
||
ui.addNotification('Saved', 'Wireless updated.', 'info');
|
||
self.nsShow('wireless');
|
||
}).catch(function(err) { ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger'); });
|
||
} }, _('Save Wireless'))
|
||
]);
|
||
},
|
||
|
||
renderNsFirmware: function() {
|
||
var self = this;
|
||
var cur = {
|
||
autoupgrade_fm: uci.get('wizard', 'default', 'autoupgrade_fm') || '1',
|
||
coremark: uci.get('wizard', 'default', 'coremark') || '0',
|
||
cookie_p: uci.get('wizard', 'default', 'persistent_cookies') || '1',
|
||
https: uci.get('wizard', 'default', 'https') || '0',
|
||
landing_page: uci.get('wizard', 'default', 'landing_page') || 'default'
|
||
};
|
||
function fRow(label, id, val, hint) {
|
||
var cb = E('input', { 'id': 'ns-fw-' + id, 'type': 'checkbox' });
|
||
cb.checked = (val === '1');
|
||
return E('div', { 'style': 'margin-bottom:12px;display:flex;align-items:center;gap:8px;' }, [
|
||
cb, E('label', { 'style': 'font-size:0.9em;' }, label),
|
||
hint ? E('span', { 'style': 'font-size:0.78em;color:#888;' }, hint) : null
|
||
]);
|
||
}
|
||
var landingSel = E('select', { 'id': 'ns-fw-landing', 'style': 'width:100%;max-width:400px;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
[['default','Default'],['routerdog','RouterDog'],['nas','NAS'],['next-nas','Next-NAS'],['router','Router']].forEach(function(o) {
|
||
var opt = E('option', { 'value': o[0] }, o[1]);
|
||
if (o[0] === cur.landing_page) opt.selected = true;
|
||
landingSel.appendChild(opt);
|
||
});
|
||
|
||
return E('div', {}, [
|
||
E('h3', { 'style': 'margin-top:0;' }, 'Firmware & System Settings'),
|
||
fRow('Firmware upgrade notices', 'autoupgrade', cur.autoupgrade_fm),
|
||
fRow('Run CoreMark on boot', 'coremark', cur.coremark, 'Runs once into /etc/bench.log.'),
|
||
fRow('Persistent login cookies', 'cookie', cur.cookie_p),
|
||
fRow('Enforce HTTPS redirect', 'https', cur.https, 'Requires uhttpd SSL or nginx.'),
|
||
E('div', { 'style': 'margin-bottom:12px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#888;margin-bottom:4px;' }, 'Landing page'), landingSel
|
||
]),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action important', 'style': 'margin-top:8px;', 'click': function() {
|
||
var changes = [];
|
||
var list = [];
|
||
var na = document.getElementById('ns-fw-autoupgrade').checked ? '1' : '0';
|
||
var nc = document.getElementById('ns-fw-coremark').checked ? '1' : '0';
|
||
var np = document.getElementById('ns-fw-cookie').checked ? '1' : '0';
|
||
var nh = document.getElementById('ns-fw-https').checked ? '1' : '0';
|
||
var nl = document.getElementById('ns-fw-landing').value;
|
||
if (na !== cur.autoupgrade_fm) { changes.push(['wizard', 'default', 'autoupgrade_fm', na]); list.push('upgrade notices → ' + na); }
|
||
if (nc !== cur.coremark) { changes.push(['wizard', 'default', 'coremark', nc]); list.push('coremark → ' + nc); }
|
||
if (np !== cur.cookie_p) { changes.push(['wizard', 'default', 'persistent_cookies', np]); list.push('cookies → ' + np); }
|
||
if (nh !== cur.https) { changes.push(['wizard', 'default', 'https', nh]); list.push('https → ' + nh); }
|
||
if (nl !== cur.landing_page) { changes.push(['wizard', 'default', 'landing_page', nl]); list.push('landing → ' + nl); }
|
||
if (list.length === 0) { ui.addNotification('No changes', 'Nothing to save.', 'info'); return; }
|
||
if (!confirm('Save firmware settings?\n\n• ' + list.join('\n• '))) return;
|
||
self.nsFormSave(changes).then(function() {
|
||
// Re-run init to pick up cookie/https changes
|
||
return self.callSystemCommand('/bin/sh', ['-c', '/etc/init.d/quickactions-netwizard restart']).catch(function(){ return {}; });
|
||
}).then(function() {
|
||
ui.addNotification('Saved', 'Firmware settings updated.', 'info');
|
||
}).catch(function(err) { ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger'); });
|
||
} }, _('Save Firmware Settings'))
|
||
]);
|
||
},
|
||
|
||
renderNsShortcuts: function() {
|
||
var self = this;
|
||
return fs.stat('/etc/config/nginx').then(function() {
|
||
return uci.load('wizard').catch(function(){}).then(function() {
|
||
var secs = uci.sections('wizard', 'shortcuts') || [];
|
||
var grid = E('div', { 'style': 'display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px;margin-top:12px;' });
|
||
secs.forEach(function(s) {
|
||
var sid = s['.name'];
|
||
var label = s.shortcut || sid;
|
||
var url = s.to_url || '';
|
||
grid.appendChild(E('div', { 'style': 'background:rgba(0,0,0,0.03);border-radius:6px;padding:12px;' }, [
|
||
E('div', { 'style': 'font-weight:bold;margin-bottom:4px;' }, label + '/'),
|
||
E('div', { 'style': 'font-size:0.85em;color:#888;word-break:break-all;margin-bottom:8px;' }, url),
|
||
E('button', { 'class': 'btn cbi-button', 'style': 'padding:2px 10px;font-size:0.85em;', 'click': function() {
|
||
if (!confirm('Delete shortcut "' + label + '"?')) return;
|
||
uci.load('wizard').then(function() { uci.remove('wizard', sid); return uci.save(); })
|
||
.then(function() { return self.callSystemCommand('/usr/libexec/quickactions-netwizard/apply-shortcuts.sh', []).catch(function(){ return {}; }); })
|
||
.then(function() { ui.addNotification('Deleted', label, 'info'); self.nsShow('shortcuts'); });
|
||
} }, 'Delete')
|
||
]));
|
||
});
|
||
if (secs.length === 0) grid.appendChild(E('p', { 'style': 'color:#888;' }, 'No shortcuts yet.'));
|
||
|
||
var scIn = E('input', { 'id': 'ns-sc-key', 'placeholder': 'g', 'style': 'padding:6px 10px;border:1px solid #ccc;border-radius:4px;width:120px;' });
|
||
var urlIn = E('input', { 'id': 'ns-sc-url', 'placeholder': 'https://google.com', 'style': 'padding:6px 10px;border:1px solid #ccc;border-radius:4px;width:280px;' });
|
||
|
||
return E('div', {}, [
|
||
E('h3', { 'style': 'margin-top:0;' }, 'Shortcuts (nginx + dnsmasq)'),
|
||
E('p', { 'style': 'color:#888;font-size:0.9em;' }, 'Type shortcut + "/" in any browser on this network → redirects.'),
|
||
grid,
|
||
E('div', { 'style': 'margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;' }, [
|
||
scIn, E('span', {}, '→'), urlIn,
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action', 'click': function() {
|
||
var k = document.getElementById('ns-sc-key').value.trim();
|
||
var u = document.getElementById('ns-sc-url').value.trim();
|
||
if (!k || !/^[a-zA-Z0-9_-]+$/.test(k)) { ui.addNotification('Error', 'Shortcut must be alphanumeric.', 'danger'); return; }
|
||
if (!u || !/^https?:\/\//i.test(u)) { ui.addNotification('Error', 'URL must start with http(s)://', 'danger'); return; }
|
||
uci.load('wizard').then(function() {
|
||
var sid = 'sc_' + k + '_' + Date.now();
|
||
uci.add('wizard', 'shortcuts', sid);
|
||
uci.set('wizard', sid, 'shortcut', k);
|
||
uci.set('wizard', sid, 'to_url', u);
|
||
return uci.save();
|
||
}).then(function() {
|
||
return self.callSystemCommand('/usr/libexec/quickactions-netwizard/apply-shortcuts.sh', []).catch(function(){ return {}; });
|
||
}).then(function() {
|
||
ui.addNotification('Added', k + ' → ' + u, 'info');
|
||
self.nsShow('shortcuts');
|
||
}).catch(function(err) { ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger'); });
|
||
} }, 'Add')
|
||
])
|
||
]);
|
||
});
|
||
}).catch(function() {
|
||
return E('div', { 'class': 'alert-message notice' }, 'Shortcuts require nginx. Install nginx package to enable.');
|
||
});
|
||
},
|
||
|
||
renderNsAdvanced: function() {
|
||
var self = this;
|
||
var aggressiveMode = false;
|
||
|
||
var backupBtn = E('button', { 'class': 'btn cbi-button cbi-button-action', 'click': function() {
|
||
ui.addNotification('Backing up', 'Creating backup...', 'info');
|
||
self.callSystemCommand('/usr/libexec/quickactions-netwizard/backup.sh', []).then(function(r) {
|
||
var out = (r.stdout || '') + (r.stderr ? '\n' + r.stderr : '');
|
||
ui.addNotification('Backup complete', E('pre', { 'style': 'text-align:left;white-space:pre-wrap;background:#1e1e1e;color:#00ff00;padding:10px;border-radius:4px;font-size:0.85em;' }, out), 'info');
|
||
}).catch(function(err) { ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger'); });
|
||
} }, 'Backup Now');
|
||
|
||
var listBtn = E('button', { 'class': 'btn cbi-button', 'click': function() {
|
||
self.callSystemCommand('/usr/libexec/quickactions-netwizard/list-backups.sh', []).then(function(r) {
|
||
var out = (r.stdout || '(none)').trim();
|
||
var ul = E('div', { 'style': 'margin-top:10px;' });
|
||
if (!out) ul.appendChild(E('div', { 'style': 'color:#888;' }, 'No backups yet.'));
|
||
out.split('\n').forEach(function(line) {
|
||
if (!line.trim()) return;
|
||
ul.appendChild(E('div', { 'style': 'display:flex;gap:10px;align-items:center;padding:6px;border-bottom:1px solid rgba(0,0,0,0.06);' }, [
|
||
E('code', { 'style': 'flex:1;font-size:0.85em;' }, line),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action', 'style': 'padding:2px 10px;font-size:0.85em;', 'click': function() {
|
||
if (!confirm('Restore from ' + line + '?\n\nCurrent config will be saved as pre-restore backup first.')) return;
|
||
self.callSystemCommand('/usr/libexec/quickactions-netwizard/restore.sh', [line]).then(function(rr) {
|
||
ui.addNotification('Restored', E('pre', { 'style': 'text-align:left;white-space:pre-wrap;' }, (rr.stdout || '') + (rr.stderr ? '\n' + rr.stderr : '')), 'info');
|
||
});
|
||
} }, 'Restore')
|
||
]));
|
||
});
|
||
ui.addNotification('Backups', ul, 'info');
|
||
});
|
||
} }, 'List Backups');
|
||
|
||
var aggressiveSection = E('div', { 'style': 'margin-top:20px;padding:14px;border:1px solid rgba(220,53,69,0.4);border-radius:8px;background:rgba(220,53,69,0.06);' }, [
|
||
E('h4', { 'style': 'margin:0 0 8px 0;color:#dc3545;' }, '⚠️ Aggressive Mode (opt-in)'),
|
||
E('p', { 'style': 'font-size:0.9em;margin:0 0 12px 0;' }, 'Matches the original wizard: DELETES and RECREATES WAN, wan6, lan6, and the WAN firewall zone. Custom WAN routes / WAN firewall rules / WAN forwarding rules will be lost. A backup is taken automatically.'),
|
||
E('label', { 'style': 'display:flex;align-items:center;gap:8px;cursor:pointer;' }, [
|
||
E('input', { 'type': 'checkbox', 'id': 'ns-adv-agg', 'change': function() { aggressiveMode = this.checked; } }),
|
||
E('strong', {}, 'I understand — rebuild my network config from scratch')
|
||
]),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action important', 'style': 'margin-top:12px;background-color:#dc3545;', 'click': function() {
|
||
if (!aggressiveMode) { ui.addNotification('Error', 'Check the box above first.', 'danger'); return; }
|
||
var proto = uci.get('wizard', 'default', 'wan_proto') || 'dhcp';
|
||
var lanIp = getCurrentLanIp();
|
||
var pu = uci.get('network', 'wan', 'username') || '';
|
||
var pp = uci.get('network', 'wan', 'password') || '';
|
||
if (!confirm('AGGRESSIVE REBUILD\n\nMode: ' + proto + '\nLAN: ' + lanIp + '\nPPPoE user: ' + (pu || '(none)') + '\n\nBackup first. Proceed?')) return;
|
||
ui.addNotification('Running', 'Aggressive apply in progress...', 'info');
|
||
self.callSystemCommand('/usr/libexec/quickactions-netwizard/apply-aggressive.sh', [proto, lanIp, pu, pp]).then(function(r) {
|
||
var out = (r.stdout || '') + (r.stderr ? '\n' + r.stderr : '');
|
||
ui.addNotification('Complete', E('pre', { 'style': 'text-align:left;white-space:pre-wrap;background:#1e1e1e;color:#00ff00;padding:10px;border-radius:4px;font-size:0.85em;' }, out), 'info');
|
||
}).catch(function(err) {
|
||
ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger');
|
||
});
|
||
} }, 'Apply Aggressive Rebuild')
|
||
]);
|
||
|
||
return E('div', {}, [
|
||
E('h3', { 'style': 'margin-top:0;' }, 'Advanced'),
|
||
E('p', { 'style': 'color:#888;font-size:0.9em;' }, 'Backups, restore, opt-in aggressive rebuild.'),
|
||
E('div', { 'style': 'display:flex;gap:10px;flex-wrap:wrap;' }, [backupBtn, listBtn]),
|
||
aggressiveSection
|
||
]);
|
||
},
|
||
|
||
renderScripts: function() {
|
||
var self = this;
|
||
|
||
var rpcList = rpc.declare({
|
||
object: 'luci.quickactions-scripts', method: 'list', expect: { '': {} }
|
||
});
|
||
var rpcRead = rpc.declare({
|
||
object: 'luci.quickactions-scripts', method: 'read',
|
||
params: ['sid'], expect: { '': {} }
|
||
});
|
||
var rpcWrite = rpc.declare({
|
||
object: 'luci.quickactions-scripts', method: 'write',
|
||
params: ['sid','name','category','content','args','timeout','enabled'], expect: { '': {} }
|
||
});
|
||
var rpcDelete = rpc.declare({
|
||
object: 'luci.quickactions-scripts', method: 'delete',
|
||
params: ['sid'], expect: { '': {} }
|
||
});
|
||
var rpcRun = rpc.declare({
|
||
object: 'luci.quickactions-scripts', method: 'run',
|
||
params: ['sid'], expect: { '': {} }
|
||
});
|
||
var rpcChmod = rpc.declare({
|
||
object: 'luci.quickactions-scripts', method: 'chmod',
|
||
params: ['sid','mode'], expect: { '': {} }
|
||
});
|
||
var rpcPreset = rpc.declare({
|
||
object: 'luci.quickactions-scripts', method: 'install_preset',
|
||
params: ['preset'], expect: { '': {} }
|
||
});
|
||
var rpcDetail = rpc.declare({
|
||
object: 'luci.quickactions-scripts', method: 'detail',
|
||
params: ['sid'], expect: { '': {} }
|
||
});
|
||
|
||
function catIcon(c) {
|
||
return ({ 'failover': '🔁', 'network': '🌐', 'system': '⚙️',
|
||
'iot': '📡', 'custom': '📜' })[c] || '📜';
|
||
}
|
||
|
||
// ---- Table ----
|
||
var tableWrap = E('div', { 'id': 'qa-scripts-table', 'style': 'overflow-x:auto;' });
|
||
|
||
function refreshTable() {
|
||
tableWrap.innerHTML = '';
|
||
tableWrap.appendChild(E('p', { 'style': 'color:#888;padding:20px;' }, _('Loading scripts...')));
|
||
rpcList().then(function(r) {
|
||
var scripts = (r && r.scripts) || [];
|
||
tableWrap.innerHTML = '';
|
||
|
||
var addBar = E('div', { 'style': 'display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;' }, [
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action important',
|
||
'click': function() { openEditor(null); } }, _('+ Create new script')),
|
||
E('button', { 'class': 'btn cbi-button',
|
||
'click': function() {
|
||
if (!confirm(_('Install Failover-lite preset? Adds an editable MWAN3-substitute script.'))) return;
|
||
rpcPreset('failover').then(function(res) {
|
||
ui.addNotification(null, E('p', {}, _('Preset installed: ') + (res.sid || '')), 'info');
|
||
refreshTable();
|
||
}).catch(function(err) {
|
||
ui.addNotification(null, E('p', {}, 'Failed: ' + (err.message || err)), 'danger');
|
||
});
|
||
} }, _('Install Failover-lite preset')),
|
||
E('button', { 'class': 'btn cbi-button',
|
||
'click': function() {
|
||
ui.showModal(_('Scripts — help'), [
|
||
E('div', { 'style': 'line-height:1.6;font-size:0.92em;max-width:640px;' }, [
|
||
E('p', {}, _('Scripts live in /etc/quickactions/scripts/. Each script is a normal shell file.')),
|
||
E('ul', {}, [
|
||
E('li', {}, _('Name — display name.')),
|
||
E('li', {}, _('Category — failover / network / system / iot / custom (icon only).')),
|
||
E('li', {}, _('Timeout — seconds before the script is force-killed.')),
|
||
E('li', {}, _('Arguments — passed as $1 $2 on Run.')),
|
||
E('li', {}, _('Script body — the actual shell script.'))
|
||
]),
|
||
E('p', {}, _('Use "Install Failover-lite preset" for an MWAN3 substitute on low-RAM routers.')),
|
||
E('p', { 'style': 'color:#c00;' }, _('Scripts execute as root. Only run scripts you trust.'))
|
||
]),
|
||
E('div', { 'style': 'margin-top:14px;text-align:right;' }, [
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action',
|
||
'click': function() {
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
} }, _('Close'))
|
||
])
|
||
]);
|
||
} }, _('❔ Help'))
|
||
]);
|
||
tableWrap.appendChild(addBar);
|
||
|
||
if (!scripts.length) {
|
||
tableWrap.appendChild(E('p', { 'style': 'color:#888;padding:14px;' },
|
||
_('No scripts yet.')));
|
||
return;
|
||
}
|
||
|
||
var table = E('table', { 'style': 'width:100%;border-collapse:collapse;font-size:0.9em;' });
|
||
table.appendChild(E('tr', { 'style': 'text-align:left;border-bottom:2px solid rgba(0,0,0,0.1);' }, [
|
||
E('th', { 'style': 'padding:8px;' }, _('Name')),
|
||
E('th', { 'style': 'padding:8px;' }, _('Category')),
|
||
E('th', { 'style': 'padding:8px;text-align:center;' }, _('Enabled')),
|
||
E('th', { 'style': 'padding:8px;text-align:right;' }, _('Size')),
|
||
E('th', { 'style': 'padding:8px;text-align:right;' }, _('Actions'))
|
||
]));
|
||
|
||
scripts.forEach(function(s) {
|
||
var sid = s.sid;
|
||
var en = String(s.enabled) === '1';
|
||
var toggle = E('input', { 'type': 'checkbox' });
|
||
if (en) toggle.checked = true;
|
||
toggle.addEventListener('change', function(ev) {
|
||
uci.load('quickactions').then(function() {
|
||
uci.set('quickactions', sid, 'enabled', ev.target.checked ? '1' : '0');
|
||
return uci.save();
|
||
}).then(function() {
|
||
ui.addNotification(null, E('p', {}, _('Saved to memory.')), 'info');
|
||
self.refreshPendingCount();
|
||
});
|
||
});
|
||
|
||
var row = E('tr', { 'style': 'border-bottom:1px solid rgba(0,0,0,0.06);' }, [
|
||
E('td', { 'style': 'padding:8px;' }, [
|
||
E('strong', {}, catIcon(s.category || 'custom') + ' ' + (s.name || sid)),
|
||
E('br'),
|
||
E('code', { 'style': 'font-size:0.78em;color:#888;word-break:break-all;' },
|
||
s.path || ('/etc/quickactions/scripts/' + sid + '.sh'))
|
||
]),
|
||
E('td', { 'style': 'padding:8px;font-size:0.9em;' }, s.category || 'custom'),
|
||
E('td', { 'style': 'padding:8px;text-align:center;' }, toggle),
|
||
E('td', { 'style': 'padding:8px;text-align:right;font-size:0.85em;color:#888;' },
|
||
(s.size || 0) + ' B'),
|
||
E('td', { 'style': 'padding:8px;text-align:right;' }, [
|
||
E('button', { 'class': 'btn cbi-button',
|
||
'style': 'padding:2px 10px;font-size:0.85em;margin-right:4px;',
|
||
'click': function() { openDetail(sid); } }, _('Info')),
|
||
E('button', { 'class': 'btn cbi-button',
|
||
'style': 'padding:2px 10px;font-size:0.85em;margin-right:4px;',
|
||
'click': function() { openEditor(sid); } }, _('Edit')),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action',
|
||
'style': 'padding:2px 10px;font-size:0.85em;margin-right:4px;',
|
||
'click': function() { runScript(sid, s.name); } }, _('Run')),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-negative',
|
||
'style': 'padding:2px 10px;font-size:0.85em;',
|
||
'click': function() { deleteScript(sid, s.name); } }, _('Delete'))
|
||
])
|
||
]);
|
||
table.appendChild(row);
|
||
});
|
||
|
||
tableWrap.appendChild(table);
|
||
}).catch(function(err) {
|
||
tableWrap.innerHTML = '';
|
||
tableWrap.appendChild(E('div', { 'class': 'alert-message error',
|
||
'style': 'padding:12px;' }, 'List failed: ' + (err.message || err)));
|
||
});
|
||
}
|
||
|
||
// ---- Editor modal ----
|
||
function openEditor(sid) {
|
||
var isNew = !sid;
|
||
|
||
function build(fields) {
|
||
var inputs = {
|
||
name: E('input', { 'type': 'text', 'value': fields.name || '',
|
||
'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' }),
|
||
category: (function() {
|
||
var s = E('select', { 'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
[['failover','Failover'],['network','Network'],['system','System'],
|
||
['iot','IoT'],['custom','Custom']].forEach(function(c) {
|
||
var o = E('option', { 'value': c[0] }, c[1]);
|
||
if (c[0] === (fields.category || 'custom')) o.selected = true;
|
||
s.appendChild(o);
|
||
});
|
||
return s;
|
||
})(),
|
||
timeout: E('input', { 'type': 'text', 'value': fields.timeout || '60',
|
||
'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' }),
|
||
args: E('input', { 'type': 'text', 'value': fields.args || '',
|
||
'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' }),
|
||
content: E('textarea', { 'spellcheck': 'false',
|
||
'style': 'width:100%;min-height:320px;font-family:monospace;font-size:0.9em;padding:8px;' },
|
||
fields.content || '#!/bin/sh\n\n# Write your script here\n')
|
||
};
|
||
|
||
var body = E('div', {}, [
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Name *')),
|
||
inputs.name
|
||
]),
|
||
E('div', { 'style': 'display:flex;gap:12px;margin-bottom:10px;flex-wrap:wrap;' }, [
|
||
E('div', { 'style': 'flex:1 1 200px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Category')),
|
||
inputs.category
|
||
]),
|
||
E('div', { 'style': 'flex:1 1 200px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Timeout (s)')),
|
||
inputs.timeout
|
||
])
|
||
]),
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Arguments (space-separated)')),
|
||
inputs.args
|
||
]),
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Script body *')),
|
||
inputs.content
|
||
])
|
||
]);
|
||
|
||
var saveBtn = E('button', {
|
||
'class': 'btn cbi-button',
|
||
'style': 'background-color:#28a745 !important;background:#28a745 !important;color:#fff !important;padding:8px 22px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;'
|
||
}, _('Save'));
|
||
saveBtn.addEventListener('click', function() {
|
||
var n = inputs.name.value.trim();
|
||
var c = inputs.content.value;
|
||
if (!n) { ui.addNotification(null, E('p', {}, 'Name required'), 'danger'); return; }
|
||
if (!c) { ui.addNotification(null, E('p', {}, 'Script body required'), 'danger'); return; }
|
||
var payload = {
|
||
sid: sid || '',
|
||
name: n,
|
||
category: inputs.category.value,
|
||
content: c,
|
||
args: inputs.args.value,
|
||
timeout: inputs.timeout.value || '60',
|
||
enabled: '1'
|
||
};
|
||
rpcWrite(
|
||
payload.sid, payload.name, payload.category, payload.content,
|
||
payload.args, payload.timeout, payload.enabled
|
||
).then(function(res) {
|
||
ui.addNotification(null, E('p', {}, _('Saved')), 'info');
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
uci.load('quickactions').then(function() {
|
||
self.refreshPendingCount();
|
||
refreshTable();
|
||
});
|
||
}).catch(function(err) {
|
||
ui.addNotification(null, E('p', {}, 'Save failed: ' + (err.message || err)), 'danger');
|
||
});
|
||
});
|
||
var cancelBtn = E('button', { 'class': 'btn cbi-button',
|
||
'style': 'margin-right:8px;',
|
||
'click': function() {
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
}
|
||
}, _('Cancel'));
|
||
|
||
ui.showModal(_(isNew ? 'Create script' : ('Edit: ' + (fields.name || sid))), [
|
||
body,
|
||
E('div', { 'style': 'margin-top:14px;text-align:right;' }, [cancelBtn, saveBtn])
|
||
]);
|
||
}
|
||
|
||
if (isNew) {
|
||
build({});
|
||
} else {
|
||
rpcRead(sid).then(function(r) {
|
||
return rpcDetail(sid).then(function(d) {
|
||
build({
|
||
name: d.name || sid,
|
||
category: d.category || 'custom',
|
||
timeout: d.timeout || '60',
|
||
args: d.args || '',
|
||
content: r.content || ''
|
||
});
|
||
});
|
||
}).catch(function(err) {
|
||
ui.addNotification(null, E('p', {}, 'Load failed: ' + (err.message || err)), 'danger');
|
||
});
|
||
}
|
||
}
|
||
|
||
function openDetail(sid) {
|
||
rpcDetail(sid).then(function(d) {
|
||
ui.showModal(_('Script detail: ' + (d.name || sid)), [
|
||
E('div', { 'style': 'line-height:1.7;font-size:0.92em;' }, [
|
||
E('div', {}, [E('strong', {}, _('Name: ')), d.name || '—']),
|
||
E('div', {}, [E('strong', {}, _('Category: ')), d.category || '—']),
|
||
E('div', {}, [E('strong', {}, _('Path: ')), E('code', {}, d.path || '')]),
|
||
E('div', {}, [E('strong', {}, _('Enabled: ')), d.enabled === '1' ? 'yes' : 'no']),
|
||
E('div', {}, [E('strong', {}, _('Timeout: ')), d.timeout || '—']),
|
||
E('div', {}, [E('strong', {}, _('Arguments: ')), d.args || '—']),
|
||
E('div', {}, [E('strong', {}, _('Size: ')), (d.size || 0) + ' B']),
|
||
E('div', {}, [E('strong', {}, _('Permissions: ')), E('code', {}, d.permissions || '—')])
|
||
]),
|
||
E('div', { 'style': 'margin-top:14px;text-align:right;' }, [
|
||
E('button', { 'class': 'btn cbi-button', 'style': 'margin-right:8px;',
|
||
'click': function() {
|
||
rpcChmod(sid, '755').then(function() {
|
||
ui.addNotification(null, E('p', {}, 'chmod 755 ok'), 'info');
|
||
});
|
||
} }, _('Make executable')),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action',
|
||
'click': function() {
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
} }, _('Close'))
|
||
])
|
||
]);
|
||
});
|
||
}
|
||
|
||
function runScript(sid, name) {
|
||
ui.addNotification(null, E('p', {}, _('Running ' + name + '...')), 'info');
|
||
rpcRun(sid).then(function(r) {
|
||
var out = (r && r.output) ? r.output : '(no output)';
|
||
var rc = (r && r.exit_code != null) ? r.exit_code : '?';
|
||
var dur = (r && r.duration != null) ? r.duration + 's' : '';
|
||
ui.addNotification(
|
||
E('strong', {}, name + ' · exit ' + rc + (dur ? ' · ' + dur : '')),
|
||
E('pre', { 'style': 'text-align:left;white-space:pre-wrap;max-height:60vh;overflow:auto;background:#111;color:#0f0;padding:12px;border-radius:4px;font-size:0.85em;' }, out),
|
||
rc === 0 ? 'info' : 'warning'
|
||
);
|
||
}).catch(function(err) {
|
||
ui.addNotification(null, E('p', {}, 'Run failed: ' + (err.message || err)), 'danger');
|
||
});
|
||
}
|
||
|
||
function deleteScript(sid, name) {
|
||
if (!confirm(_('Delete script "' + name + '"? Removes file and UCI entry.'))) return;
|
||
rpcDelete(sid).then(function() {
|
||
ui.addNotification(null, E('p', {}, 'Deleted'), 'info');
|
||
uci.load('quickactions').then(refreshTable);
|
||
}).catch(function(err) {
|
||
ui.addNotification(null, E('p', {}, 'Delete failed: ' + (err.message || err)), 'danger');
|
||
});
|
||
}
|
||
|
||
refreshTable();
|
||
|
||
return E('div', {}, [
|
||
E('h3', { 'style': 'margin-top:0;' }, _('Scripts')),
|
||
E('div', { 'style': 'background:rgba(30,144,255,0.08);border:1px solid rgba(30,144,255,0.2);border-radius:6px;padding:12px 16px;margin-bottom:16px;font-size:0.9em;' }, [
|
||
E('strong', {}, _('Scripts ')),
|
||
_('— create, edit, run, and manage shell scripts. Files live in '),
|
||
E('code', {}, '/etc/quickactions/scripts/'),
|
||
_('. Metadata in '),
|
||
E('code', {}, '/etc/config/quickactions'),
|
||
_('.')
|
||
]),
|
||
tableWrap
|
||
]);
|
||
},
|
||
|
||
// ============ VLAN tab ============
|
||
renderVlan: function() {
|
||
var self = this;
|
||
|
||
// Resolve tier (auto → based on detect; else honor uci override)
|
||
function resolvedTier(detect) {
|
||
var override = uci.get('quickactions', 'vlan', 'tier') || 'auto';
|
||
if (override === 'low' || override === 'high') return override;
|
||
var mb = parseInt(detect.mem_mb, 10) || 0;
|
||
return (mb >= 64) ? 'high' : 'low';
|
||
}
|
||
|
||
// ---------- top bar with subtab buttons ----------
|
||
var SUBTABS = [
|
||
{ id: 'overview', label: _('Overview') },
|
||
{ id: 'wizard', label: _('Wizard') },
|
||
{ id: 'edit', label: _('Edit') },
|
||
{ id: 'library', label: _('Library'), tier: 'high' },
|
||
{ id: 'ssid', label: _('SSID') },
|
||
{ id: 'import', label: _('Import') },
|
||
{ id: 'safety', label: _('Safety') },
|
||
{ id: 'diagnostics', label: _('Diagnostics'), tier: 'high' },
|
||
{ id: 'guide', label: _('Guide') }
|
||
];
|
||
|
||
var subBar = E('div', {
|
||
'style': 'display:flex;gap:4px;border-bottom:2px solid rgba(0,0,0,0.08);' +
|
||
'margin:0 0 16px 0;flex-wrap:wrap;'
|
||
});
|
||
var subContent = E('div');
|
||
|
||
var subButtons = {};
|
||
var tierResolved = null;
|
||
|
||
function paintSubTabButtons() {
|
||
SUBTABS.forEach(function(t) {
|
||
var b = subButtons[t.id];
|
||
if (!b) return;
|
||
var hidden = (t.tier === 'high' && tierResolved !== 'high');
|
||
b.style.display = hidden ? 'none' : '';
|
||
if (t.id === self.vlanActiveSubTab) {
|
||
b.style.borderBottomColor = '#1e90ff';
|
||
b.style.color = '#1e90ff';
|
||
b.style.fontWeight = 'bold';
|
||
} else {
|
||
b.style.borderBottomColor = 'transparent';
|
||
b.style.color = '#666';
|
||
b.style.fontWeight = '500';
|
||
}
|
||
});
|
||
}
|
||
|
||
SUBTABS.forEach(function(t) {
|
||
var b = E('div', {
|
||
'style': 'padding:8px 16px;cursor:pointer;border-bottom:3px solid transparent;' +
|
||
'color:#666;font-weight:500;font-size:0.9em;user-select:none;',
|
||
'click': function() { self.vlanShowSub(t.id); }
|
||
}, t.label);
|
||
subButtons[t.id] = b;
|
||
subBar.appendChild(b);
|
||
});
|
||
|
||
// stash refs on the view for helper methods
|
||
self._vlanSubButtons = subButtons;
|
||
self._vlanSubContent = subContent;
|
||
self._vlanPaintSubTabButtons = paintSubTabButtons;
|
||
self._vlanResolvedTier = resolvedTier;
|
||
|
||
// initial subtab
|
||
self.vlanActiveSubTab = self.vlanActiveSubTab || 'overview';
|
||
|
||
// first-time detect (needed to know the tier)
|
||
self.callVlan('detect').then(function(detect) {
|
||
self._vlanDetect = detect;
|
||
tierResolved = resolvedTier(detect);
|
||
self.vlanTier = tierResolved;
|
||
paintSubTabButtons();
|
||
self.vlanShowSub(self.vlanActiveSubTab);
|
||
}).catch(function(err) {
|
||
subContent.innerHTML = '';
|
||
subContent.appendChild(E('div', {
|
||
'class': 'alert-message error',
|
||
'style': 'padding:16px;'
|
||
}, 'VLAN detect failed: ' + (err && err.message ? err.message : String(err))));
|
||
});
|
||
|
||
return E('div', {}, [
|
||
E('h3', { 'style': 'margin-top:0;' }, _('VLAN Management')),
|
||
E('p', { 'style': 'color:#888;font-size:0.9em;margin-top:-8px;' },
|
||
_('View, create, and edit VLANs on this router and generate matching configs for MikroTik, Cisco, and EdgeOS devices.')),
|
||
subBar,
|
||
subContent
|
||
]);
|
||
},
|
||
|
||
// rpc shim for luci.quickactions-vlan
|
||
callVlan: function(method, p1, p2, p3, p4) {
|
||
var sigs = {
|
||
'detect': [],
|
||
'preview': ['plan'],
|
||
'apply': ['plan', 'safe'],
|
||
'snapshot': ['note'],
|
||
'list_snapshots': [],
|
||
'restore': ['path'],
|
||
'confirm': [],
|
||
'generate_cli': ['plan', 'target'],
|
||
'save_template': ['name', 'target', 'plan', 'notes'],
|
||
'list_templates': [],
|
||
'load_template': ['name'],
|
||
'delete_template': ['name'],
|
||
'save_generated': ['name', 'target', 'content'],
|
||
'list_generated': [],
|
||
'delete_generated': ['name'],
|
||
'lldp_neighbors': [],
|
||
'snmp_query': ['ip', 'community'],
|
||
'bridge_vlan_show': [],
|
||
'kernel_ifaces': [],
|
||
'iface_stats': ['ifaces'],
|
||
'ping_test': ['iface', 'ip']
|
||
};
|
||
var sig = sigs[method] || [];
|
||
var call = rpc.declare({
|
||
object: 'luci.quickactions-vlan',
|
||
method: method,
|
||
params: sig,
|
||
expect: { '': {} }
|
||
});
|
||
var args = [p1 || '', p2 || '', p3 || '', p4 || ''].slice(0, sig.length);
|
||
return call.apply(null, args);
|
||
},
|
||
|
||
// sub-tab dispatcher
|
||
vlanShowSub: function(id) {
|
||
var self = this;
|
||
self.vlanActiveSubTab = id;
|
||
if (self._vlanPaintSubTabButtons) self._vlanPaintSubTabButtons();
|
||
var subContent = self._vlanSubContent;
|
||
if (!subContent) return;
|
||
subContent.innerHTML = '';
|
||
subContent.appendChild(E('p', { 'style': 'color:#888;padding:20px;' },
|
||
_('Loading ') + id + _('...')));
|
||
|
||
var builder = {
|
||
overview: 'vlanRenderOverview',
|
||
wizard: 'vlanRenderWizard',
|
||
edit: 'vlanRenderEdit',
|
||
library: 'vlanRenderLibrary',
|
||
ssid: 'vlanRenderSsid',
|
||
import: 'vlanRenderImport',
|
||
safety: 'vlanRenderSafety',
|
||
diagnostics: 'vlanRenderDiagnostics',
|
||
guide: 'vlanRenderGuide'
|
||
}[id];
|
||
|
||
if (!builder || typeof self[builder] !== 'function') {
|
||
subContent.innerHTML = '';
|
||
subContent.appendChild(E('p', { 'style': 'color:#888;padding:20px;' },
|
||
_('This section is coming in a follow-up patch.')));
|
||
return;
|
||
}
|
||
|
||
Promise.resolve(self[builder]()).then(function(node) {
|
||
subContent.innerHTML = '';
|
||
if (node) subContent.appendChild(node);
|
||
}).catch(function(err) {
|
||
subContent.innerHTML = '';
|
||
subContent.appendChild(E('div', {
|
||
'class': 'alert-message error',
|
||
'style': 'padding:16px;'
|
||
}, 'Error: ' + (err && err.message ? err.message : String(err))));
|
||
});
|
||
},
|
||
|
||
// ---------- Overview ----------
|
||
vlanRenderOverview: function() {
|
||
var self = this;
|
||
var d = self._vlanDetect;
|
||
if (!d) return E('p', { 'style': 'color:#888;' }, _('Detection has not run yet.'));
|
||
|
||
function card(title, body) {
|
||
return E('div', {
|
||
'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);' +
|
||
'border-radius:8px;padding:14px 16px;margin-bottom:16px;'
|
||
}, [
|
||
E('h4', { 'style': 'margin:0 0 10px 0;font-size:1em;' }, title),
|
||
body
|
||
]);
|
||
}
|
||
|
||
function table(cols, rows) {
|
||
var t = E('table', { 'style': 'width:100%;border-collapse:collapse;font-size:0.9em;' });
|
||
var hr = E('tr', { 'style': 'text-align:left;border-bottom:2px solid rgba(0,0,0,0.1);' });
|
||
cols.forEach(function(c) {
|
||
hr.appendChild(E('th', { 'style': 'padding:6px 8px;font-weight:600;' }, c));
|
||
});
|
||
t.appendChild(hr);
|
||
rows.forEach(function(r) {
|
||
var tr = E('tr', { 'style': 'border-bottom:1px solid rgba(0,0,0,0.05);' });
|
||
r.forEach(function(cell) {
|
||
tr.appendChild(E('td', { 'style': 'padding:6px 8px;' },
|
||
typeof cell === 'string' ? cell : cell));
|
||
});
|
||
t.appendChild(tr);
|
||
});
|
||
return t;
|
||
}
|
||
|
||
// Platform card
|
||
var currentTier = self.vlanTier || self._vlanResolvedTier(d);
|
||
var platformBody = E('div', {}, [
|
||
E('div', { 'style': 'display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;' }, [
|
||
E('div', {}, [E('strong', {}, _('Mode: ')), d.mode === 'dsa' ? 'DSA (bridge VLAN filtering)' : (d.mode === 'swconfig' ? 'swconfig (legacy)' : 'unknown')]),
|
||
E('div', {}, [E('strong', {}, _('Kernel: ')), d.kernel || '—']),
|
||
E('div', {}, [E('strong', {}, _('Memory: ')), (d.mem_mb || '—') + ' MB']),
|
||
E('div', {}, [E('strong', {}, _('Hostname: ')), d.hostname || '—'])
|
||
]),
|
||
E('div', { 'style': 'margin-top:10px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;' }, [
|
||
E('span', { 'style': 'font-size:0.9em;color:#666;' }, _('Resource mode: ')),
|
||
(function() {
|
||
var override = uci.get('quickactions', 'vlan', 'tier') || 'auto';
|
||
var wrap = E('div', { 'style': 'display:flex;gap:6px;' });
|
||
[['auto','Auto'],['low','Low'],['high','High']].forEach(function(o) {
|
||
var btn = E('button', {
|
||
'class': 'btn cbi-button' + (override === o[0] ? ' cbi-button-action important' : ''),
|
||
'style': 'padding:4px 12px;font-size:0.85em;',
|
||
'click': function() {
|
||
uci.load('quickactions').then(function() {
|
||
uci.set('quickactions', 'vlan', 'tier', o[0]);
|
||
return uci.save();
|
||
}).then(function() {
|
||
ui.addNotification(null, E('p', {}, _('Tier saved. Reload the tab to apply.')), 'info');
|
||
self.refreshPendingCount();
|
||
});
|
||
}
|
||
}, o[1]);
|
||
wrap.appendChild(btn);
|
||
});
|
||
return wrap;
|
||
})(),
|
||
E('span', { 'style': 'font-size:0.85em;color:#888;' },
|
||
_('Resolved: ') + (currentTier || '—').toUpperCase())
|
||
])
|
||
]);
|
||
|
||
// Bridges
|
||
var bridgeRows = (d.bridges || []).map(function(b) {
|
||
return [
|
||
b.name || b.sid,
|
||
b.vlan_filtering === '1' ? '✓' : '—',
|
||
(b.sid && b.sid.indexOf('@') === 0) ? _('anonymous') : b.sid
|
||
];
|
||
});
|
||
|
||
// VLANs
|
||
var vlanRows = (d.bridge_vlans || []).map(function(v) {
|
||
return [v.device || '', v.vlan || '', v.ports || '', v.sid || ''];
|
||
});
|
||
|
||
// Interfaces
|
||
var ifaceRows = (d.interfaces || []).map(function(i) {
|
||
return [i.sid || '', i.device || '', i.proto || '', i.ipaddr || '', i.netmask || ''];
|
||
});
|
||
|
||
// DHCP
|
||
var dhcpRows = (d.dhcp || []).map(function(x) {
|
||
return [x.sid || '', x.interface || '', x.start || '', x.limit || ''];
|
||
});
|
||
|
||
// Zones
|
||
var zoneRows = (d.zones || []).map(function(z) {
|
||
return [z.name || '', z.network || '', z.input || '', z.output || '', z.forward || ''];
|
||
});
|
||
|
||
// Wifi
|
||
var wifiRows = (d.wifi || []).map(function(w) {
|
||
return [w.ssid || w.sid || '', w.device || '', w.mode || '', w.network || ''];
|
||
});
|
||
|
||
// Easymesh devices
|
||
var easymeshRows = (d.easymesh || []).map(function(x) { return [x]; });
|
||
|
||
var refreshRow = E('div', { 'style': 'margin-bottom:14px;' }, [
|
||
E('button', {
|
||
'class': 'btn cbi-button',
|
||
'style': 'padding:6px 14px;',
|
||
'click': function() {
|
||
self.callVlan('detect').then(function(dd) {
|
||
self._vlanDetect = dd;
|
||
self.vlanShowSub('overview');
|
||
});
|
||
}
|
||
}, _('↻ Refresh'))
|
||
]);
|
||
|
||
return E('div', {}, [
|
||
refreshRow,
|
||
card(_('Platform'), platformBody),
|
||
card(_('Bridges') + ' (' + (d.bridges || []).length + ')',
|
||
table([_('Name'), _('VLAN filtering'), _('Section')], bridgeRows.length ? bridgeRows :
|
||
[[E('em', { 'style': 'color:#888;' }, _('(none)')), '', '']])),
|
||
card(_('VLANs') + ' (' + vlanRows.length + ')',
|
||
table([_('Device'), _('VLAN ID'), _('Ports'), _('Section')], vlanRows.length ? vlanRows :
|
||
[[E('em', { 'style': 'color:#888;' }, _('(no bridge-vlan entries yet)')), '', '', '']])),
|
||
card(_('Interfaces') + ' (' + ifaceRows.length + ')',
|
||
table([_('Name'), _('Device'), _('Proto'), _('IP address'), _('Netmask')], ifaceRows)),
|
||
card(_('DHCP pools') + ' (' + dhcpRows.length + ')',
|
||
table([_('Section'), _('Interface'), _('Start'), _('Limit')], dhcpRows)),
|
||
card(_('Firewall zones') + ' (' + zoneRows.length + ')',
|
||
table([_('Name'), _('Networks'), _('Input'), _('Output'), _('Forward')], zoneRows)),
|
||
card(_('Wireless') + ' (' + wifiRows.length + ')',
|
||
table([_('SSID'), _('Radio'), _('Mode'), _('Attached network')], wifiRows)),
|
||
easymeshRows.length ? card(_('EasyMesh bat0 devices'),
|
||
table([_('Device')], easymeshRows)) : null
|
||
].filter(Boolean));
|
||
},
|
||
|
||
// ---------- Guide ----------
|
||
vlanRenderGuide: function() {
|
||
function h4(t) { return E('h4', { 'style': 'margin:20px 0 8px 0;font-size:1.05em;color:#1e90ff;' }, t); }
|
||
function p(t) { return E('p', { 'style': 'margin:6px 0;line-height:1.6;font-size:0.92em;' }, t); }
|
||
function pre(t) { return E('pre', { 'style': 'background:#111;color:#0f0;padding:10px 12px;border-radius:4px;overflow-x:auto;font-size:0.85em;white-space:pre-wrap;' }, t); }
|
||
function li(t) { return E('li', { 'style': 'line-height:1.6;font-size:0.9em;' }, t); }
|
||
function box(children, color) {
|
||
return E('div', {
|
||
'style': 'background:rgba(' + (color || '30,144,255') + ',0.08);' +
|
||
'border:1px solid rgba(' + (color || '30,144,255') + ',0.2);' +
|
||
'border-radius:6px;padding:12px 16px;margin:12px 0;font-size:0.9em;'
|
||
}, children);
|
||
}
|
||
|
||
var guide = E('div', { 'style': 'line-height:1.6;' }, [
|
||
p(_('This tab lets you view, create, and edit VLANs on this router. It can also generate matching configs for external switches (MikroTik, Cisco, EdgeOS).')),
|
||
|
||
h4(_('📘 What is a VLAN?')),
|
||
p(_('A VLAN (Virtual Local Area Network) divides one physical LAN into multiple logical networks. Devices on the same switch can be isolated into separate groups — as if they were on entirely different networks.')),
|
||
|
||
h4(_('🛠️ Why use VLAN?')),
|
||
E('ul', { 'style': 'padding-left:22px;' }, [
|
||
li(_('Security: keep sensitive devices (printers, NAS) separate from guest traffic.')),
|
||
li(_('Traffic management: reduce broadcast traffic and improve speed.')),
|
||
li(_('Easy management: move a device without reconfiguring its network.')),
|
||
li(_('Cost efficiency: no extra switches or routers needed.'))
|
||
]),
|
||
|
||
h4(_('🔍 First check: DSA or swconfig?')),
|
||
box([
|
||
E('strong', {}, _('Your router: ')),
|
||
p(_('Network → Interfaces → Devices with "Bridge VLAN filtering" = DSA (modern).')),
|
||
p(_('Network → Switch menu = swconfig (legacy).')),
|
||
p(_('OpenWrt 21.02+ typically uses DSA. Some single-port travel routers do not support VLAN filtering at all.'))
|
||
]),
|
||
|
||
h4(_('📂 Types of VLAN')),
|
||
E('ul', { 'style': 'padding-left:22px;' }, [
|
||
li(_('Port-based: one port = one VLAN.')),
|
||
li(_('Tagged (802.1Q): one port carries multiple VLANs, each packet tagged.')),
|
||
li(_('MAC-based: VLAN membership decided by MAC address.')),
|
||
li(_('Protocol-based: VLAN membership decided by packet protocol.'))
|
||
]),
|
||
|
||
h4(_('💻 OpenWrt DSA quick example — VLAN 20 for a Guest network')),
|
||
box([
|
||
E('strong', { 'style': 'color:#c00;' }, _('⚠️ Connect via a LAN port you will NOT modify. Never apply VLAN changes over Wi-Fi or over the port you are changing. You will lock yourself out.'))
|
||
], '220,53,69'),
|
||
p(_('Step 1 — Enable VLAN filtering on the bridge:')),
|
||
pre('Network → Interfaces → Devices → br-lan → Configure → Bridge VLAN filtering → Enable'),
|
||
p(_('Step 2 — Add VLAN 20: pick ports, mark them tagged or untagged.')),
|
||
p(_('Step 3 — Create the interface (protocol static, IP 192.168.20.1/24, device br-lan.20).')),
|
||
p(_('Step 4 — Create the firewall zone, then click Save & Apply.')),
|
||
p(_('Use the Wizard subtab above to do all four steps in one flow.')),
|
||
|
||
h4(_('📶 Tagging a Wi-Fi SSID to a VLAN')),
|
||
p(_('You do not tag the SSID directly — you attach the wireless interface to the VLAN\'s logical network.')),
|
||
p(_('Use the SSID subtab above: pick the SSID, pick the VLAN interface, save.')),
|
||
|
||
h4(_('🛡️ Client Isolation')),
|
||
p(_('Blocks guests from reaching each other on the same SSID. Enable "Isolate clients" in the wireless advanced settings, or use the SSID subtab in this tab.')),
|
||
|
||
h4(_('📖 Term definitions')),
|
||
E('ul', { 'style': 'padding-left:22px;' }, [
|
||
li([E('strong', {}, _('VLAN ID: ')), _('number 1–4094. Avoid 0, 1, 4095.')]),
|
||
li([E('strong', {}, _('Access port: ')), _('a port dedicated to one VLAN.')]),
|
||
li([E('strong', {}, _('Trunk port: ')), _('a port carrying multiple VLANs (tagged).')]),
|
||
li([E('strong', {}, _('Tagged packet: ')), _('carries a VLAN tag identifying its network.')])
|
||
]),
|
||
|
||
h4(_('🪵 Inter-VLAN Routing (Router-on-a-Stick)')),
|
||
p(_('The router is the gateway between VLANs. Traffic between them goes through the firewall zone forwarding rules.')),
|
||
|
||
h4(_('🔥 Zone Forwarding')),
|
||
p(_('Allow LAN → Guest (one-way): Network → Firewall → Zones → lan → Allow forward to guest_zone.')),
|
||
box([
|
||
E('strong', { 'style': 'color:#c00;' }, _('⚠️ Never enable Guest → LAN. That defeats the isolation.'))
|
||
], '220,53,69'),
|
||
|
||
h4(_('🛟 Anti-lockout & rollback')),
|
||
p(_('The Safety subtab above takes snapshots and supports a 90-second auto-rollback after every apply.')),
|
||
p(_('Manual restore:')),
|
||
pre('cp /etc/quickactions/vlan/network.bak.<ts> /etc/config/network\n/etc/init.d/network reload'),
|
||
|
||
h4(_('🌐 Cross-platform notes')),
|
||
p(_('DSA bridge-vlan → MikroTik /interface bridge vlan add bridge=... vlan-ids=N tagged=... untagged=...')),
|
||
p(_('Cisco: switchport trunk allowed vlan 10,20 ; switchport trunk native vlan 1')),
|
||
p(_('EdgeOS: set interfaces ethernet ethN vif <id> address <ip>')),
|
||
|
||
box([
|
||
E('strong', {}, _('Generated CLI examples ')),
|
||
_('are available in the Wizard subtab once you build a plan and pick a target platform.')
|
||
])
|
||
]);
|
||
|
||
return E('div', {}, [
|
||
E('h3', { 'style': 'margin-top:0;' }, _('VLAN Guide')),
|
||
guide
|
||
]);
|
||
},
|
||
|
||
// ---------- Wizard ----------
|
||
vlanRenderWizard: function() {
|
||
var self = this;
|
||
var d = self._vlanDetect || {};
|
||
var ifaces = d.interfaces || [];
|
||
var bridges = d.bridges || [];
|
||
|
||
// Collect all physical ports from UCI network.device (non-bridge) + interface devices
|
||
function collectPorts() {
|
||
var ports = {};
|
||
// bridge members
|
||
(uci.sections('network', 'device') || []).forEach(function(dev) {
|
||
if (dev.type === 'bridge') {
|
||
var p = dev.ports;
|
||
if (typeof p === 'string') p = p.split(/\s+/);
|
||
if (Array.isArray(p)) p.forEach(function(x) { if (x) ports[x] = 1; });
|
||
} else if (dev.name) {
|
||
ports[dev.name] = 1;
|
||
}
|
||
});
|
||
// interface devices
|
||
ifaces.forEach(function(i) {
|
||
if (i.device && i.device.indexOf('@') !== 0) ports[i.device] = 1;
|
||
});
|
||
// physical /sys/class/net
|
||
return Object.keys(ports).sort();
|
||
}
|
||
var allPorts = collectPorts();
|
||
|
||
// ---- Step state ----
|
||
var state = {
|
||
target: 'openwrt-dsa',
|
||
vlanId: '20',
|
||
bridge: (bridges[0] && bridges[0].name) || 'br-lan',
|
||
newBridge: '',
|
||
ports: {}, // {port: 'off'|'u'|'t'}
|
||
ifaceOn: true,
|
||
ifaceName: 'GUEST',
|
||
ifaceProto:'static',
|
||
ifaceIp: '192.168.20.1',
|
||
ifaceMask: '255.255.255.0',
|
||
dhcpOn: true,
|
||
dhcpStart: '100',
|
||
dhcpLimit: '150',
|
||
dhcpLease: '12h',
|
||
zoneOn: true,
|
||
zoneName: 'guest_zone',
|
||
zoneInput: 'REJECT',
|
||
zoneOutput:'ACCEPT',
|
||
zoneForward:'REJECT',
|
||
zoneMasq: false,
|
||
zoneForwardTo: 'wan',
|
||
ssidOn: false,
|
||
ssidNew: true,
|
||
ssidName: 'GUEST-WiFi',
|
||
ssidEnc: 'psk2',
|
||
ssidPass: '',
|
||
ssidPick: ''
|
||
};
|
||
|
||
allPorts.forEach(function(p) { state.ports[p] = 'off'; });
|
||
|
||
// ---- Simple field helpers ----
|
||
function field(label, el, hint) {
|
||
return E('div', { 'style': 'margin-bottom:12px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;font-weight:500;' }, label),
|
||
el,
|
||
hint ? E('div', { 'style': 'font-size:0.8em;color:#888;margin-top:3px;' }, hint) : null
|
||
]);
|
||
}
|
||
function txtInput(v, ph) {
|
||
return E('input', { 'type': 'text', 'value': v || '', 'placeholder': ph || '',
|
||
'style': 'width:100%;max-width:400px;padding:8px 10px;border:1px solid #ccc;border-radius:4px;box-sizing:border-box;' });
|
||
}
|
||
function numInput(v, min, max) {
|
||
return E('input', { 'type': 'number', 'value': v, 'min': min, 'max': max,
|
||
'style': 'width:100%;max-width:200px;padding:8px 10px;border:1px solid #ccc;border-radius:4px;box-sizing:border-box;' });
|
||
}
|
||
function select(opts, val) {
|
||
var s = E('select', { 'style': 'width:100%;max-width:400px;padding:8px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
opts.forEach(function(o) {
|
||
var opt = E('option', { 'value': o[0] }, o[1]);
|
||
if (o[0] === val) opt.selected = true;
|
||
s.appendChild(opt);
|
||
});
|
||
return s;
|
||
}
|
||
function check(label, checked, hint) {
|
||
var cb = E('input', { 'type': 'checkbox' });
|
||
if (checked) cb.checked = true;
|
||
return E('div', { 'style': 'margin-bottom:10px;display:flex;align-items:flex-start;gap:10px;' }, [
|
||
cb,
|
||
E('div', {}, [
|
||
E('label', { 'style': 'font-size:0.95em;font-weight:500;' }, label),
|
||
hint ? E('div', { 'style': 'font-size:0.8em;color:#888;margin-top:2px;' }, hint) : null
|
||
])
|
||
]);
|
||
}
|
||
|
||
// ---- Step 0: Target platform ----
|
||
var targetSel = select([
|
||
['openwrt-dsa', 'OpenWrt DSA (this router)'],
|
||
['openwrt-swconfig','OpenWrt swconfig (this router)'],
|
||
['mikrotik', 'MikroTik RouterOS 6/7 (generate CLI)'],
|
||
['cisco', 'Cisco IOS / NX-OS (generate CLI)'],
|
||
['edgeos', 'EdgeOS / EdgeSwitch (generate CLI)'],
|
||
['generic', 'Generic 802.1Q (table + JSON)']
|
||
], state.target);
|
||
targetSel.addEventListener('change', function() { state.target = targetSel.value; });
|
||
|
||
// ---- Step 1: VLAN ID + parent bridge ----
|
||
var vidEl = numInput(state.vlanId, 2, 4094);
|
||
vidEl.addEventListener('input', function() { state.vlanId = vidEl.value; });
|
||
|
||
var bridgeSel = select(bridges.map(function(b) { return [b.name, b.name]; }).concat([['__new__', 'Create new bridge...']]), state.bridge);
|
||
bridgeSel.addEventListener('change', function() { state.bridge = bridgeSel.value; });
|
||
var newBridgeEl = txtInput('', 'br-vlan');
|
||
newBridgeEl.addEventListener('input', function() { state.newBridge = newBridgeEl.value; });
|
||
|
||
// ---- Step 2: Ports ----
|
||
var portRows = [];
|
||
function rebuildPortTable() {
|
||
portRows = allPorts.map(function(p) {
|
||
var sel = E('select', { 'style': 'width:120px;padding:4px 8px;border:1px solid #ccc;border-radius:4px;' });
|
||
[['off','— off'],['u','Untagged'],['t','Tagged']].forEach(function(o) {
|
||
var opt = E('option', { 'value': o[0] }, o[1]);
|
||
if (o[0] === state.ports[p]) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
});
|
||
sel.addEventListener('change', function() { state.ports[p] = sel.value; });
|
||
return E('tr', { 'style': 'border-bottom:1px solid rgba(0,0,0,0.05);' }, [
|
||
E('td', { 'style': 'padding:4px 8px;font-family:monospace;' }, p),
|
||
E('td', { 'style': 'padding:4px 8px;' }, sel)
|
||
]);
|
||
});
|
||
var t = E('table', { 'style': 'border-collapse:collapse;' });
|
||
t.appendChild(E('tr', { 'style': 'text-align:left;border-bottom:2px solid rgba(0,0,0,0.1);' }, [
|
||
E('th', { 'style': 'padding:6px 8px;' }, _('Port')),
|
||
E('th', { 'style': 'padding:6px 8px;' }, _('Mode'))
|
||
]));
|
||
portRows.forEach(function(r) { t.appendChild(r); });
|
||
return t;
|
||
}
|
||
|
||
// ---- Step 3: Interface ----
|
||
var ifNameEl = txtInput(state.ifaceName);
|
||
ifNameEl.addEventListener('input', function() { state.ifaceName = ifNameEl.value; });
|
||
var ifProtoSel = select([['static','Static'],['dhcp','DHCP'],['none','None']], state.ifaceProto);
|
||
ifProtoSel.addEventListener('change', function() { state.ifaceProto = ifProtoSel.value; });
|
||
var ifIpEl = txtInput(state.ifaceIp);
|
||
ifIpEl.addEventListener('input', function() { state.ifaceIp = ifIpEl.value; });
|
||
var ifMaskEl = txtInput(state.ifaceMask);
|
||
ifMaskEl.addEventListener('input', function() { state.ifaceMask = ifMaskEl.value; });
|
||
|
||
// ---- Step 4: DHCP ----
|
||
var dhStartEl = numInput(state.dhcpStart, 1, 250);
|
||
dhStartEl.addEventListener('input', function() { state.dhcpStart = dhStartEl.value; });
|
||
var dhLimitEl = numInput(state.dhcpLimit, 1, 250);
|
||
dhLimitEl.addEventListener('input', function() { state.dhcpLimit = dhLimitEl.value; });
|
||
var dhLeaseEl = txtInput(state.dhcpLease, '12h');
|
||
dhLeaseEl.addEventListener('input', function() { state.dhcpLease = dhLeaseEl.value; });
|
||
|
||
// ---- Step 5: Firewall zone ----
|
||
var zNameEl = txtInput(state.zoneName);
|
||
zNameEl.addEventListener('input', function() { state.zoneName = zNameEl.value; });
|
||
var zInSel = select([['REJECT','REJECT'],['ACCEPT','ACCEPT'],['DROP','DROP']], state.zoneInput);
|
||
zInSel.addEventListener('change', function() { state.zoneInput = zInSel.value; });
|
||
var zOutSel = select([['ACCEPT','ACCEPT'],['REJECT','REJECT'],['DROP','DROP']], state.zoneOutput);
|
||
zOutSel.addEventListener('change', function() { state.zoneOutput = zOutSel.value; });
|
||
var zFwdSel = select([['REJECT','REJECT'],['ACCEPT','ACCEPT'],['DROP','DROP']], state.zoneForward);
|
||
zFwdSel.addEventListener('change', function() { state.zoneForward = zFwdSel.value; });
|
||
var zDestSel = select(
|
||
(d.zones || []).map(function(z) { return [z.name, z.name]; }),
|
||
state.zoneForwardTo
|
||
);
|
||
zDestSel.addEventListener('change', function() { state.zoneForwardTo = zDestSel.value; });
|
||
|
||
// ---- Step 6: SSID attach ----
|
||
var ssidPickSel = select(
|
||
(d.wifi || []).map(function(w) { return [w.sid, (w.ssid || w.sid) + ' (' + w.device + ')']; }),
|
||
state.ssidPick
|
||
);
|
||
ssidPickSel.addEventListener('change', function() { state.ssidPick = ssidPickSel.value; });
|
||
var ssidNameEl = txtInput(state.ssidName);
|
||
ssidNameEl.addEventListener('input', function() { state.ssidName = ssidNameEl.value; });
|
||
var ssidEncSel = select([['psk2','WPA2-PSK'],['sae','WPA3-SAE'],['sae-mixed','WPA2/WPA3'],['none','Open']], state.ssidEnc);
|
||
ssidEncSel.addEventListener('change', function() { state.ssidEnc = ssidEncSel.value; });
|
||
var ssidPassEl = txtInput(state.ssidPass, 'min 8 chars');
|
||
ssidPassEl.addEventListener('input', function() { state.ssidPass = ssidPassEl.value; });
|
||
|
||
// ---- Build plan object ----
|
||
function buildPlan() {
|
||
var tagged = [], untagged = [];
|
||
Object.keys(state.ports).forEach(function(p) {
|
||
if (state.ports[p] === 't') tagged.push(p);
|
||
else if (state.ports[p] === 'u') untagged.push(p);
|
||
});
|
||
var bridge = (state.bridge === '__new__') ? (state.newBridge || 'br-vlan') : state.bridge;
|
||
var plan = {
|
||
vlans: [{
|
||
sid: 'vlan' + state.vlanId,
|
||
bridge: bridge,
|
||
vlan: state.vlanId,
|
||
tagged: tagged,
|
||
untagged: untagged,
|
||
purpose: ''
|
||
}]
|
||
};
|
||
if (state.ifaceOn) {
|
||
plan.interfaces = [{
|
||
name: state.ifaceName,
|
||
device: bridge + '.' + state.vlanId,
|
||
proto: state.ifaceProto,
|
||
ipaddr: state.ifaceIp,
|
||
netmask: state.ifaceMask
|
||
}];
|
||
}
|
||
if (state.dhcpOn) {
|
||
plan.dhcp = [{
|
||
name: state.ifaceName,
|
||
interface: state.ifaceName,
|
||
start: state.dhcpStart,
|
||
limit: state.dhcpLimit,
|
||
leasetime: state.dhcpLease
|
||
}];
|
||
}
|
||
if (state.zoneOn) {
|
||
plan.zones = [{
|
||
name: state.zoneName,
|
||
network: [state.ifaceName],
|
||
input: state.zoneInput,
|
||
output: state.zoneOutput,
|
||
forward: state.zoneForward,
|
||
masq: state.zoneMasq ? '1' : '0',
|
||
forward_to: state.zoneForwardTo
|
||
}];
|
||
}
|
||
if (state.ssidOn && !state.ssidNew && state.ssidPick) {
|
||
plan.attach_wifi = [{ sid: state.ssidPick, network: state.ifaceName }];
|
||
}
|
||
return plan;
|
||
}
|
||
|
||
// ---- Preview + Apply ----
|
||
var previewOut = E('pre', {
|
||
'style': 'background:#111;color:#0f0;padding:12px;border-radius:4px;' +
|
||
'font-size:0.85em;white-space:pre-wrap;max-height:300px;overflow:auto;' +
|
||
'margin:0;display:none;'
|
||
});
|
||
|
||
function updatePreview() {
|
||
var plan = buildPlan();
|
||
previewOut.style.display = 'block';
|
||
previewOut.textContent = JSON.stringify(plan, null, 2);
|
||
}
|
||
|
||
var previewBtn = E('button', { 'class': 'btn cbi-button',
|
||
'style': 'padding:8px 18px;',
|
||
'click': updatePreview }, _('Preview plan'));
|
||
|
||
var applyBtn = E('button', {
|
||
'class': 'btn cbi-button',
|
||
'style': 'background:#28a745 !important;background-color:#28a745 !important;' +
|
||
'color:#fff !important;padding:8px 22px;font-weight:bold;border:none;' +
|
||
'border-radius:4px;cursor:pointer;margin-left:8px;'
|
||
}, _('Apply to this router'));
|
||
applyBtn.addEventListener('click', function() {
|
||
var plan = buildPlan();
|
||
if (!confirm(_('Apply this VLAN plan to the router?\n\nA snapshot will be taken first. Network will reload.'))) return;
|
||
self.callVlan('apply', JSON.stringify(plan), '0').then(function(r) {
|
||
if (r && r.error) {
|
||
ui.addNotification(null, E('p', {}, 'Apply failed: ' + r.error), 'danger');
|
||
return;
|
||
}
|
||
ui.addNotification(null,
|
||
E('p', {}, _('VLAN applied. Network reloaded.')),
|
||
'info');
|
||
self.vlanShowSub('overview');
|
||
}).catch(function(err) {
|
||
ui.addNotification(null, E('p', {}, 'Apply failed: ' + (err.message || err)), 'danger');
|
||
});
|
||
});
|
||
|
||
var applySafeBtn = E('button', {
|
||
'class': 'btn cbi-button',
|
||
'style': 'background:#dc3545 !important;background-color:#dc3545 !important;' +
|
||
'color:#fff !important;padding:8px 22px;font-weight:bold;border:none;' +
|
||
'border-radius:4px;cursor:pointer;margin-left:8px;'
|
||
}, _('Apply with 90s rollback'));
|
||
applySafeBtn.addEventListener('click', function() {
|
||
var plan = buildPlan();
|
||
if (!confirm(_('Apply with 90-second rollback?\n\nIf you lose access, the network will auto-restore after 90 seconds.'))) return;
|
||
self.callVlan('apply', JSON.stringify(plan), '1').then(function(r) {
|
||
if (r && r.error) {
|
||
ui.addNotification(null, E('p', {}, 'Apply failed: ' + r.error), 'danger');
|
||
return;
|
||
}
|
||
ui.addNotification(null,
|
||
E('p', {}, _('Applied with rollback in 90s. Click Confirm in Safety subtab to keep.')),
|
||
'warning');
|
||
self.vlanShowSub('overview');
|
||
}).catch(function(err) {
|
||
ui.addNotification(null, E('p', {}, 'Apply failed: ' + (err.message || err)), 'danger');
|
||
});
|
||
});
|
||
|
||
var genBtn = E('button', {
|
||
'class': 'btn cbi-button',
|
||
'style': 'background:#17a2b8 !important;background-color:#17a2b8 !important;' +
|
||
'color:#fff !important;padding:8px 22px;font-weight:bold;border:none;' +
|
||
'border-radius:4px;cursor:pointer;margin-left:8px;'
|
||
}, _('Generate CLI for selected target'));
|
||
genBtn.addEventListener('click', function() {
|
||
var plan = buildPlan();
|
||
var target = (state.target === 'openwrt-dsa' || state.target === 'openwrt-swconfig') ? 'generic' : state.target;
|
||
self.callVlan('generate_cli', JSON.stringify(plan), target).then(function(r) {
|
||
if (r && r.cli) {
|
||
ui.showModal(_('Generated CLI for ') + target, [
|
||
E('pre', {
|
||
'style': 'background:#111;color:#0f0;padding:12px;border-radius:4px;' +
|
||
'font-size:0.85em;white-space:pre-wrap;max-height:60vh;overflow:auto;width:100%;' +
|
||
'box-sizing:border-box;'
|
||
}, r.cli),
|
||
E('div', { 'style': 'margin-top:14px;text-align:right;' }, [
|
||
E('button', { 'class': 'btn cbi-button', 'style': 'margin-right:8px;',
|
||
'click': function() {
|
||
navigator.clipboard && navigator.clipboard.writeText(r.cli);
|
||
ui.addNotification(null, E('p', {}, _('Copied to clipboard.')), 'info');
|
||
} }, _('Copy')),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action',
|
||
'click': function() {
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
} }, _('Close'))
|
||
])
|
||
]);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ---- Render ----
|
||
var step = function(title, body) {
|
||
return E('div', {
|
||
'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);' +
|
||
'border-radius:8px;padding:14px 18px;margin-bottom:14px;'
|
||
}, [
|
||
E('h4', { 'style': 'margin:0 0 12px 0;font-size:1.02em;color:#1e90ff;' }, title),
|
||
body
|
||
]);
|
||
};
|
||
|
||
var warn = E('div', {
|
||
'style': 'background:rgba(220,53,69,0.08);border:1px solid rgba(220,53,69,0.3);' +
|
||
'border-radius:6px;padding:12px 16px;margin-bottom:16px;font-size:0.9em;'
|
||
}, [
|
||
E('strong', { 'style': 'color:#c00;' }, _('⚠️ Connect via a LAN port you will NOT modify. ')),
|
||
_('Never apply VLAN changes over Wi-Fi or over the port you are changing.')
|
||
]);
|
||
|
||
return E('div', {}, [
|
||
warn,
|
||
step(_('Step 0 — Target platform'), field(_('Target'), targetSel,
|
||
_('Choose "this router" to apply directly. Other targets generate a CLI script to paste into the external device.'))),
|
||
step(_('Step 1 — VLAN ID and parent bridge'), E('div', {}, [
|
||
field(_('VLAN ID (2–4094)'), vidEl, _('Avoid VLAN 0, 1 and 4095.')),
|
||
field(_('Parent bridge'), bridgeSel),
|
||
(state.bridge === '__new__') ? field(_('New bridge name'), newBridgeEl) : null
|
||
].filter(Boolean))),
|
||
step(_('Step 2 — Port assignment'), E('div', {}, [
|
||
E('p', { 'style': 'font-size:0.88em;color:#888;margin:0 0 10px 0;' },
|
||
_('Choose per-port mode. Leave ports on "off" if unused.')),
|
||
rebuildPortTable()
|
||
])),
|
||
step(_('Step 3 — Logical interface'), E('div', {}, [
|
||
check(_('Create logical interface'), state.ifaceOn),
|
||
field(_('Interface name'), ifNameEl),
|
||
field(_('Protocol'), ifProtoSel),
|
||
field(_('IP address'), ifIpEl),
|
||
field(_('Netmask'), ifMaskEl)
|
||
])),
|
||
step(_('Step 4 — DHCP server'), E('div', {}, [
|
||
check(_('Create DHCP pool'), state.dhcpOn),
|
||
field(_('Start offset'), dhStartEl),
|
||
field(_('Client limit'), dhLimitEl),
|
||
field(_('Lease time'), dhLeaseEl)
|
||
])),
|
||
step(_('Step 5 — Firewall zone'), E('div', {}, [
|
||
check(_('Create / update firewall zone'), state.zoneOn),
|
||
field(_('Zone name'), zNameEl),
|
||
field(_('Input'), zInSel),
|
||
field(_('Output'), zOutSel),
|
||
field(_('Forward'), zFwdSel),
|
||
field(_('Forward to zone'), zDestSel)
|
||
])),
|
||
step(_('Step 6 — Attach SSID (optional)'), E('div', {}, [
|
||
check(_('Attach an existing SSID to this VLAN interface'), state.ssidOn),
|
||
state.ssidOn ? E('div', {}, [
|
||
field(_('Existing SSID'), ssidPickSel)
|
||
]) : null
|
||
].filter(Boolean))),
|
||
E('div', { 'style': 'margin-top:18px;display:flex;flex-wrap:wrap;gap:8px;align-items:center;' }, [
|
||
previewBtn, applyBtn, applySafeBtn, genBtn
|
||
]),
|
||
E('div', { 'style': 'margin-top:14px;' }, [previewOut])
|
||
]);
|
||
},
|
||
|
||
// ---------- Edit ----------
|
||
vlanRenderEdit: function() {
|
||
var self = this;
|
||
var d = self._vlanDetect || {};
|
||
|
||
function table(cols, rows, editFn) {
|
||
var t = E('table', { 'style': 'width:100%;border-collapse:collapse;font-size:0.9em;' });
|
||
var hr = E('tr', { 'style': 'text-align:left;border-bottom:2px solid rgba(0,0,0,0.1);' });
|
||
cols.forEach(function(c) { hr.appendChild(E('th', { 'style': 'padding:6px 8px;' }, c)); });
|
||
if (editFn) hr.appendChild(E('th', { 'style': 'padding:6px 8px;text-align:right;' }, _('')));
|
||
t.appendChild(hr);
|
||
rows.forEach(function(r) {
|
||
var tr = E('tr', { 'style': 'border-bottom:1px solid rgba(0,0,0,0.05);' });
|
||
r.forEach(function(cell) {
|
||
tr.appendChild(E('td', { 'style': 'padding:6px 8px;' }, cell));
|
||
});
|
||
if (editFn) {
|
||
tr.appendChild(E('td', { 'style': 'padding:6px 8px;text-align:right;' },
|
||
E('button', {
|
||
'class': 'btn cbi-button',
|
||
'style': 'padding:2px 10px;font-size:0.85em;',
|
||
'click': function() { editFn(r); }
|
||
}, _('Edit'))));
|
||
}
|
||
t.appendChild(tr);
|
||
});
|
||
return t;
|
||
}
|
||
|
||
function card(title, body) {
|
||
return E('div', {
|
||
'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);' +
|
||
'border-radius:8px;padding:14px 16px;margin-bottom:16px;'
|
||
}, [E('h4', { 'style': 'margin:0 0 10px 0;font-size:1em;' }, title), body]);
|
||
}
|
||
|
||
// Bridge VLAN editor
|
||
var vlanRows = (d.bridge_vlans || []).map(function(v) {
|
||
return [v.device || '', v.vlan || '', v.ports || '', v.sid || ''];
|
||
});
|
||
|
||
function editVlan(row) {
|
||
var sid = row[3];
|
||
var devEl = E('input', { 'type': 'text', 'value': row[0],
|
||
'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
var vidEl = E('input', { 'type': 'text', 'value': row[1],
|
||
'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
var portsEl = E('input', { 'type': 'text', 'value': row[2],
|
||
'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;font-family:monospace;' });
|
||
ui.showModal(_('Edit VLAN ' + sid), [
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Device')),
|
||
devEl
|
||
]),
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('VLAN ID')),
|
||
vidEl
|
||
]),
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Ports (space-separated, use :t for tagged)')),
|
||
portsEl
|
||
]),
|
||
E('div', { 'style': 'margin-top:14px;text-align:right;' }, [
|
||
E('button', { 'class': 'btn cbi-button', 'style': 'margin-right:8px;',
|
||
'click': function() {
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
} }, _('Cancel')),
|
||
(function() {
|
||
var b = E('button', { 'class': 'btn cbi-button',
|
||
'style': 'background:#28a745 !important;background-color:#28a745 !important;color:#fff !important;padding:8px 22px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;' }, _('Save'));
|
||
b.addEventListener('click', function() {
|
||
uci.load('network').then(function() {
|
||
uci.set('network', sid, 'device', devEl.value);
|
||
uci.set('network', sid, 'vlan', vidEl.value);
|
||
uci.set('network', sid, 'ports', portsEl.value.split(/\s+/));
|
||
return uci.save();
|
||
}).then(function() {
|
||
ui.addNotification(null, E('p', {}, _('Saved to memory. Use Save & Apply at the top to commit.')), 'info');
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
self.vlanShowSub('edit');
|
||
self.refreshPendingCount();
|
||
});
|
||
});
|
||
return b;
|
||
})()
|
||
])
|
||
]);
|
||
}
|
||
|
||
// Interface editor
|
||
var ifRows = (d.interfaces || []).map(function(i) {
|
||
return [i.sid || '', i.device || '', i.proto || '', i.ipaddr || '', i.netmask || ''];
|
||
});
|
||
|
||
function editIface(row) {
|
||
var sid = row[0];
|
||
var devEl = E('input', { 'type': 'text', 'value': row[1],
|
||
'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
var protoEl = E('input', { 'type': 'text', 'value': row[2],
|
||
'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
var ipEl = E('input', { 'type': 'text', 'value': row[3],
|
||
'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
var maskEl = E('input', { 'type': 'text', 'value': row[4],
|
||
'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
ui.showModal(_('Edit interface ' + sid), [
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Device')), devEl]),
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Protocol')), protoEl]),
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('IP address')), ipEl]),
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Netmask')), maskEl]),
|
||
E('div', { 'style': 'margin-top:14px;text-align:right;' }, [
|
||
E('button', { 'class': 'btn cbi-button', 'style': 'margin-right:8px;',
|
||
'click': function() {
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
} }, _('Cancel')),
|
||
(function() {
|
||
var b = E('button', { 'class': 'btn cbi-button',
|
||
'style': 'background:#28a745 !important;background-color:#28a745 !important;color:#fff !important;padding:8px 22px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;' }, _('Save'));
|
||
b.addEventListener('click', function() {
|
||
uci.load('network').then(function() {
|
||
uci.set('network', sid, 'device', devEl.value);
|
||
uci.set('network', sid, 'proto', protoEl.value);
|
||
if (ipEl.value) uci.set('network', sid, 'ipaddr', ipEl.value);
|
||
if (maskEl.value) uci.set('network', sid, 'netmask', maskEl.value);
|
||
return uci.save();
|
||
}).then(function() {
|
||
ui.addNotification(null, E('p', {}, _('Saved to memory.')), 'info');
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
self.vlanShowSub('edit');
|
||
self.refreshPendingCount();
|
||
});
|
||
});
|
||
return b;
|
||
})()
|
||
])
|
||
]);
|
||
}
|
||
|
||
return E('div', {}, [
|
||
E('p', { 'style': 'color:#888;font-size:0.9em;' },
|
||
_('Edit existing configuration. Every change is saved to memory; use the top-bar Save & Apply to commit.')),
|
||
card(_('Bridge-VLANs') + ' (' + vlanRows.length + ')',
|
||
table([_('Device'), _('VLAN'), _('Ports'), _('Section')], vlanRows, editVlan)),
|
||
card(_('Interfaces') + ' (' + ifRows.length + ')',
|
||
table([_('Name'), _('Device'), _('Proto'), _('IP'), _('Netmask')], ifRows, editIface)),
|
||
E('div', { 'style': 'margin-top:12px;text-align:right;' }, [
|
||
(function() {
|
||
var b = E('button', { 'class': 'btn cbi-button',
|
||
'style': 'background:#28a745 !important;background-color:#28a745 !important;color:#fff !important;padding:8px 22px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;' }, _('Save & Apply all VLAN changes'));
|
||
b.addEventListener('click', function() {
|
||
uci.save().then(function() {
|
||
return ui.changes.apply(false);
|
||
}).then(function() {
|
||
ui.addNotification(null, E('p', {}, _('Changes applied.')), 'info');
|
||
self.vlanShowSub('overview');
|
||
}).catch(function(err) {
|
||
var m = (err && err.message) ? err.message : String(err);
|
||
if (/No data|code 5/i.test(m)) return;
|
||
ui.addNotification(null, E('p', {}, 'Apply failed: ' + m), 'danger');
|
||
});
|
||
});
|
||
return b;
|
||
})()
|
||
])
|
||
]);
|
||
},
|
||
|
||
// ---------- Import ----------
|
||
vlanRenderImport: function() {
|
||
var self = this;
|
||
|
||
var textarea = E('textarea', {
|
||
'spellcheck': 'false',
|
||
'style': 'width:100%;min-height:240px;font-family:monospace;font-size:0.88em;' +
|
||
'padding:10px;border:1px solid #ccc;border-radius:4px;box-sizing:border-box;',
|
||
'placeholder': '/interface bridge vlan\nadd bridge=bridge1 tagged=ether1,ether2 untagged=ether3 vlan-ids=10'
|
||
});
|
||
|
||
var formatSel = E('select', { 'style': 'width:100%;max-width:400px;padding:8px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
[['auto','Auto-detect'],
|
||
['mikrotik','MikroTik RouterOS'],
|
||
['cisco','Cisco IOS / NX-OS'],
|
||
['edgeos','EdgeOS / EdgeSwitch'],
|
||
['generic','Generic 802.1Q'],
|
||
['json','JSON (from this app)']].forEach(function(o) {
|
||
formatSel.appendChild(E('option', { 'value': o[0] }, o[1]));
|
||
});
|
||
|
||
var preview = E('pre', {
|
||
'style': 'background:#111;color:#0f0;padding:12px;border-radius:4px;font-size:0.85em;' +
|
||
'white-space:pre-wrap;max-height:320px;overflow:auto;margin-top:14px;display:none;'
|
||
});
|
||
|
||
// ---- Parsers ----
|
||
function parseMikrotik(txt) {
|
||
var vlans = {};
|
||
txt.split('\n').forEach(function(line) {
|
||
if (line.indexOf('vlan-ids=') === -1) return;
|
||
var bridge = (line.match(/bridge=([^\s]+)/) || [])[1] || 'bridge1';
|
||
var vid = (line.match(/vlan-ids=([^\s]+)/) || [])[1];
|
||
if (!vid) return;
|
||
var tagged = ((line.match(/tagged=([^\s]+)/) || [])[1] || '').split(',').filter(Boolean);
|
||
var untagged = ((line.match(/untagged=([^\s]+)/) || [])[1] || '').split(',').filter(Boolean);
|
||
vlans[vid] = { sid: 'vlan' + vid, bridge: bridge, vlan: vid, tagged: tagged, untagged: untagged };
|
||
});
|
||
return { vlans: Object.keys(vlans).map(function(k) { return vlans[k]; }) };
|
||
}
|
||
|
||
function parseCisco(txt) {
|
||
var vlans = {};
|
||
var curVid = null, curIface = null, allowed = {}, native = {};
|
||
txt.split('\n').forEach(function(line) {
|
||
var l = line.trim();
|
||
if (/^vlan \d+/.test(l)) {
|
||
curVid = l.match(/\d+/)[0];
|
||
} else if (/^interface\s+(.+)/.test(l)) {
|
||
curIface = l.match(/^interface\s+(.+)/)[1];
|
||
} else if (/switchport trunk allowed vlan/i.test(l)) {
|
||
var m = l.match(/vlan\s+(.+)/i);
|
||
if (m) allowed[curIface] = m[1].split(',').map(function(x) { return x.trim(); });
|
||
} else if (/switchport trunk native vlan/i.test(l)) {
|
||
var n = l.match(/vlan\s+(\d+)/i);
|
||
if (n) native[curIface] = n[1];
|
||
} else if (/switchport access vlan/i.test(l)) {
|
||
var a = l.match(/vlan\s+(\d+)/i);
|
||
if (a && curIface) {
|
||
if (!vlans[a[1]]) vlans[a[1]] = { sid: 'vlan' + a[1], bridge: 'br-lan', vlan: a[1], tagged: [], untagged: [] };
|
||
vlans[a[1]].untagged.push(curIface);
|
||
}
|
||
}
|
||
});
|
||
Object.keys(allowed).forEach(function(iface) {
|
||
allowed[iface].forEach(function(v) {
|
||
if (!vlans[v]) vlans[v] = { sid: 'vlan' + v, bridge: 'br-lan', vlan: v, tagged: [], untagged: [] };
|
||
vlans[v].tagged.push(iface);
|
||
});
|
||
});
|
||
return { vlans: Object.keys(vlans).map(function(k) { return vlans[k]; }) };
|
||
}
|
||
|
||
function parseEdgeos(txt) {
|
||
var vlans = {};
|
||
txt.split('\n').forEach(function(line) {
|
||
var m = line.match(/set interfaces ethernet (\S+) vif (\d+)/);
|
||
if (m) {
|
||
var iface = m[1], vid = m[2];
|
||
if (!vlans[vid]) vlans[vid] = { sid: 'vlan' + vid, bridge: 'br-lan', vlan: vid, tagged: [], untagged: [] };
|
||
vlans[vid].tagged.push(iface);
|
||
}
|
||
var p = line.match(/set interfaces ethernet (\S+) pvid (\d+)/);
|
||
if (p) {
|
||
var if2 = p[1], vid2 = p[2];
|
||
if (!vlans[vid2]) vlans[vid2] = { sid: 'vlan' + vid2, bridge: 'br-lan', vlan: vid2, tagged: [], untagged: [] };
|
||
vlans[vid2].untagged.push(if2);
|
||
}
|
||
});
|
||
return { vlans: Object.keys(vlans).map(function(k) { return vlans[k]; }) };
|
||
}
|
||
|
||
function parseGeneric(txt) {
|
||
var vlans = {};
|
||
txt.split('\n').forEach(function(line) {
|
||
var m = line.match(/VLAN\s+(\d+)\s+tagged:\s*([^\s]+)\s+untagged:\s*([^\s]+)/i);
|
||
if (!m) return;
|
||
var vid = m[1];
|
||
vlans[vid] = {
|
||
sid: 'vlan' + vid, bridge: 'br-lan', vlan: vid,
|
||
tagged: m[2].split(',').filter(Boolean),
|
||
untagged: m[3].split(',').filter(Boolean)
|
||
};
|
||
});
|
||
return { vlans: Object.keys(vlans).map(function(k) { return vlans[k]; }) };
|
||
}
|
||
|
||
function parseInput(txt, fmt) {
|
||
var t = txt.trim();
|
||
if (fmt === 'json' || t.startsWith('{')) return JSON.parse(t);
|
||
if (fmt === 'mikrotik' || (fmt === 'auto' && /\/interface bridge vlan/.test(t))) return parseMikrotik(t);
|
||
if (fmt === 'cisco' || (fmt === 'auto' && /switchport (trunk|mode)/i.test(t))) return parseCisco(t);
|
||
if (fmt === 'edgeos' || (fmt === 'auto' && /set interfaces ethernet/.test(t))) return parseEdgeos(t);
|
||
if (fmt === 'generic' || (fmt === 'auto' && /VLAN \d+/.test(t))) return parseGeneric(t);
|
||
throw new Error('Could not detect format. Please pick one manually.');
|
||
}
|
||
|
||
var parseBtn = E('button', { 'class': 'btn cbi-button',
|
||
'style': 'padding:8px 18px;',
|
||
'click': function() {
|
||
try {
|
||
var plan = parseInput(textarea.value, formatSel.value);
|
||
preview.style.display = 'block';
|
||
preview.textContent = JSON.stringify(plan, null, 2);
|
||
window._vlanImportedPlan = plan;
|
||
ui.addNotification(null, E('p', {},
|
||
(plan.vlans || []).length + ' VLAN(s) parsed. Click Apply to write them.'),
|
||
'info');
|
||
} catch (e) {
|
||
preview.style.display = 'block';
|
||
preview.textContent = 'Error: ' + (e.message || e);
|
||
window._vlanImportedPlan = null;
|
||
}
|
||
} }, _('Parse'));
|
||
|
||
var applyBtn = E('button', {
|
||
'class': 'btn cbi-button',
|
||
'style': 'background:#28a745 !important;background-color:#28a745 !important;color:#fff !important;' +
|
||
'padding:8px 22px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;margin-left:8px;'
|
||
}, _('Apply to this router'));
|
||
applyBtn.addEventListener('click', function() {
|
||
var plan = window._vlanImportedPlan;
|
||
if (!plan || !plan.vlans || !plan.vlans.length) {
|
||
ui.addNotification(null, E('p', {}, 'Nothing to apply — parse first.'), 'warning');
|
||
return;
|
||
}
|
||
if (!confirm('Apply ' + plan.vlans.length + ' VLAN(s) to this router? Snapshot will be taken first.')) return;
|
||
self.callVlan('apply', JSON.stringify(plan), '0').then(function(r) {
|
||
if (r && r.error) {
|
||
ui.addNotification(null, E('p', {}, 'Apply failed: ' + r.error), 'danger');
|
||
return;
|
||
}
|
||
ui.addNotification(null, E('p', {}, 'Applied.'), 'info');
|
||
self.vlanShowSub('overview');
|
||
}).catch(function(err) {
|
||
ui.addNotification(null, E('p', {}, 'Apply failed: ' + (err.message || err)), 'danger');
|
||
});
|
||
});
|
||
|
||
var genBtn = E('button', {
|
||
'class': 'btn cbi-button',
|
||
'style': 'background:#17a2b8 !important;background-color:#17a2b8 !important;color:#fff !important;' +
|
||
'padding:8px 22px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;margin-left:8px;'
|
||
}, _('Generate CLI'));
|
||
genBtn.addEventListener('click', function() {
|
||
var plan = window._vlanImportedPlan;
|
||
if (!plan || !plan.vlans || !plan.vlans.length) {
|
||
ui.addNotification(null, E('p', {}, 'Parse first.'), 'warning');
|
||
return;
|
||
}
|
||
ui.showModal(_('Generate CLI for'), [
|
||
E('div', { 'style': 'display:flex;gap:8px;flex-wrap:wrap;' }, [
|
||
['mikrotik','MikroTik'],['cisco','Cisco'],['edgeos','EdgeOS'],['generic','Generic']
|
||
.map(function(t) {
|
||
return E('button', { 'class': 'btn cbi-button', 'style': 'padding:8px 16px;',
|
||
'click': function() {
|
||
self.callVlan('generate_cli', JSON.stringify(plan), t[0]).then(function(r) {
|
||
if (r && r.cli) {
|
||
ui.showModal(_('Generated ') + t[1] + _(' CLI'), [
|
||
E('pre', { 'style': 'background:#111;color:#0f0;padding:12px;border-radius:4px;font-size:0.85em;white-space:pre-wrap;max-height:60vh;overflow:auto;width:100%;box-sizing:border-box;' }, r.cli),
|
||
E('div', { 'style': 'margin-top:14px;text-align:right;' }, [
|
||
E('button', { 'class': 'btn cbi-button',
|
||
'click': function() {
|
||
navigator.clipboard && navigator.clipboard.writeText(r.cli);
|
||
ui.addNotification(null, E('p', {}, 'Copied.'), 'info');
|
||
} }, _('Copy')),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action',
|
||
'style': 'margin-left:8px;',
|
||
'click': function() {
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
} }, _('Close'))
|
||
])
|
||
]);
|
||
}
|
||
});
|
||
} }, t[1]);
|
||
})
|
||
])
|
||
]);
|
||
});
|
||
|
||
return E('div', {}, [
|
||
E('p', { 'style': 'color:#888;font-size:0.9em;' },
|
||
_('Paste a VLAN configuration from another platform. Parsers run in your browser — nothing is sent to the router until you click Apply.')),
|
||
E('div', { 'style': 'margin-bottom:12px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Format')),
|
||
formatSel
|
||
]),
|
||
textarea,
|
||
E('div', { 'style': 'margin-top:14px;' }, [parseBtn, applyBtn, genBtn]),
|
||
preview
|
||
]);
|
||
},
|
||
|
||
// ---------- SSID ----------
|
||
vlanRenderSsid: function() {
|
||
var self = this;
|
||
var d = self._vlanDetect || {};
|
||
var ifaces = d.wifi || [];
|
||
var nets = (d.interfaces || []).map(function(i) { return i.sid; }).sort();
|
||
|
||
function reload() {
|
||
self.callVlan('detect').then(function(dd) {
|
||
self._vlanDetect = dd;
|
||
self.vlanShowSub('ssid');
|
||
});
|
||
}
|
||
|
||
function openEdit(w) {
|
||
var sid = w.sid;
|
||
var ssidEl = E('input', { 'type': 'text', 'value': w.ssid || '',
|
||
'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
var encEl = E('select', { 'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
[['psk2','WPA2-PSK'],['sae','WPA3-SAE'],['sae-mixed','WPA2/WPA3'],
|
||
['psk-mixed','WPA/WPA2'],['psk','WPA-PSK'],['owe','OWE'],
|
||
['none','Open']].forEach(function(o) {
|
||
var opt = E('option', { 'value': o[0] }, o[1]);
|
||
if (o[0] === (uci.get('wireless', sid, 'encryption') || 'psk2')) opt.selected = true;
|
||
encEl.appendChild(opt);
|
||
});
|
||
var keyEl = E('input', { 'type': 'text',
|
||
'value': uci.get('wireless', sid, 'key') || uci.get('wireless', sid, 'key1') || '',
|
||
'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;font-family:monospace;' });
|
||
var netEl = E('select', { 'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
netEl.appendChild(E('option', { 'value': '' }, _('— none —')));
|
||
nets.forEach(function(n) {
|
||
var opt = E('option', { 'value': n }, n);
|
||
if (n === w.network) opt.selected = true;
|
||
netEl.appendChild(opt);
|
||
});
|
||
var isoCb = E('input', { 'type': 'checkbox' });
|
||
if (uci.get('wireless', sid, 'isolate') === '1') isoCb.checked = true;
|
||
|
||
var saveBtn = E('button', { 'class': 'btn cbi-button',
|
||
'style': 'background:#28a745 !important;background-color:#28a745 !important;color:#fff !important;padding:8px 22px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;' }, _('Save & Apply'));
|
||
saveBtn.addEventListener('click', function() {
|
||
uci.load('wireless').then(function() {
|
||
uci.set('wireless', sid, 'ssid', ssidEl.value);
|
||
uci.set('wireless', sid, 'encryption', encEl.value);
|
||
if (encEl.value !== 'none' && encEl.value !== 'owe') {
|
||
uci.set('wireless', sid, 'key', keyEl.value);
|
||
}
|
||
if (netEl.value) uci.set('wireless', sid, 'network', netEl.value);
|
||
uci.set('wireless', sid, 'isolate', isoCb.checked ? '1' : '0');
|
||
return uci.save();
|
||
}).then(function() {
|
||
return ui.changes.apply(false);
|
||
}).then(function() {
|
||
ui.addNotification(null, E('p', {}, _('SSID saved and applied.')), 'info');
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
reload();
|
||
}).catch(function(err) {
|
||
var m = (err && err.message) ? err.message : String(err);
|
||
if (/No data|code 5/i.test(m)) { reload(); return; }
|
||
ui.addNotification(null, E('p', {}, 'Failed: ' + m), 'danger');
|
||
});
|
||
});
|
||
|
||
ui.showModal(_('Edit SSID: ') + (w.ssid || sid), [
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('SSID')), ssidEl]),
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Encryption')), encEl]),
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Password')), keyEl]),
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Attach to network (VLAN interface)')), netEl]),
|
||
E('div', { 'style': 'margin-bottom:10px;display:flex;align-items:center;gap:8px;' }, [isoCb, E('label', { 'style': 'font-size:0.9em;' }, _('Client isolation'))]),
|
||
E('div', { 'style': 'margin-top:14px;text-align:right;' }, [
|
||
E('button', { 'class': 'btn cbi-button', 'style': 'margin-right:8px;',
|
||
'click': function() {
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
} }, _('Cancel')),
|
||
saveBtn
|
||
])
|
||
]);
|
||
}
|
||
|
||
var t = E('table', { 'style': 'width:100%;border-collapse:collapse;font-size:0.9em;' });
|
||
t.appendChild(E('tr', { 'style': 'text-align:left;border-bottom:2px solid rgba(0,0,0,0.1);' }, [
|
||
E('th', { 'style': 'padding:8px;' }, _('SSID')),
|
||
E('th', { 'style': 'padding:8px;' }, _('Radio')),
|
||
E('th', { 'style': 'padding:8px;' }, _('Mode')),
|
||
E('th', { 'style': 'padding:8px;' }, _('Network')),
|
||
E('th', { 'style': 'padding:8px;text-align:right;' }, _(''))
|
||
]));
|
||
ifaces.forEach(function(w) {
|
||
var tr = E('tr', { 'style': 'border-bottom:1px solid rgba(0,0,0,0.05);' }, [
|
||
E('td', { 'style': 'padding:8px;' }, w.ssid || w.sid),
|
||
E('td', { 'style': 'padding:8px;font-family:monospace;' }, w.device || '—'),
|
||
E('td', { 'style': 'padding:8px;' }, w.mode || 'ap'),
|
||
E('td', { 'style': 'padding:8px;' }, w.network || '—'),
|
||
E('td', { 'style': 'padding:8px;text-align:right;' },
|
||
E('button', { 'class': 'btn cbi-button',
|
||
'style': 'padding:2px 12px;font-size:0.85em;',
|
||
'click': function() { openEdit(w); } }, _('Edit')))
|
||
]);
|
||
t.appendChild(tr);
|
||
});
|
||
|
||
return E('div', {}, [
|
||
E('p', { 'style': 'color:#888;font-size:0.9em;' },
|
||
_('Every wireless interface on every radio — attach any SSID to a VLAN network with one click.')),
|
||
t
|
||
]);
|
||
},
|
||
|
||
// ---------- Library ----------
|
||
vlanRenderLibrary: function() {
|
||
var self = this;
|
||
|
||
function card(title, body) {
|
||
return E('div', { 'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);border-radius:8px;padding:14px 16px;margin-bottom:16px;' },
|
||
[E('h4', { 'style': 'margin:0 0 10px 0;font-size:1em;' }, title), body]);
|
||
}
|
||
|
||
var tplOut = E('div', { 'id': 'qa-vlan-tpl-list' });
|
||
var genOut = E('div', { 'id': 'qa-vlan-gen-list' });
|
||
|
||
function loadTemplates() {
|
||
tplOut.innerHTML = '';
|
||
tplOut.appendChild(E('p', { 'style': 'color:#888;padding:10px;' }, _('Loading...')));
|
||
self.callVlan('list_templates').then(function(r) {
|
||
var items = (r && r.templates) || [];
|
||
tplOut.innerHTML = '';
|
||
if (!items.length) {
|
||
tplOut.appendChild(E('p', { 'style': 'color:#888;padding:6px;' },
|
||
_('No templates yet. Save one from the Wizard after you build a plan.')));
|
||
return;
|
||
}
|
||
var t = E('table', { 'style': 'width:100%;border-collapse:collapse;font-size:0.9em;' });
|
||
t.appendChild(E('tr', { 'style': 'text-align:left;border-bottom:2px solid rgba(0,0,0,0.1);' }, [
|
||
E('th', { 'style': 'padding:6px 8px;' }, _('Name')),
|
||
E('th', { 'style': 'padding:6px 8px;' }, _('Target')),
|
||
E('th', { 'style': 'padding:6px 8px;text-align:right;' }, _(''))
|
||
]));
|
||
items.forEach(function(it) {
|
||
var meta = {};
|
||
try { meta = JSON.parse(it.meta || '{}'); } catch(e) {}
|
||
t.appendChild(E('tr', { 'style': 'border-bottom:1px solid rgba(0,0,0,0.05);' }, [
|
||
E('td', { 'style': 'padding:6px 8px;font-family:monospace;' }, it.name),
|
||
E('td', { 'style': 'padding:6px 8px;' }, meta.target || '—'),
|
||
E('td', { 'style': 'padding:6px 8px;text-align:right;' }, [
|
||
E('button', { 'class': 'btn cbi-button',
|
||
'style': 'padding:2px 10px;font-size:0.85em;margin-right:4px;',
|
||
'click': function() {
|
||
self.callVlan('load_template', it.name).then(function(rr) {
|
||
try {
|
||
window._vlanLoadedTemplate = JSON.parse(rr.plan || '{}');
|
||
ui.addNotification(null, E('p', {},
|
||
_('Template loaded. Switch to Wizard to edit and apply.')),
|
||
'info');
|
||
} catch(e) {
|
||
ui.addNotification(null, E('p', {}, 'Bad template JSON'), 'danger');
|
||
}
|
||
});
|
||
} }, _('Load')),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-negative',
|
||
'style': 'padding:2px 10px;font-size:0.85em;',
|
||
'click': function() {
|
||
if (!confirm(_('Delete template "' + it.name + '"?'))) return;
|
||
self.callVlan('delete_template', it.name).then(function() {
|
||
ui.addNotification(null, E('p', {}, _('Deleted')), 'info');
|
||
loadTemplates();
|
||
});
|
||
} }, _('Delete'))
|
||
])
|
||
]));
|
||
});
|
||
tplOut.appendChild(t);
|
||
});
|
||
}
|
||
|
||
function loadGenerated() {
|
||
genOut.innerHTML = '';
|
||
genOut.appendChild(E('p', { 'style': 'color:#888;padding:10px;' }, _('Loading...')));
|
||
self.callVlan('list_generated').then(function(r) {
|
||
var items = (r && r.generated) || [];
|
||
genOut.innerHTML = '';
|
||
if (!items.length) {
|
||
genOut.appendChild(E('p', { 'style': 'color:#888;padding:6px;' },
|
||
_('No generated scripts yet. Use Generate CLI in the Wizard.')));
|
||
return;
|
||
}
|
||
var t = E('table', { 'style': 'width:100%;border-collapse:collapse;font-size:0.9em;' });
|
||
t.appendChild(E('tr', { 'style': 'text-align:left;border-bottom:2px solid rgba(0,0,0,0.1);' }, [
|
||
E('th', { 'style': 'padding:6px 8px;' }, _('Name')),
|
||
E('th', { 'style': 'padding:6px 8px;' }, _('Target')),
|
||
E('th', { 'style': 'padding:6px 8px;text-align:right;' }, _(''))
|
||
]));
|
||
items.forEach(function(it) {
|
||
t.appendChild(E('tr', { 'style': 'border-bottom:1px solid rgba(0,0,0,0.05);' }, [
|
||
E('td', { 'style': 'padding:6px 8px;font-family:monospace;' }, it.name),
|
||
E('td', { 'style': 'padding:6px 8px;' }, it.target || '—'),
|
||
E('td', { 'style': 'padding:6px 8px;text-align:right;' },
|
||
E('button', { 'class': 'btn cbi-button cbi-button-negative',
|
||
'style': 'padding:2px 10px;font-size:0.85em;',
|
||
'click': function() {
|
||
if (!confirm(_('Delete generated script "' + it.name + '"?'))) return;
|
||
self.callVlan('delete_generated', it.name).then(function() {
|
||
loadGenerated();
|
||
});
|
||
} }, _('Delete')))
|
||
]));
|
||
});
|
||
genOut.appendChild(t);
|
||
});
|
||
}
|
||
|
||
var saveBtn = E('button', { 'class': 'btn cbi-button',
|
||
'style': 'padding:8px 18px;',
|
||
'click': function() {
|
||
var plan = window._vlanImportedPlan || window._vlanLoadedTemplate;
|
||
if (!plan || !plan.vlans) {
|
||
ui.addNotification(null, E('p', {}, _('Build a plan in the Wizard or Import first.')), 'warning');
|
||
return;
|
||
}
|
||
var nameEl = E('input', { 'type': 'text', 'placeholder': 'template-name',
|
||
'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
var targetSel = E('select', { 'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
['openwrt-dsa','openwrt-swconfig','mikrotik','cisco','edgeos','generic'].forEach(function(v) {
|
||
targetSel.appendChild(E('option', { 'value': v }, v));
|
||
});
|
||
var notesEl = E('input', { 'type': 'text',
|
||
'style': 'width:100%;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
|
||
var save = E('button', { 'class': 'btn cbi-button',
|
||
'style': 'background:#28a745 !important;background-color:#28a745 !important;color:#fff !important;padding:8px 22px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;' }, _('Save'));
|
||
save.addEventListener('click', function() {
|
||
if (!nameEl.value.trim()) { ui.addNotification(null, E('p', {}, 'Name required'), 'danger'); return; }
|
||
self.callVlan('save_template', nameEl.value.trim(), targetSel.value, JSON.stringify(plan), notesEl.value).then(function() {
|
||
ui.addNotification(null, E('p', {}, _('Template saved')), 'info');
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
loadTemplates();
|
||
});
|
||
});
|
||
|
||
ui.showModal(_('Save current plan as template'), [
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Name')), nameEl]),
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Target platform')), targetSel]),
|
||
E('div', { 'style': 'margin-bottom:10px;' }, [E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:4px;' }, _('Notes')), notesEl]),
|
||
E('div', { 'style': 'margin-top:14px;text-align:right;' }, [
|
||
E('button', { 'class': 'btn cbi-button', 'style': 'margin-right:8px;',
|
||
'click': function() {
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
} }, _('Cancel')),
|
||
save
|
||
])
|
||
]);
|
||
}
|
||
}, _('Save current plan as template'));
|
||
|
||
loadTemplates();
|
||
loadGenerated();
|
||
|
||
return E('div', {}, [
|
||
E('p', { 'style': 'color:#888;font-size:0.9em;' },
|
||
_('Reusable VLAN schemes. Templates can be loaded back into the Wizard and applied on this or another router.')),
|
||
E('div', { 'style': 'margin-bottom:14px;' }, [saveBtn]),
|
||
card(_('Templates'), tplOut),
|
||
card(_('Generated scripts'), genOut)
|
||
]);
|
||
},
|
||
|
||
// ---------- Safety ----------
|
||
vlanRenderSafety: function() {
|
||
var self = this;
|
||
|
||
function card(title, body) {
|
||
return E('div', { 'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);border-radius:8px;padding:14px 16px;margin-bottom:16px;' },
|
||
[E('h4', { 'style': 'margin:0 0 10px 0;font-size:1em;' }, title), body]);
|
||
}
|
||
|
||
var listOut = E('div');
|
||
var pendingOut = E('div');
|
||
|
||
function load() {
|
||
pendingOut.innerHTML = '';
|
||
listOut.innerHTML = '';
|
||
self.callVlan('list_snapshots').then(function(r) {
|
||
// Pending rollback warning
|
||
if (r && r.pending) {
|
||
var dl = parseInt(r.pending, 10);
|
||
var now = Math.floor(Date.now() / 1000);
|
||
var left = dl - now;
|
||
if (left > 0) {
|
||
pendingOut.appendChild(E('div', {
|
||
'style': 'background:rgba(220,53,69,0.12);border:1px solid rgba(220,53,69,0.4);' +
|
||
'border-radius:6px;padding:12px 16px;margin-bottom:14px;font-size:0.95em;'
|
||
}, [
|
||
E('strong', { 'style': 'color:#c00;' }, _('⚠️ Pending auto-rollback in ' + left + 's. ')),
|
||
_('Click Confirm to keep the changes, or do nothing to revert.'),
|
||
E('button', { 'class': 'btn cbi-button', 'style': 'margin-left:12px;padding:6px 16px;background:#28a745 !important;color:#fff !important;border:none;',
|
||
'click': function() {
|
||
self.callVlan('confirm').then(function() {
|
||
ui.addNotification(null, E('p', {}, _('Rollback cancelled.')), 'info');
|
||
load();
|
||
});
|
||
} }, _('Confirm'))
|
||
]));
|
||
}
|
||
}
|
||
|
||
var items = (r && r.snapshots) || [];
|
||
if (!items.length) {
|
||
listOut.appendChild(E('p', { 'style': 'color:#888;' }, _('No snapshots yet.')));
|
||
return;
|
||
}
|
||
var t = E('table', { 'style': 'width:100%;border-collapse:collapse;font-size:0.9em;' });
|
||
t.appendChild(E('tr', { 'style': 'text-align:left;border-bottom:2px solid rgba(0,0,0,0.1);' }, [
|
||
E('th', { 'style': 'padding:6px 8px;' }, _('Timestamp')),
|
||
E('th', { 'style': 'padding:6px 8px;' }, _('Size')),
|
||
E('th', { 'style': 'padding:6px 8px;' }, _('Note')),
|
||
E('th', { 'style': 'padding:6px 8px;text-align:right;' }, _(''))
|
||
]));
|
||
items.forEach(function(it) {
|
||
var fname = (it.path || '').split('/').pop();
|
||
t.appendChild(E('tr', { 'style': 'border-bottom:1px solid rgba(0,0,0,0.05);' }, [
|
||
E('td', { 'style': 'padding:6px 8px;font-family:monospace;' }, fname),
|
||
E('td', { 'style': 'padding:6px 8px;' }, (it.size || 0) + ' B'),
|
||
E('td', { 'style': 'padding:6px 8px;' }, it.note || '—'),
|
||
E('td', { 'style': 'padding:6px 8px;text-align:right;' },
|
||
E('button', { 'class': 'btn cbi-button cbi-button-action',
|
||
'style': 'padding:2px 12px;font-size:0.85em;',
|
||
'click': function() {
|
||
if (!confirm(_('Restore from ' + fname + '?\n\nNetwork will reload.'))) return;
|
||
self.callVlan('restore', it.path).then(function() {
|
||
ui.addNotification(null, E('p', {}, _('Restored.')), 'info');
|
||
setTimeout(function() { window.location.reload(); }, 1500);
|
||
});
|
||
} }, _('Restore')))
|
||
]));
|
||
});
|
||
listOut.appendChild(t);
|
||
});
|
||
}
|
||
|
||
var snapBtn = E('button', { 'class': 'btn cbi-button cbi-button-action',
|
||
'style': 'padding:8px 18px;',
|
||
'click': function() {
|
||
var note = prompt(_('Snapshot note (optional):'), 'manual');
|
||
if (note === null) return;
|
||
self.callVlan('snapshot', note || 'manual').then(function(r) {
|
||
if (r && r.path) {
|
||
ui.addNotification(null, E('p', {}, _('Snapshot saved: ') + r.path), 'info');
|
||
load();
|
||
}
|
||
});
|
||
} }, _('📸 Snapshot now'));
|
||
|
||
load();
|
||
|
||
return E('div', {}, [
|
||
pendingOut,
|
||
card(_('Snapshot / Restore'),
|
||
E('div', {}, [
|
||
E('p', { 'style': 'color:#888;font-size:0.9em;' },
|
||
_('Every VLAN apply takes an automatic snapshot first. Restore if something goes wrong.')),
|
||
E('div', { 'style': 'margin-bottom:14px;' }, [snapBtn]),
|
||
listOut
|
||
]))
|
||
]);
|
||
},
|
||
|
||
// ---------- Diagnostics ----------
|
||
vlanRenderDiagnostics: function() {
|
||
var self = this;
|
||
|
||
// Load kernel interfaces once, cache for both dropdowns.
|
||
var kernelLoad = (self._vlanKernelIfaces ? Promise.resolve(self._vlanKernelIfaces) :
|
||
self.callVlan('kernel_ifaces').then(function(r) {
|
||
self._vlanKernelIfaces = (r && r.ifaces) || [];
|
||
return self._vlanKernelIfaces;
|
||
})).catch(function() { return []; });
|
||
|
||
function card(title, body) {
|
||
return E('div', { 'style': 'background:rgba(0,0,0,0.02);border:1px solid rgba(0,0,0,0.06);border-radius:8px;padding:14px 16px;margin-bottom:16px;' },
|
||
[E('h4', { 'style': 'margin:0 0 10px 0;font-size:1em;' }, title), body]);
|
||
}
|
||
|
||
// Bridge VLAN show
|
||
var bvOut = E('pre', { 'style': 'background:#111;color:#0f0;padding:12px;border-radius:4px;font-size:0.85em;white-space:pre-wrap;max-height:400px;overflow:auto;margin:0;' }, _('(not loaded)'));
|
||
var bvBtn = E('button', { 'class': 'btn cbi-button', 'style': 'padding:6px 14px;',
|
||
'click': function() {
|
||
bvOut.textContent = _('Loading...');
|
||
self.callVlan('bridge_vlan_show').then(function(r) {
|
||
bvOut.textContent = (r && r.output) || '(empty)';
|
||
});
|
||
} }, _('Load bridge vlan show'));
|
||
|
||
// iface stats — kernel device dropdown
|
||
var ifSel = E('select', { 'style': 'width:100%;max-width:400px;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
kernelLoad.then(function(ifs) {
|
||
ifSel.innerHTML = '';
|
||
ifs.forEach(function(i) {
|
||
var label = i.name + (i.up ? ' (up)' : '');
|
||
ifSel.appendChild(E('option', { 'value': i.name }, label));
|
||
});
|
||
if (!ifs.length) ifSel.appendChild(E('option', { 'value': '' }, 'no interfaces'));
|
||
});
|
||
var statOut = E('pre', { 'style': 'background:#111;color:#0f0;padding:12px;border-radius:4px;font-size:0.85em;white-space:pre-wrap;margin:10px 0 0 0;' }, '');
|
||
var statBtn = E('button', { 'class': 'btn cbi-button', 'style': 'padding:6px 14px;',
|
||
'click': function() {
|
||
var dev = ifSel.value;
|
||
if (!dev) { statOut.textContent = 'No interface selected.'; return; }
|
||
statOut.textContent = 'Loading...';
|
||
self.callVlan('iface_stats', dev).then(function(r) {
|
||
statOut.textContent = JSON.stringify(r, null, 2);
|
||
});
|
||
} }, _('Show stats'));
|
||
|
||
// cross-vlan ping
|
||
var srcSel = E('select', { 'style': 'width:100%;max-width:300px;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
kernelLoad.then(function(ifs) {
|
||
srcSel.innerHTML = '';
|
||
// Filter to useful ping sources: bridges, pppoe, physical
|
||
var preferred = ifs.filter(function(i) {
|
||
var n = i.name;
|
||
return /^br-/.test(n) || /^pppoe-/.test(n) ||
|
||
/^eth[0-9]/.test(n) || /^usb[0-9]/.test(n) ||
|
||
n === 'tailscale0' || /^lan[0-9]/.test(n);
|
||
});
|
||
var list = preferred.length ? preferred : ifs;
|
||
list.forEach(function(i) {
|
||
var label = i.name + (i.up ? ' (up)' : '');
|
||
srcSel.appendChild(E('option', { 'value': i.name }, label));
|
||
});
|
||
if (!list.length) srcSel.appendChild(E('option', { 'value': '' }, 'no interfaces'));
|
||
});
|
||
var ipEl = E('input', { 'type': 'text', 'placeholder': '8.8.8.8',
|
||
'style': 'width:100%;max-width:300px;padding:6px 10px;border:1px solid #ccc;border-radius:4px;' });
|
||
var pingOut = E('pre', { 'style': 'background:#111;color:#0f0;padding:12px;border-radius:4px;font-size:0.85em;white-space:pre-wrap;margin:10px 0 0 0;' }, '');
|
||
var pingBtn = E('button', { 'class': 'btn cbi-button', 'style': 'padding:6px 14px;',
|
||
'click': function() {
|
||
var iface = srcSel.value;
|
||
var ip = ipEl.value.trim();
|
||
if (!iface) { pingOut.textContent = 'No interface selected.'; return; }
|
||
if (!ip) { pingOut.textContent = 'Enter a target IP or host.'; return; }
|
||
pingOut.textContent = 'Pinging ' + ip + ' through ' + iface + '...';
|
||
self.callVlan('ping_test', iface, ip).then(function(r) {
|
||
var out = (r && r.output) ? r.output : '';
|
||
var rc = (r && r.exit_code != null) ? r.exit_code : '?';
|
||
if (!out) {
|
||
out = 'No output. Command: ping -I ' + iface + ' -c 3 -W 3 ' + ip + '\n' +
|
||
'Exit code: ' + rc + '\n\n' +
|
||
'Common causes:\n' +
|
||
'- Interface has no route to target\n' +
|
||
'- Interface is down\n' +
|
||
'- Wrong device (use pppoe-wan for PPPoE WAN, not eth1)';
|
||
} else {
|
||
out = 'Exit code: ' + rc + '\n' + out;
|
||
}
|
||
pingOut.textContent = out;
|
||
});
|
||
} }, _('Run ping'));
|
||
|
||
return E('div', {}, [
|
||
E('p', { 'style': 'color:#888;font-size:0.9em;' },
|
||
_('Diagnostics tools. Load on demand — nothing runs in the background.')),
|
||
card(_('Bridge VLAN table'), E('div', {}, [bvBtn, E('div', { 'style': 'margin-top:10px;' }, [bvOut])])),
|
||
card(_('Interface stats'), E('div', {}, [ifSel, E('div', { 'style': 'margin-top:10px;' }, [statBtn]), statOut])),
|
||
card(_('Cross-VLAN ping'), E('div', {}, [
|
||
E('div', { 'style': 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;' }, [srcSel, ipEl]),
|
||
E('div', { 'style': 'margin-top:10px;' }, [pingBtn]),
|
||
pingOut
|
||
]))
|
||
]);
|
||
},
|
||
|
||
renderFailover: function() {
|
||
var self = this;
|
||
|
||
var rpcStatus = rpc.declare({
|
||
object: 'luci.quickactions-failover', method: 'status', expect: { '': {} }
|
||
});
|
||
var rpcInterfaces = rpc.declare({
|
||
object: 'luci.quickactions-failover', method: 'interfaces', expect: { '': {} }
|
||
});
|
||
var rpcAction = rpc.declare({
|
||
object: 'luci.quickactions-failover', method: 'action',
|
||
params: ['action'], expect: { '': {} }
|
||
});
|
||
|
||
function cfg(k, d) {
|
||
var v = uci.get('quickactions', 'failover', k);
|
||
return (v == null || v === '') ? d : v;
|
||
}
|
||
|
||
// ---- Status bar ----
|
||
var statusLabel = E('span', {
|
||
'style': 'font-weight:bold;padding:6px 12px;border-radius:4px;background:rgba(0,0,0,0.1);display:inline-block;'
|
||
}, '...');
|
||
|
||
function refreshStatus() {
|
||
return rpcStatus().then(function(r) {
|
||
var on = String(r.enabled) === '1';
|
||
var backup = String(r.on_backup) === '1';
|
||
statusLabel.textContent = 'STATUS: ' + (on ? 'ON' : 'OFF') +
|
||
' · MODE: ' + (backup ? 'BACKUP' : 'PRIMARY');
|
||
statusLabel.style.background = on
|
||
? (backup ? 'rgba(255,193,7,0.25)' : 'rgba(40,167,69,0.2)')
|
||
: 'rgba(220,53,69,0.2)';
|
||
var log = document.getElementById('qa-fo-lastlog');
|
||
if (log) log.textContent = r.last_log || '(no recent log)';
|
||
}).catch(function() {});
|
||
}
|
||
|
||
// ---- Action buttons ----
|
||
function doAction(action, msg) {
|
||
if (!confirm(msg)) return;
|
||
rpcAction(action).then(function(r) {
|
||
ui.addNotification(null, E('pre', {
|
||
'style': 'margin:0;white-space:pre-wrap;font-size:0.85em;'
|
||
}, (r && r.output) || '(done)'), 'info');
|
||
refreshStatus();
|
||
}).catch(function(err) {
|
||
ui.addNotification(null, E('p', {}, 'Failed: ' + (err.message || err)), 'danger');
|
||
});
|
||
}
|
||
|
||
var controlBar = E('div', {
|
||
'style': 'display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:18px;' }, [
|
||
statusLabel,
|
||
E('button', { 'class': 'btn cbi-button',
|
||
'style': 'background-color:#28a745 !important;background:#28a745 !important;color:#fff !important;padding:8px 18px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;',
|
||
'click': function() { doAction('on', 'Enable failover cron + hotplug?'); } }, _('Enable')),
|
||
E('button', { 'class': 'btn cbi-button',
|
||
'style': 'background-color:#dc3545 !important;background:#dc3545 !important;color:#fff !important;padding:8px 18px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;',
|
||
'click': function() { doAction('off', 'Disable failover cron + hotplug?'); } }, _('Disable')),
|
||
E('button', { 'class': 'btn cbi-button',
|
||
'click': function() {
|
||
ui.addNotification(null, E('p', {}, 'Running watchdog manually...'), 'info');
|
||
rpcAction('test').then(function(r) {
|
||
ui.addNotification(null, E('pre', {
|
||
'style': 'margin:0;white-space:pre-wrap;font-size:0.85em;'
|
||
}, (r && r.output) || '(no output)'), 'info');
|
||
refreshStatus();
|
||
});
|
||
} }, _('Run watchdog')),
|
||
E('button', { 'class': 'btn cbi-button', 'click': refreshStatus }, _('Refresh'))
|
||
]);
|
||
|
||
// ---- Interface dropdowns ----
|
||
var primSel = E('select', {
|
||
'style': 'width:100%;max-width:440px;padding:8px 10px;border:1px solid #ccc;border-radius:4px;font-size:0.95em;' });
|
||
|
||
var backSel = E('select', {
|
||
'multiple': true, 'size': 8,
|
||
'style': 'width:100%;max-width:440px;padding:8px 10px;border:1px solid #ccc;border-radius:4px;font-size:0.95em;' });
|
||
|
||
var curPrim = cfg('primary', 'wan');
|
||
var curBackups = (cfg('backups', '') || '').split(/\s+/).filter(Boolean);
|
||
|
||
rpcInterfaces().then(function(r) {
|
||
var raw = (r && r.interfaces) ? String(r.interfaces).trim() : '';
|
||
var list = raw ? raw.split(/\s+/) : [];
|
||
list.forEach(function(n) {
|
||
if (!n) return;
|
||
var o1 = E('option', { 'value': n }, n);
|
||
if (n === curPrim) o1.selected = true;
|
||
primSel.appendChild(o1);
|
||
|
||
var o2 = E('option', { 'value': n }, n);
|
||
if (curBackups.indexOf(n) !== -1) o2.selected = true;
|
||
backSel.appendChild(o2);
|
||
});
|
||
}).catch(function(err) {
|
||
ui.addNotification(null, E('p', {}, 'Interface list failed: ' + (err.message || err)), 'warning');
|
||
});
|
||
|
||
function fieldRow(label, el, hint) {
|
||
return E('div', { 'style': 'margin-bottom:14px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:0.9em;color:#666;margin-bottom:5px;font-weight:500;' }, label),
|
||
el,
|
||
hint ? E('div', { 'style': 'font-size:0.8em;color:#888;margin-top:4px;' }, hint) : null
|
||
]);
|
||
}
|
||
|
||
function textField(key, dflt, hint) {
|
||
return fieldRow(_(key), E('input', {
|
||
'type': 'text', 'id': 'qa-fo-' + key,
|
||
'value': cfg(key, dflt),
|
||
'style': 'width:100%;max-width:440px;padding:8px 10px;border:1px solid #ccc;border-radius:4px;font-size:0.95em;box-sizing:border-box;'
|
||
}), hint);
|
||
}
|
||
|
||
function checkboxRow(key, dflt, label, hint) {
|
||
var cb = E('input', { 'type': 'checkbox', 'id': 'qa-fo-' + key });
|
||
if (cfg(key, dflt) === '1') cb.checked = true;
|
||
return E('div', { 'style': 'margin-bottom:12px;display:flex;align-items:flex-start;gap:10px;' }, [
|
||
cb,
|
||
E('div', {}, [
|
||
E('label', { 'style': 'font-size:0.95em;font-weight:500;' }, label),
|
||
hint ? E('div', { 'style': 'font-size:0.8em;color:#888;margin-top:2px;' }, hint) : null
|
||
])
|
||
]);
|
||
}
|
||
|
||
var formGrid = E('div', { 'style': 'display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;' }, [
|
||
textField('check_ips', '1.1.1.1 8.8.8.8',
|
||
_('Space-separated. Pings go out through the primary interface.')),
|
||
textField('check_interval', '60',
|
||
_('Seconds between cron runs.')),
|
||
textField('ping_timeout', '5',
|
||
_('Per-ping timeout in seconds.')),
|
||
textField('fail_threshold', '2',
|
||
_('Consecutive failures before failover.')),
|
||
textField('recover_threshold', '1',
|
||
_('Consecutive successes before switching back.'))
|
||
]);
|
||
|
||
var checkboxes = E('div', { 'style': 'margin-top:16px;' }, [
|
||
checkboxRow('tailscale_restart', '0',
|
||
_('Restart Tailscale on failover and recovery'),
|
||
_('Fixes stuck tailscale0 routes when the WAN IP changes.')),
|
||
checkboxRow('firewall_reset', '1',
|
||
_('Reset firewall on failover'),
|
||
_('Prevents the "LAN access lost" bug on some OpenWrt forks.')),
|
||
checkboxRow('log_healthy', '0',
|
||
_('Log every healthy check'),
|
||
_('Generates ~1440 log lines per day. Leave off unless debugging.'))
|
||
]);
|
||
|
||
function gather() {
|
||
var backups = [];
|
||
for (var i = 0; i < backSel.options.length; i++)
|
||
if (backSel.options[i].selected) backups.push(backSel.options[i].value);
|
||
|
||
uci.set('quickactions', 'failover', 'primary', primSel.value);
|
||
uci.set('quickactions', 'failover', 'backups', backups.join(' '));
|
||
['check_ips', 'check_interval', 'ping_timeout',
|
||
'fail_threshold', 'recover_threshold'].forEach(function(k) {
|
||
var el = document.getElementById('qa-fo-' + k);
|
||
if (el) uci.set('quickactions', 'failover', k, el.value);
|
||
});
|
||
['tailscale_restart', 'firewall_reset', 'log_healthy'].forEach(function(k) {
|
||
var el = document.getElementById('qa-fo-' + k);
|
||
if (el) uci.set('quickactions', 'failover', k, el.checked ? '1' : '0');
|
||
});
|
||
return uci.save();
|
||
}
|
||
|
||
var actionBar = E('div', { 'style': 'margin-top:22px;padding-top:16px;border-top:1px solid rgba(0,0,0,0.08);display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;' }, [
|
||
E('button', {
|
||
'class': 'btn cbi-button',
|
||
'style': 'background-color:#6c757d !important;background:#6c757d !important;color:#fff !important;padding:8px 18px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;',
|
||
'click': function() {
|
||
if (confirm('Discard form changes?')) self.switchTab('failover');
|
||
} }, _('Reset')),
|
||
E('button', {
|
||
'class': 'btn cbi-button',
|
||
'style': 'background-color:#17a2b8 !important;background:#17a2b8 !important;color:#fff !important;padding:8px 18px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;',
|
||
'click': function() {
|
||
gather().then(function() {
|
||
ui.addNotification(null, E('p', {}, 'Saved to memory. Use Save & Apply to commit.'), 'info');
|
||
self.refreshPendingCount();
|
||
}).catch(function(err) {
|
||
ui.addNotification(null, E('p', {}, 'Save failed: ' + (err.message || err)), 'danger');
|
||
});
|
||
} }, _('Save')),
|
||
E('button', {
|
||
'class': 'btn cbi-button',
|
||
'style': 'background-color:#28a745 !important;background:#28a745 !important;color:#fff !important;padding:8px 20px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;',
|
||
'click': function() {
|
||
gather().then(function() {
|
||
return ui.changes.apply(false);
|
||
}).then(function() {
|
||
ui.addNotification(null, E('p', {}, 'Saved and applied.'), 'info');
|
||
self.refreshPendingCount();
|
||
refreshStatus();
|
||
}).catch(function(err) {
|
||
var m = (err && err.message) ? err.message : String(err);
|
||
if (/No data|code 5/i.test(m)) return;
|
||
ui.addNotification(null, E('p', {}, 'Apply failed: ' + m), 'danger');
|
||
});
|
||
} }, _('Save & Apply'))
|
||
]);
|
||
|
||
var infoBar = E('div', {
|
||
'style': 'background:rgba(30,144,255,0.08);border:1px solid rgba(30,144,255,0.2);border-radius:6px;padding:12px 16px;margin-bottom:18px;font-size:0.9em;line-height:1.55;' }, [
|
||
E('strong', {}, _('Failover — lightweight mwan3 alternative. ')),
|
||
_('Cron-driven ping tests run once per minute through the primary interface. ' +
|
||
'On repeated failure, the conntrack table is flushed so the kernel falls back to the backup ' +
|
||
'with the lowest route metric. Zero background RAM when idle — a single ping run per minute.')
|
||
]);
|
||
|
||
var logBox = E('pre', {
|
||
'id': 'qa-fo-lastlog',
|
||
'style': 'background:#111;color:#0f0;padding:10px 12px;border-radius:4px;font-size:0.85em;' +
|
||
'white-space:pre-wrap;word-break:break-all;max-height:100px;overflow:auto;margin:0;'
|
||
}, _('(no recent log)'));
|
||
|
||
refreshStatus();
|
||
if (self._failoverTimer) clearInterval(self._failoverTimer);
|
||
self._failoverTimer = setInterval(refreshStatus, 8000);
|
||
|
||
return E('div', {}, [
|
||
E('h3', { 'style': 'margin-top:0;' }, _('WAN Failover')),
|
||
infoBar,
|
||
controlBar,
|
||
E('h4', { 'style': 'margin:22px 0 12px 0;' }, _('Interfaces')),
|
||
fieldRow(_('Primary (fastest) interface'), primSel,
|
||
_('Traffic uses this interface whenever it is healthy.')),
|
||
fieldRow(_('Backup interfaces (multi-select)'), backSel,
|
||
_('Traffic switches to whichever backup is up with the lowest route metric.')),
|
||
E('h4', { 'style': 'margin:22px 0 12px 0;' }, _('Health check')),
|
||
formGrid,
|
||
E('h4', { 'style': 'margin:22px 0 12px 0;' }, _('Options')),
|
||
checkboxes,
|
||
actionBar,
|
||
E('h4', { 'style': 'margin:22px 0 12px 0;' }, _('Last log line')),
|
||
logBox
|
||
]);
|
||
},
|
||
|
||
wirelessQuickEdit: function(sid) {
|
||
var self = this;
|
||
var dev = uci.get('wireless', sid, 'device') || '';
|
||
var ssid = uci.get('wireless', sid, 'ssid') || '';
|
||
var enc = uci.get('wireless', sid, 'encryption') || 'psk2';
|
||
var key = uci.get('wireless', sid, 'key') || uci.get('wireless', sid, 'key1') || '';
|
||
var net = uci.get('wireless', sid, 'network') || '';
|
||
var disabled = uci.get('wireless', sid, 'disabled') === '1';
|
||
|
||
var ssidEl = E('input', { 'type':'text', 'value': ssid,
|
||
'style':'width:100%;padding:8px 10px;border:1px solid #ccc;border-radius:4px;box-sizing:border-box;' });
|
||
var encSel = E('select', { 'style':'width:100%;padding:8px 10px;border:1px solid #ccc;border-radius:4px;box-sizing:border-box;' });
|
||
[['psk2','WPA2-PSK'],['sae','WPA3-SAE'],['sae-mixed','WPA2/WPA3 mixed'],
|
||
['psk-mixed','WPA/WPA2 mixed'],['psk','WPA-PSK'],
|
||
['owe','OWE (enhanced open)'],['none','Open']]
|
||
.forEach(function(o) {
|
||
var opt = E('option', { 'value': o[0] }, o[1]);
|
||
if (o[0] === enc) opt.selected = true;
|
||
encSel.appendChild(opt);
|
||
});
|
||
var keyEl = E('input', { 'type':'text', 'value': key,
|
||
'style':'width:100%;padding:8px 10px;border:1px solid #ccc;border-radius:4px;font-family:monospace;box-sizing:border-box;' });
|
||
|
||
var netSel = E('select', { 'style':'width:100%;padding:8px 10px;border:1px solid #ccc;border-radius:4px;box-sizing:border-box;' });
|
||
var netOpts = (uci.sections('network', 'interface') || []).map(function(s) { return s['.name']; });
|
||
if (netOpts.indexOf(net) === -1 && net) netOpts.push(net);
|
||
netOpts.sort();
|
||
netOpts.forEach(function(n) {
|
||
var opt = E('option', { 'value': n }, n);
|
||
if (n === net) opt.selected = true;
|
||
netSel.appendChild(opt);
|
||
});
|
||
|
||
var disEl = E('input', { 'type':'checkbox' });
|
||
if (!disabled) disEl.checked = true;
|
||
|
||
var body = E('div', {}, [
|
||
E('div', { 'style':'margin-bottom:12px;' }, [
|
||
E('label', { 'style':'display:block;font-size:0.9em;color:#666;margin-bottom:5px;font-weight:500;' }, _('SSID')),
|
||
ssidEl
|
||
]),
|
||
E('div', { 'style':'margin-bottom:12px;' }, [
|
||
E('label', { 'style':'display:block;font-size:0.9em;color:#666;margin-bottom:5px;font-weight:500;' }, _('Security')),
|
||
encSel
|
||
]),
|
||
E('div', { 'style':'margin-bottom:12px;' }, [
|
||
E('label', { 'style':'display:block;font-size:0.9em;color:#666;margin-bottom:5px;font-weight:500;' }, _('Password')),
|
||
keyEl
|
||
]),
|
||
E('div', { 'style':'margin-bottom:12px;' }, [
|
||
E('label', { 'style':'display:block;font-size:0.9em;color:#666;margin-bottom:5px;font-weight:500;' }, _('Network interface')),
|
||
netSel
|
||
]),
|
||
E('div', { 'style':'margin-bottom:12px;display:flex;align-items:center;gap:10px;' }, [
|
||
disEl, E('label', { 'style':'font-size:0.95em;' }, _('Enabled'))
|
||
]),
|
||
E('div', { 'style':'font-size:0.82em;color:#888;' }, _('Device: ') + dev)
|
||
]);
|
||
|
||
function gather() {
|
||
uci.set('wireless', sid, 'ssid', ssidEl.value);
|
||
uci.set('wireless', sid, 'encryption', encSel.value);
|
||
if (encSel.value === 'none' || encSel.value === 'owe') {
|
||
uci.unset('wireless', sid, 'key');
|
||
uci.unset('wireless', sid, 'key1');
|
||
} else {
|
||
uci.set('wireless', sid, 'key', keyEl.value);
|
||
}
|
||
uci.set('wireless', sid, 'network', netSel.value);
|
||
uci.set('wireless', sid, 'disabled', disEl.checked ? '0' : '1');
|
||
|
||
// If this iface carries a guest_owner tag, mirror the changes
|
||
// into the corresponding guestwifi.@guest[X] section so the
|
||
// Guest WiFi tab also reflects them.
|
||
var owner = uci.get('wireless', sid, 'guest_owner');
|
||
if (owner) {
|
||
var match = (uci.sections('guestwifi', 'guest') || []).filter(function(g) {
|
||
return g['.name'] === owner;
|
||
})[0];
|
||
if (match) {
|
||
uci.set('guestwifi', owner, 'ssid', ssidEl.value);
|
||
uci.set('guestwifi', owner, 'encryption', encSel.value);
|
||
if (encSel.value !== 'none' && encSel.value !== 'owe') {
|
||
uci.set('guestwifi', owner, 'password', keyEl.value);
|
||
}
|
||
// Mirror the enable/disable state as well. Guest tab
|
||
// reads guestwifi.*.enable, so without this the toggle
|
||
// wouldn't reflect back.
|
||
uci.set('guestwifi', owner, 'enable', disEl.checked ? '1' : '0');
|
||
}
|
||
}
|
||
}
|
||
|
||
function closeModal() {
|
||
document.querySelectorAll('.modal-overlay').forEach(function(m) {
|
||
if (m.parentNode) m.parentNode.removeChild(m);
|
||
});
|
||
document.body.classList.remove('modal-overlay-active');
|
||
}
|
||
|
||
var cancelBtn = E('button', {
|
||
'class': 'btn cbi-button',
|
||
'style': 'margin-right:8px;padding:8px 20px;'
|
||
}, _('Cancel'));
|
||
cancelBtn.addEventListener('click', closeModal);
|
||
|
||
var saveBtn = E('button', {
|
||
'class': 'btn cbi-button',
|
||
'style': 'background-color:#17a2b8 !important;background:#17a2b8 !important;color:#fff !important;padding:8px 22px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;margin-right:8px;'
|
||
}, _('Save'));
|
||
saveBtn.addEventListener('click', function() {
|
||
gather();
|
||
uci.save().then(function() {
|
||
closeModal();
|
||
self.refreshPendingCount();
|
||
try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch(e) { window.scrollTo(0, 0); }
|
||
ui.addNotification(null,
|
||
E('p', {}, _('Saved. Scroll to the top of the page and click "Save & Apply" to commit.')),
|
||
'info');
|
||
}).catch(function(err) {
|
||
ui.addNotification(null, E('p', {}, 'Save failed: ' + (err.message||err)), 'danger');
|
||
});
|
||
});
|
||
|
||
var applyBtn = E('button', {
|
||
'class': 'btn cbi-button',
|
||
'style': 'background-color:#28a745 !important;background:#28a745 !important;color:#fff !important;padding:8px 22px;font-weight:bold;border:none;border-radius:4px;cursor:pointer;'
|
||
}, _('Save & Apply'));
|
||
applyBtn.addEventListener('click', function() {
|
||
gather();
|
||
var applyRpc = rpc.declare({
|
||
object: 'uci', method: 'apply',
|
||
params: ['rollback'], expect: { '': {} }
|
||
});
|
||
uci.save().then(function() {
|
||
// Raw rpc uci/apply — the LuCI wrapper ui.changes.apply()
|
||
// reloads the current view after applying, which resets the
|
||
// tab hash and drops us back on the Dashboard.
|
||
return applyRpc(0);
|
||
}).then(function() {
|
||
closeModal();
|
||
ui.addNotification(null,
|
||
E('p', {}, _('Wireless saved and applied. Reloading...')),
|
||
'info');
|
||
// Reload the page. This clears LuCI's stale change counter
|
||
// (the tracker lives in browser memory) and gives the Guest
|
||
// WiFi iframe a fresh load so it picks up the new config.
|
||
setTimeout(function() {
|
||
window.location.reload();
|
||
}, 900);
|
||
}).catch(function(err) {
|
||
var m = (err && err.message) ? err.message : String(err);
|
||
if (/No data|code 5/i.test(m)) {
|
||
closeModal();
|
||
return;
|
||
}
|
||
ui.addNotification(null, E('p', {}, 'Apply failed: ' + m), 'danger');
|
||
});
|
||
});
|
||
|
||
ui.showModal(_('Edit wireless: ' + (ssid || sid)), [
|
||
body,
|
||
E('div', { 'style':'margin-top:16px;text-align:right;' }, [cancelBtn, saveBtn, applyBtn])
|
||
]);
|
||
},
|
||
|
||
renderConfigForm: function() {
|
||
var self = this;
|
||
var m = new form.Map('quickactions', 'Quick Actions Configuration', 'Manage all sections. Use Order field to control sequence (lower = first).');
|
||
var s = m.section(form.NamedSection, 'global', 'settings', 'Global Settings');
|
||
s.anonymous = true;
|
||
var o = s.option(form.Value, 'poll_interval', 'Polling Interval (seconds)');
|
||
o.datatype = 'integer'; o.placeholder = '5';
|
||
o = s.option(form.ListValue, 'design_style', 'Button Style');
|
||
o.value('flat', 'Modern Flat'); o.value('3d', 'Tactile 3D'); o.default = '3d';
|
||
|
||
var s2 = m.section(form.GridSection, 'essential', 'Essential Buttons', 'Reorder field controls display order.');
|
||
s2.anonymous = true; s2.addremove = true;
|
||
o = s2.option(form.Value, 'order', 'Order'); o.datatype = 'integer'; o.placeholder = '10';
|
||
s2.option(form.Value, 'label', 'Label');
|
||
o = s2.option(form.Value, 'icon', 'Icon / Emoji'); o.placeholder = '🔄';
|
||
o = s2.option(form.Value, 'command', 'Shell Command'); o.placeholder = '/sbin/reboot';
|
||
s2.option(form.Flag, 'confirm', 'Require Confirmation');
|
||
|
||
var s3 = m.section(form.GridSection, 'mapping', 'Service Mappings', 'Map keywords to init services.');
|
||
s3.anonymous = true; s3.addremove = true;
|
||
o = s3.option(form.Value, 'order', 'Order'); o.datatype = 'integer';
|
||
s3.option(form.Value, 'keyword', 'Label Keyword');
|
||
s3.option(form.Value, 'service', 'Target Service');
|
||
o = s3.option(form.Value, 'icon', 'Icon / Emoji'); o.placeholder = '⚙️';
|
||
s3.option(form.Flag, 'dangerous', 'Require Confirmation');
|
||
|
||
var s4 = m.section(form.GridSection, 'shortcut', 'Navigation Shortcuts', 'Buttons that jump to other LuCI pages.');
|
||
s4.anonymous = true; s4.addremove = true;
|
||
o = s4.option(form.Value, 'order', 'Order'); o.datatype = 'integer';
|
||
s4.option(form.Value, 'label', 'Label');
|
||
o = s4.option(form.Value, 'icon', 'Icon / Emoji'); o.placeholder = '🔗';
|
||
o = s4.option(form.Value, 'path', 'LuCI Path'); o.placeholder = 'admin/network/firewall';
|
||
|
||
self.configMap = m;
|
||
return m.render();
|
||
},
|
||
|
||
render: function(data) {
|
||
var self = this;
|
||
|
||
// Embedded mode — ?embed=guestwifi renders only the guest wifi form (no outer tabs)
|
||
var embedParam = '';
|
||
try { embedParam = new URLSearchParams(window.location.search).get('embed') || ''; } catch(e) {}
|
||
|
||
if (embedParam === 'guestwifi') {
|
||
// Walk the parent document and un-cap max-width on every
|
||
// ancestor of this iframe. The theme (Argon) sets
|
||
// .container{max-width:940px}, and injecting a <style> into
|
||
// the parent loses to theme CSS that loads after us. Inline
|
||
// !important on the actual DOM nodes always wins.
|
||
try {
|
||
var pd2 = window.parent && window.parent.document;
|
||
if (pd2) {
|
||
var iframeEl = pd2.getElementById('qa-guestwifi-iframe');
|
||
var anc = iframeEl ? iframeEl.parentNode : null;
|
||
while (anc && anc.tagName && anc.tagName !== 'BODY') {
|
||
try {
|
||
anc.style.setProperty('max-width', 'none', 'important');
|
||
anc.style.setProperty('width', '100%', 'important');
|
||
} catch(e) {}
|
||
anc = anc.parentNode;
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
// Walk the parent document and un-cap max-width on every
|
||
// ancestor of this iframe. Runs on a 1s interval so if
|
||
// LuCI re-renders its view container (which re-applies
|
||
// the theme's .container{max-width:940px} rule), we snap
|
||
// it back to full width.
|
||
function qaUncapAncestors() {
|
||
try {
|
||
var pd2 = window.parent && window.parent.document;
|
||
if (!pd2) return;
|
||
var iframeEl = pd2.getElementById('qa-guestwifi-iframe');
|
||
if (!iframeEl) return;
|
||
iframeEl.style.setProperty('width', '100%', 'important');
|
||
iframeEl.style.setProperty('max-width', 'none', 'important');
|
||
var anc = iframeEl.parentNode;
|
||
while (anc && anc.tagName && anc.tagName !== 'BODY') {
|
||
try {
|
||
anc.style.setProperty('max-width', 'none', 'important');
|
||
anc.style.setProperty('width', '100%', 'important');
|
||
} catch(e) {}
|
||
anc = anc.parentNode;
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
qaUncapAncestors();
|
||
if (!window._qaUncapTimer) {
|
||
window._qaUncapTimer = setInterval(qaUncapAncestors, 1000);
|
||
}
|
||
var gw = new GuestWifiViewClass();
|
||
self.embedGuestWifi = gw;
|
||
return Promise.resolve(gw.load()).then(function() {
|
||
return gw.render();
|
||
}).catch(function(err) {
|
||
return E('div', { 'class': 'alert-message error', 'style': 'padding:20px;' }, 'Guest WiFi form error: ' + (err && err.message ? err.message : String(err)));
|
||
});
|
||
}
|
||
|
||
var cmds = data[0] || [];
|
||
var initList = data[1] || {};
|
||
var tabBar = E('div', { 'style': 'display:flex;gap:2px;border-bottom:2px solid rgba(0,0,0,0.08);margin:0 0 20px 0;flex-wrap:wrap;' });
|
||
var tabContent = E('div');
|
||
var tabButtons = {};
|
||
var tabs = [
|
||
{ id: 'dashboard', label: 'Dashboard' },
|
||
{ id: 'essential', label: 'Essential' },
|
||
{ id: 'tools', label: 'Tools' },
|
||
{ id: 'logs', label: 'Logs' },
|
||
{ id: 'services', label: 'Services' },
|
||
{ id: 'hotplug', label: 'Hotplug' },
|
||
{ id: 'crontab', label: 'Crontab' },
|
||
{ id: 'guestwifi', label: 'Guest WiFi' },
|
||
{ id: 'ttyd', label: 'Terminal' },
|
||
{ id: 'taskplan', label: 'Task Plan' },
|
||
{ id: 'netwizard', label: 'Network Setup' },
|
||
{ id: 'command', label: 'Command' },
|
||
{ id: 'dependencies', label: 'Dependencies' },
|
||
{ id: 'scripts', label: 'Scripts' },
|
||
{ id: 'vlan', label: 'VLAN' },
|
||
{ id: 'failover', label: 'Failover' },
|
||
{ id: 'config', label: 'Configuration' }
|
||
];
|
||
function showTab(id) {
|
||
self.activeTab = id;
|
||
try {
|
||
if (window.location.hash !== '#' + id) {
|
||
// Prefer replaceState (no hashchange event, no LuCI re-dispatch)
|
||
if (window.history && window.history.replaceState) {
|
||
window.history.replaceState(null, '', '#' + id);
|
||
} else {
|
||
// Fallback: direct hash assignment (fires hashchange — guarded below)
|
||
window.location.hash = id;
|
||
}
|
||
// Verify write succeeded (some themes/dispatchers strip the hash)
|
||
setTimeout(function() {
|
||
if (window.location.hash !== '#' + id) {
|
||
try { window.location.hash = id; } catch(e) {}
|
||
}
|
||
}, 50);
|
||
}
|
||
} catch(e) {}
|
||
Object.keys(tabButtons).forEach(function(tid) {
|
||
var b = tabButtons[tid];
|
||
if (tid === id) { b.style.borderBottomColor = '#1e90ff'; b.style.color = '#1e90ff'; b.style.fontWeight = 'bold'; }
|
||
else { b.style.borderBottomColor = 'transparent'; b.style.color = '#666'; b.style.fontWeight = '500'; }
|
||
});
|
||
tabContent.innerHTML = '';
|
||
self.hideLuCIPageActions();
|
||
if (id === 'dashboard') tabContent.appendChild(self.renderDashboard(cmds, initList));
|
||
else if (id === 'essential') tabContent.appendChild(self.renderEssential());
|
||
else if (id === 'tools') tabContent.appendChild(self.renderTools());
|
||
else if (id === 'logs') tabContent.appendChild(self.renderLogs());
|
||
else if (id === 'services') tabContent.appendChild(self.renderServices());
|
||
else if (id === 'hotplug') {
|
||
tabContent.appendChild(E('p', { 'style': 'color:#888;padding:20px;' }, 'Loading hotplug form...'));
|
||
Promise.resolve(self.renderHotplug()).then(function(node) { tabContent.innerHTML = ''; tabContent.appendChild(node); })
|
||
.catch(function(err) { tabContent.innerHTML = ''; tabContent.appendChild(E('div', { 'class': 'alert-message error' }, 'Hotplug error: ' + (err && err.message ? err.message : String(err)))); });
|
||
}
|
||
else if (id === 'crontab') tabContent.appendChild(self.renderCrontabWizard());
|
||
else if (id === 'guestwifi') {
|
||
// Guest WiFi's form.Map hijacks #view when rendered inline. Isolate it in an iframe.
|
||
var baseUrl = window.location.pathname;
|
||
// Cache-bust so the iframe always reloads its config from disk
|
||
var iframeUrl = baseUrl + (baseUrl.indexOf('?') >= 0 ? '&' : '?')
|
||
+ 'embed=guestwifi&_t=' + Date.now();
|
||
var infoBar = E('div', { 'style': 'background:rgba(30,144,255,0.08);border:1px solid rgba(30,144,255,0.2);border-radius:8px;padding:10px 12px;margin-bottom:12px;font-size:0.85em;display:flex;align-items:center;gap:12px;flex-wrap:wrap;' }, [
|
||
E('span', { 'style': 'flex:1;' }, 'Guest WiFi runs in an isolated frame so this tab bar stays visible. Save & Apply works normally inside the frame.'),
|
||
E('a', { 'href': iframeUrl, 'target': '_blank', 'style': 'color:#1e90ff;font-weight:bold;' }, 'Open in new tab ↗'),
|
||
E('button', { 'class': 'btn cbi-button', 'style': 'padding:2px 10px;font-size:0.85em;', 'click': function() {
|
||
var f = document.getElementById('qa-guestwifi-iframe');
|
||
if (f) { var s = f.src; f.src = 'about:blank'; setTimeout(function(){ f.src = s; }, 50); }
|
||
} }, '↻ Reload')
|
||
]);
|
||
var ifr = document.createElement('iframe');
|
||
ifr.id = 'qa-guestwifi-iframe';
|
||
ifr.style.cssText = 'width:100%;height:400px;min-height:200px;max-height:calc(100vh - 140px);display:block;border:1px solid rgba(0,0,0,0.1);border-radius:6px;background:transparent;overflow:hidden;';
|
||
|
||
function stripEmbedChrome() {
|
||
var f = document.getElementById('qa-guestwifi-iframe');
|
||
if (!f) return;
|
||
try {
|
||
var doc = f.contentDocument;
|
||
if (!doc || !doc.body) return;
|
||
|
||
// 1. Inject generic "hide chrome" CSS
|
||
if (!doc.getElementById('qa-embed-css')) {
|
||
var s = doc.createElement('style');
|
||
s.id = 'qa-embed-css';
|
||
s.textContent = [
|
||
'html, body { padding: 0 !important; margin: 0 !important; }',
|
||
'body > header, body > nav, body > aside, body > footer { display: none !important; }',
|
||
'header, nav, aside, #header, #mainmenu, #menubar, #sidebar, .header, .main-menu, .sidebar, .navigation, .navbar { display: none !important; visibility: hidden !important; height: 0 !important; overflow: hidden !important; }',
|
||
'.main-left, .main-left * { display: none !important; }',
|
||
'.main { padding-top: 0 !important; margin-top: 0 !important; top: 0 !important; }',
|
||
'.main-right { margin-left: 0 !important; padding: 0 !important; }',
|
||
'#maincontent, #view, .cbi-map { padding-top: 0 !important; margin-top: 0 !important; }',
|
||
'body { overflow-x: hidden; }',
|
||
'a[href*="logout"], .logout, .main-right .logout { display: none !important; }',
|
||
'footer, .footer, .fs-footer, #footer, [role="contentinfo"] { display: none !important; visibility: hidden !important; height: 0 !important; overflow: hidden !important; padding: 0 !important; margin: 0 !important; }'
|
||
].join('\n');
|
||
doc.head.appendChild(s);
|
||
}
|
||
|
||
// 2. DOM-walk: hide every sibling on the path from target up to body
|
||
var target = doc.querySelector('#maincontent')
|
||
|| doc.querySelector('#view')
|
||
|| doc.querySelector('.cbi-map')
|
||
|| doc.querySelector('.main-right')
|
||
|| doc.querySelector('.main');
|
||
if (!target) return;
|
||
|
||
var el = target;
|
||
while (el && el.parentNode && el.parentNode !== doc.documentElement) {
|
||
var parent = el.parentNode;
|
||
var kids = parent.children;
|
||
for (var i = 0; i < kids.length; i++) {
|
||
var k = kids[i];
|
||
if (k === el) continue;
|
||
if (k.tagName === 'SCRIPT' || k.tagName === 'STYLE' || k.tagName === 'LINK') continue;
|
||
var kid = (k.id || '') + ' ' + (k.className || '');
|
||
if (/modal|overlay|notification|alert|dialog|spinning|popup|tooltip/i.test(kid)) continue;
|
||
k.style.display = 'none';
|
||
}
|
||
if (parent === doc.body) break;
|
||
el = parent;
|
||
}
|
||
|
||
// 3. Explicitly show the target chain
|
||
var showChain = target;
|
||
while (showChain && showChain !== doc.documentElement) {
|
||
showChain.style.display = '';
|
||
showChain.style.visibility = '';
|
||
showChain = showChain.parentNode;
|
||
}
|
||
} catch(e) { /* cross-origin, ignore */ }
|
||
}
|
||
|
||
function autoSizeIframe() {
|
||
try {
|
||
var f = document.getElementById('qa-guestwifi-iframe');
|
||
if (!f) return;
|
||
var d = f.contentDocument;
|
||
if (!d || !d.body) return;
|
||
// Reset to a small height first so we can measure natural content height
|
||
f.style.height = '100px';
|
||
var h = Math.max(
|
||
d.body.scrollHeight,
|
||
d.documentElement ? d.documentElement.scrollHeight : 0,
|
||
400
|
||
);
|
||
f.style.height = (h + 8) + 'px';
|
||
} catch(e) {}
|
||
}
|
||
|
||
ifr.addEventListener('load', function() {
|
||
stripEmbedChrome();
|
||
|
||
// Hide the parent page footer once (outer LuCI layout "Powered by" line)
|
||
try {
|
||
if (!window._qaParentFooterHidden) {
|
||
window._qaParentFooterHidden = true;
|
||
var pfs = document.querySelectorAll('footer, .fs-footer, #footer, [role="contentinfo"]');
|
||
for (var pi = 0; pi < pfs.length; pi++) {
|
||
if (!pfs[pi].closest('#qa-guestwifi-iframe')) {
|
||
pfs[pi].setAttribute('data-qa-hidden', '1');
|
||
pfs[pi].style.setProperty('display', 'none', 'important');
|
||
}
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
|
||
// Guest WiFi form is rendered inside the iframe with its own
|
||
// LuCI page-actions. Make sure they are visible inside the iframe.
|
||
try {
|
||
var doc = ifr.contentDocument;
|
||
if (doc) {
|
||
var pa = doc.querySelectorAll('.cbi-page-actions');
|
||
for (var i = 0; i < pa.length; i++) {
|
||
pa[i].style.display = '';
|
||
pa[i].style.visibility = '';
|
||
pa[i].style.marginTop = '14px';
|
||
pa[i].style.paddingTop = '12px';
|
||
pa[i].style.borderTop = '1px solid rgba(0,0,0,0.08)';
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
|
||
// Auto-resize: fire multiple times to catch late DOM/CSS settling
|
||
setTimeout(autoSizeIframe, 150);
|
||
setTimeout(autoSizeIframe, 500);
|
||
setTimeout(autoSizeIframe, 1500);
|
||
setTimeout(autoSizeIframe, 3000);
|
||
|
||
// Re-measure when iframe content changes (modals, save notifications, etc.)
|
||
// Disconnect previous observer to avoid stacking on every iframe reload
|
||
if (self._guestWifiMO) { try { self._guestWifiMO.disconnect(); } catch(e) {} self._guestWifiMO = null; }
|
||
try {
|
||
var d2 = ifr.contentDocument;
|
||
if (d2 && d2.body && window.MutationObserver) {
|
||
self._guestWifiMO = new MutationObserver(function() { autoSizeIframe(); });
|
||
self._guestWifiMO.observe(d2.body, { childList: true, subtree: true, attributes: true });
|
||
}
|
||
} catch(e) {}
|
||
});
|
||
ifr.src = iframeUrl;
|
||
|
||
// Force the iframe and its ancestors to full width from
|
||
// the OUTER view. Runs independently of the embed's own
|
||
// JS so it survives any parent-side re-render or theme
|
||
// rule that re-applies .container{max-width:940px}.
|
||
function qaForceIframeFullWidth() {
|
||
try {
|
||
var el = ifr;
|
||
var i = 0;
|
||
while (el && el.tagName && el.tagName !== 'BODY' && i < 12) {
|
||
try {
|
||
el.style.setProperty('width', '100%', 'important');
|
||
el.style.setProperty('max-width', 'none', 'important');
|
||
} catch(e) {}
|
||
el = el.parentNode; i++;
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
if (self._qaForceTimer) { clearInterval(self._qaForceTimer); }
|
||
qaForceIframeFullWidth();
|
||
setTimeout(qaForceIframeFullWidth, 50);
|
||
setTimeout(qaForceIframeFullWidth, 250);
|
||
setTimeout(qaForceIframeFullWidth, 800);
|
||
setTimeout(qaForceIframeFullWidth, 2000);
|
||
self._qaForceTimer = setInterval(qaForceIframeFullWidth, 1000);
|
||
|
||
// Belt-and-suspenders: poll a few times in case load fires early
|
||
setTimeout(stripEmbedChrome, 150);
|
||
setTimeout(stripEmbedChrome, 500);
|
||
setTimeout(stripEmbedChrome, 1200);
|
||
setTimeout(stripEmbedChrome, 2500);
|
||
tabContent.innerHTML = '';
|
||
tabContent.appendChild(infoBar);
|
||
tabContent.appendChild(ifr);
|
||
}
|
||
else if (id === 'ttyd') {
|
||
tabContent.innerHTML = '';
|
||
tabContent.appendChild(E('p', { 'style': 'color:#888;padding:20px;' }, 'Loading ttyd configuration...'));
|
||
Promise.resolve(typeof uci.unload === 'function' ? uci.unload('ttyd') : null).then(function() {
|
||
return uci.load('ttyd');
|
||
}).then(function() {
|
||
return self.renderTtyd();
|
||
}).then(function(node) {
|
||
tabContent.innerHTML = '';
|
||
tabContent.appendChild(node);
|
||
}).catch(function(err) {
|
||
tabContent.innerHTML = '';
|
||
tabContent.appendChild(E('div', { 'class': 'alert-message error' }, 'ttyd error: ' + (err && err.message ? err.message : String(err))));
|
||
});
|
||
}
|
||
else if (id === 'taskplan') tabContent.appendChild(self.renderTaskPlan());
|
||
else if (id === 'netwizard') {
|
||
tabContent.appendChild(E('p', { 'style': 'color:#888;padding:20px;' }, 'Loading network config...'));
|
||
Promise.resolve(self.renderNetworkSetup()).then(function(node) {
|
||
tabContent.innerHTML = '';
|
||
tabContent.appendChild(node);
|
||
}).catch(function(err) {
|
||
tabContent.innerHTML = '';
|
||
tabContent.appendChild(E('div', { 'class': 'alert-message error' }, 'Network Setup error: ' + (err && err.message ? err.message : String(err))));
|
||
});
|
||
}
|
||
else if (id === 'scripts') tabContent.appendChild(self.renderScripts());
|
||
else if (id === 'vlan') tabContent.appendChild(self.renderVlan());
|
||
else if (id === 'failover') tabContent.appendChild(self.renderFailover());
|
||
else if (id === 'command') tabContent.appendChild(self.renderCommand());
|
||
else if (id === 'dependencies') tabContent.appendChild(self.renderDependencies());
|
||
else if (id === 'config') {
|
||
tabContent.appendChild(E('p', { 'style': 'color:#888;padding:20px;' }, 'Loading configuration...'));
|
||
self.renderConfigForm().then(function(node) {
|
||
tabContent.innerHTML = '';
|
||
tabContent.appendChild(node);
|
||
|
||
// Add our own Save / Save & Apply / Reset buttons
|
||
var btnRow = E('div', { 'class': 'qa-page-actions', 'style': 'margin-top:20px;padding-top:15px;border-top:1px solid rgba(0,0,0,0.08);display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;' }, [
|
||
E('button', { 'class': 'btn cbi-button cbi-button-reset', 'click': function() {
|
||
if (!confirm('Discard unsaved changes to this tab?')) return;
|
||
self.switchTab('config');
|
||
} }, _('Reset')),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-save', 'click': function() {
|
||
var map = self.configMap;
|
||
if (!map) { ui.addNotification('Error', 'Form not ready', 'danger'); return; }
|
||
map.save().then(function() {
|
||
ui.addNotification('Saved', 'Configuration saved to memory. Use the yellow bar at the top to apply.', 'info');
|
||
self.refreshPendingCount();
|
||
}).catch(function(err) { ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger'); });
|
||
} }, _('Save')),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-apply', 'click': function() {
|
||
var map = self.configMap;
|
||
if (!map) { ui.addNotification('Error', 'Form not ready', 'danger'); return; }
|
||
map.save().then(function() {
|
||
return self.pendingApply();
|
||
}).catch(function(err) { ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger'); });
|
||
} }, _('Save & Apply'))
|
||
]);
|
||
tabContent.appendChild(btnRow);
|
||
}).catch(function(err) {
|
||
tabContent.innerHTML = '';
|
||
tabContent.appendChild(E('div', { 'class': 'alert-message error' }, 'Form error: ' + (err && err.message ? err.message : String(err))));
|
||
});
|
||
}
|
||
}
|
||
tabs.forEach(function(t) {
|
||
var b = E('div', { 'style': 'padding:10px 16px;cursor:pointer;border-bottom:3px solid transparent;color:#666;font-weight:500;font-size:0.9em;user-select:none;', 'click': function() { showTab(t.id); } }, t.label);
|
||
tabButtons[t.id] = b;
|
||
tabBar.appendChild(b);
|
||
});
|
||
// Pending changes indicator
|
||
var pendingBar = E('div', { 'id': 'qa-pending-bar', 'style': 'display:none;background:rgba(255,193,7,0.15);border:1px solid rgba(255,193,7,0.5);border-radius:6px;padding:10px 14px;margin-bottom:14px;display:none;align-items:center;gap:12px;flex-wrap:wrap;' }, [
|
||
E('span', { 'id': 'qa-pending-count', 'style': 'font-weight:bold;' }, ''),
|
||
E('span', { 'style': 'flex:1;' }),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-reset', 'click': function() {
|
||
if (!confirm('Discard ALL pending UCI changes?')) return;
|
||
Promise.resolve(ui.changes.revert()).then(function() {
|
||
ui.addNotification('Reverted', 'All pending changes discarded.', 'info');
|
||
self.refreshPendingCount();
|
||
}).catch(function(err) {
|
||
ui.addNotification('Error', (err && err.message) ? err.message : String(err), 'danger');
|
||
});
|
||
} }, _('Discard All')),
|
||
E('button', { 'class': 'btn cbi-button cbi-button-apply important', 'click': function() {
|
||
self.pendingApply();
|
||
} }, _('Save & Apply All'))
|
||
]);
|
||
|
||
var container = E('div', { 'class': 'cbi-map' }, [
|
||
E('h2', { 'style': 'margin-bottom:4px;' }, 'Quick Actions'),
|
||
E('p', { 'style': 'margin-top:0;margin-bottom:20px;color:#888;font-style:italic;font-size:0.95em;' }, 'Live Status, Commands & Controls'),
|
||
pendingBar,
|
||
tabBar, tabContent
|
||
]);
|
||
|
||
self.pendingBar = pendingBar;
|
||
this.switchTab = showTab;
|
||
if (this.timerId) clearInterval(this.timerId);
|
||
this.timerId = setInterval(function() { self.updateAllButtonsRealtime(); }, this.pollInterval * 1000);
|
||
var initialTab = 'dashboard';
|
||
try {
|
||
// Strip leading '#', trailing '/', and any query after the tab id
|
||
var rawHash = (window.location.hash || '').replace(/^#/, '');
|
||
rawHash = rawHash.split('?')[0].split('/')[0];
|
||
var known = ['dashboard','essential','tools','logs','services','hotplug','crontab','guestwifi','ttyd','taskplan','netwizard','command','dependencies','scripts','vlan','failover','config'];
|
||
if (rawHash && known.indexOf(rawHash) !== -1) initialTab = rawHash;
|
||
} catch(e) {}
|
||
showTab(initialTab);
|
||
|
||
// Re-assert hash after LuCI's own dispatcher finishes touching the URL
|
||
setTimeout(function() {
|
||
try {
|
||
if (window.location.hash !== '#' + self.activeTab) {
|
||
if (window.history && window.history.replaceState) {
|
||
window.history.replaceState(null, '', '#' + self.activeTab);
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}, 200);
|
||
// Poll pending changes every 3s
|
||
self.refreshPendingCount();
|
||
setInterval(function() { self.refreshPendingCount(); }, 3000);
|
||
window.addEventListener('hashchange', function() {
|
||
var nh = (window.location.hash || '').replace(/^#/, '').split('?')[0].split('/')[0];
|
||
var known = ['dashboard','essential','tools','logs','services','hotplug','crontab','guestwifi','ttyd','taskplan','netwizard','command','dependencies','scripts','vlan','failover','config'];
|
||
if (nh && known.indexOf(nh) !== -1 && nh !== self.activeTab) showTab(nh);
|
||
});
|
||
|
||
// Hide LuCI's auto-generated Save/Apply/Reset footer.
|
||
// We manage our own per-tab save buttons, so LuCI's would either duplicate
|
||
// them (Hotplug/TaskPlan/Config) or appear uselessly (Crontab/Dependencies/Command).
|
||
self.hideLuCIPageActions();
|
||
return container;
|
||
},
|
||
|
||
hideLuCIPageActions: function() {
|
||
// Runs a few times to catch delayed DOM injection by LuCI dispatcher
|
||
function doHide() {
|
||
// LuCI typically appends page-actions to the view container's parent
|
||
var candidates = document.querySelectorAll(
|
||
'#view > .cbi-page-actions, ' +
|
||
'#view .cbi-page-actions:not(.qa-page-actions), ' +
|
||
'#maincontent > .cbi-page-actions, ' +
|
||
'.main > .cbi-page-actions, ' +
|
||
'body > .cbi-page-actions'
|
||
);
|
||
for (var i = 0; i < candidates.length; i++) {
|
||
candidates[i].style.display = 'none';
|
||
}
|
||
}
|
||
// Fire immediately, after microtask, and after network
|
||
doHide();
|
||
setTimeout(doHide, 50);
|
||
setTimeout(doHide, 250);
|
||
setTimeout(doHide, 1000);
|
||
},
|
||
|
||
handleSaveApply: function(ev, mode) {
|
||
if (!this.embedGuestWifi)
|
||
return Promise.resolve();
|
||
return this.embedGuestWifi.handleSave(ev).then(function() {
|
||
return ui.changes.apply(false);
|
||
}).catch(function(err) {
|
||
var msg = (err && err.message) ? err.message : String(err);
|
||
if (/No data|code 5/i.test(msg)) return;
|
||
ui.addNotification(null, E('p', {}, 'Apply failed: ' + msg), 'danger');
|
||
throw err;
|
||
});
|
||
},
|
||
handleSave: function(ev) {
|
||
if (!this.embedGuestWifi)
|
||
return Promise.resolve();
|
||
|
||
return this.embedGuestWifi.handleSave(ev).then(function() {
|
||
ui.addNotification(null,
|
||
E('p', {}, _('Guest WiFi configuration saved and applied.')),
|
||
'info');
|
||
}).catch(function(err) {
|
||
var msg = (err && err.message) ? err.message : String(err);
|
||
if (/No data|code 5/i.test(msg))
|
||
return;
|
||
ui.addNotification(null,
|
||
E('p', {}, _('Save failed:') + ' ' + msg),
|
||
'danger');
|
||
throw err;
|
||
});
|
||
},
|
||
handleReset: function(ev) {
|
||
return Promise.resolve();
|
||
}
|
||
});
|