Files
op-packages/luci-app-sysctl/htdocs/luci-static/resources/view/sysctl.js
T

1723 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* SPDX-License-Identifier: Apache-2.0
*
* luci-app-sysctl - LuCI view for kernel sysctl parameters (OpenWrt 24.10)
* v1.4.0: modal-free architecture - all views, editors, confirmations and
* results render inline on the page (robust against theme quirks on
* third-party firmwares where ui.showModal may be invisible).
*
* Section 1: custom parameters (/etc/sysctl.d/99-luci-sysctl.conf)
* Section 2: online preset (/etc/sysctl.d/98-online-preset.conf)
* Section 3: browse/search live kernel parameters from /proc/sys
*/
'use strict';
'require view';
'require rpc';
'require ui';
'require dom';
var callList = rpc.declare({ object: 'luci.sysctl', method: 'list' });
var callStatus = rpc.declare({ object: 'luci.sysctl', method: 'status' });
var callBrowse = rpc.declare({ object: 'luci.sysctl', method: 'browse', params: [ 'prefix' ] });
var callSearch = rpc.declare({ object: 'luci.sysctl', method: 'search', params: [ 'query', 'limit' ] });
var callSet = rpc.declare({ object: 'luci.sysctl', method: 'set', params: [ 'key', 'value', 'disabled', 'apply' ] });
var callRemove = rpc.declare({ object: 'luci.sysctl', method: 'remove', params: [ 'key' ] });
var callApply = rpc.declare({ object: 'luci.sysctl', method: 'apply' });
var callPresetStatus = rpc.declare({ object: 'luci.sysctl', method: 'preset_status' });
var callPresetFetch = rpc.declare({ object: 'luci.sysctl', method: 'preset_fetch', params: [ 'url', 'prefix' ] });
var callPresetImport = rpc.declare({ object: 'luci.sysctl', method: 'preset_import', params: [ 'url', 'prefix' ] });
var callPresetCheck = rpc.declare({ object: 'luci.sysctl', method: 'preset_check' });
var callPresetRemove = rpc.declare({
object: 'luci.sysctl',
method: 'preset_remove'
});
var callPresetList = rpc.declare({
object: 'luci.sysctl',
method: 'preset_list',
params: [ 'url', 'prefix' ]
});
var callFileView = rpc.declare({ object: 'luci.sysctl', method: 'file_view', params: [ 'path' ] });
var callFileSet = rpc.declare({ object: 'luci.sysctl', method: 'file_set', params: [ 'path', 'key', 'value', 'disabled' ] });
var callFileDelete = rpc.declare({ object: 'luci.sysctl', method: 'file_delete', params: [ 'path', 'key' ] });
var callDupCheck = rpc.declare({ object: 'luci.sysctl', method: 'dup_check', params: [ 'key' ] });
var KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\/-]*$/;
function badge(text, kind) {
return E('span', { 'class': 'lsc-badge lsc-badge-' + kind }, text);
}
var LSC_CSS = [
'.lsc-root { font-size: 15px; }',
'.lsc-root .lsc-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 6px 0 2px; }',
'.lsc-root .lsc-chip { appearance: none; border: 1px solid #d0d7de; background: #fff; color: #24292f; border-radius: 999px; padding: 4px 12px; font-size: 12.5px; line-height: 1.5; cursor: pointer; text-transform: none; font-weight: 500; transition: background .15s, color .15s, border-color .15s, box-shadow .15s; box-shadow: 0 1px 2px rgba(16,24,40,.04); }',
'.lsc-root .lsc-chip:hover { border-color: #0969da; color: #0969da; background: #f3f8ff; }',
'.lsc-root .lsc-chip.lsc-chip-active { background: #0969da; border-color: #0969da; color: #fff; }',
'.lsc-root .lsc-tablewrap { border: 1px solid #e4e7ec; border-radius: 8px; overflow: auto; background: #fff; margin: 8px 0; }',
'.lsc-root table.lsc-table { width: 100%; border-collapse: collapse; font-size: 15px; margin: 0; }',
'.lsc-root .lsc-table thead th { background: #f6f8fa; text-align: center !important; font-weight: 600; color: #57606a; padding: 10px 12px; border-bottom: 1px solid #e4e7ec; white-space: nowrap; font-size: 15px; }',
'.lsc-root .lsc-table tbody td { padding: 10px 12px; border-bottom: 1px solid #f0f2f4; vertical-align: middle; text-align: center !important; }',
'.lsc-root .lsc-table tbody td .cbi-button { margin: 2px 3px; }',
'.lsc-root .lsc-table tbody tr:last-child td { border-bottom: none; }',
'.lsc-root .lsc-table tbody tr:nth-child(even) td { background: #fafbfc; }',
'.lsc-root .lsc-table tbody tr:hover td { background: #f0f6ff; }',
'.lsc-root .lsc-badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 13px; font-weight: 500; line-height: 1.6; white-space: nowrap; }',
'.lsc-root .lsc-badge-ok { background: #e6f6ec; color: #18794e; }',
'.lsc-root .lsc-badge-warn { background: #fff3e0; color: #a35a00; }',
'.lsc-root .lsc-badge-err { background: #fdecec; color: #b3261e; }',
'.lsc-root .lsc-badge-off { background: #eef0f2; color: #6e7781; }',
'.lsc-root .lsc-badge-main { background: #eef4fb; color: #34618e; }',
'.lsc-root .lsc-card { border: 1px solid #e4e7ec; border-radius: 10px; padding: 14px 16px; background: #fff; box-shadow: 0 1px 3px rgba(16,24,40,.06); margin: 10px 0; }',
'.lsc-root .lsc-card h3 { margin: 0 0 8px; font-size: 15px; font-weight: 600; }',
'.lsc-root .lsc-card h4 { margin: 10px 0 4px; font-size: 13.5px; color: #57606a; }',
'.lsc-root code { background: #f6f8fa; border: 1px solid #eef0f2; border-radius: 4px; padding: 1px 6px; font-size: 1em; word-break: break-all; }',
'.lsc-root .cbi-button { border-radius: 6px; transition: filter .15s, box-shadow .15s, background .15s; text-transform: none; }',
'.lsc-root .cbi-button:hover { filter: brightness(1.06); }',
'.lsc-root h2, .lsc-root h3, .lsc-root h4, .lsc-root th, .lsc-root .lsc-badge, .lsc-root code, .lsc-root .lsc-muted, .lsc-root .cbi-section-descr, .lsc-root .lsc-card * { text-transform: none !important; }',
'.lsc-root .lsc-muted { color: #777; }',
'.lsc-root .cbi-page-actions { float: none !important; text-align: center !important; display: flex !important; flex-wrap: wrap; justify-content: center !important; align-items: center; gap: .5rem; }',
'.lsc-root .cbi-page-actions > br { display: none; }'
].join('\n');
return view.extend({
ensureStyle: function() {
if (document.getElementById('luci-app-sysctl-style') != null)
return;
document.head.appendChild(E('style', { 'id': 'luci-app-sysctl-style' }, [ LSC_CSS ]));
},
load: function() {
return Promise.all([ callList(), callStatus(), callPresetStatus() ]);
},
render: function(data) {
this.ensureStyle();
var list = (data[0] != null) ? data[0] : {};
var status = (data[1] != null) ? data[1] : {};
var preset = (data[2] != null) ? data[2] : {};
this.customEntries = list.custom || [];
this.mainEntries = list.main || [];
this.statusData = status;
this.presetData = preset;
this.browsePrefix = '';
this.searchTimer = null;
this.editing = null;
this.fileViewPath = null;
this.fileEditing = null;
this._armTimers = {};
var root = E('div', { 'class': 'cbi-map lsc-root' }, [
E('h2', {}, _('内核参数(Sysctl)管理')),
E('div', { 'class': 'cbi-map-descr' }, _(
'自定义参数保存在 %s,开机时由系统自动加载,也可随时点击“应用配置”立即生效。').format('/etc/sysctl.d/99-luci-sysctl.conf')),
this.renderCustomSection(),
this.renderPresetSection(),
this.renderBrowseSection()
]);
this.refreshCustomTable();
this.refreshSourceChips();
this.loadBrowse('');
return root;
},
/* ---------- shared helpers ---------- */
errorBox: function(msg, hint) {
var children = [ E('p', { 'style': 'margin:2px 0' }, msg) ];
if (hint)
children.push(E('p', { 'style': 'margin:2px 0;color:#666' }, hint));
return E('div', { 'class': 'alert-message error', 'style': 'margin:6px 0' }, children);
},
backendErrorHint: function(msg) {
if (/not found/i.test(msg || ''))
return _('后端缺少该方法,通常是 rpcd 尚未重新加载插件所致。请在 SSH 执行:%s 后刷新页面。').format('/etc/init.d/rpcd restart');
return _('可在 SSH 用 %s 验证后端。').format('ubus call luci.sysctl status');
},
/* Two-step inline confirmation: first click arms the button,
* second click (within 4s) runs the action. */
armButton: function(btn, confirmText, action) {
if (btn.getAttribute('data-armed') == '1') {
btn.setAttribute('data-armed', '0');
btn.textContent = btn.getAttribute('data-orig');
action();
return;
}
btn.setAttribute('data-armed', '1');
btn.setAttribute('data-orig', btn.textContent);
btn.textContent = confirmText;
this._armTimers[btn.getAttribute('data-arm-id')] = window.setTimeout(function() {
if (btn.getAttribute('data-armed') == '1') {
btn.setAttribute('data-armed', '0');
btn.textContent = btn.getAttribute('data-orig');
}
}, 4000);
},
nextArmId: function() {
this._armSeq = (this._armSeq || 0) + 1;
return String(this._armSeq);
},
/* ---------- section 1: custom parameters ---------- */
renderCustomSection: function() {
this.customTableBody = E('tbody', {});
this.tableWrapBox = E('div', { 'class': 'lsc-tablewrap' });
this.customEmptyBox = E('div', {
'style': 'display:none;text-align:center;color:#777;padding:10px 0'
}, _('暂无自定义参数,点击下方“添加参数”开始。'));
this.editFormBox = E('div', { 'style': 'display:none' });
this.fileViewBox = E('div', { 'style': 'display:none' });
this.dupBox = E('div', { 'style': 'display:none' });
this.applyResultBox = E('div', { 'style': 'display:none' });
this.sourceChipsBox = E('div', { 'class': 'lsc-chips' });
var descr = [
E('p', { 'style': 'margin:4px 0' }, [
_('配置源(点击文件名可在下方查看/编辑,按顺序应用,后面的文件覆盖前面的同名参数):'),
this.sourceChipsBox
])
];
var table = E('table', { 'class': 'lsc-table' }, [
E('thead', {}, E('tr', {}, [
E('th', {}, _('参数名')),
E('th', {}, _('配置值')),
E('th', {}, _('当前值')),
E('th', {}, _('状态')),
E('th', {}, _('操作'))
])),
this.customTableBody
]);
this.tableWrapBox.appendChild(table);
return E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('自定义参数')),
E('div', { 'class': 'cbi-section-descr' }, descr),
this.applyResultBox,
this.editFormBox,
this.tableWrapBox,
this.customEmptyBox,
this.fileViewBox,
this.dupBox,
E('div', { 'class': 'cbi-page-actions' }, [
E('button', {
'class': 'cbi-button cbi-button-add',
'click': ui.createHandlerFn(this, 'showEditForm', null)
}, _('添加参数')),
' ',
E('button', {
'class': 'cbi-button cbi-button-apply',
'click': ui.createHandlerFn(this, 'applyConfig')
}, _('应用配置')),
' ',
E('button', {
'class': 'cbi-button',
'click': ui.createHandlerFn(this, 'showDupCheck')
}, _('检测重复')),
' ',
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': ui.createHandlerFn(this, 'reloadList')
}, _('刷新'))
])
]);
},
refreshCustomTable: function() {
var rows = [];
var i, e;
/* Main-config entries (/etc/sysctl.conf) are intentionally NOT listed
* here anymore: they are reachable through the source chips above,
* showing them twice was redundant. */
if (this.customEntries.length == 0) {
/* hide the whole (empty) table incl. header, show a one-liner */
this.tableWrapBox.style.display = 'none';
this.customEmptyBox.style.display = '';
dom.content(this.customTableBody, rows);
return;
}
this.tableWrapBox.style.display = '';
this.customEmptyBox.style.display = 'none';
for (i = 0; i < this.customEntries.length; i++) {
e = this.customEntries[i];
var badgeEl;
if (e.disabled)
badgeEl = badge(_('已禁用'), 'off');
else if (!e.exists)
badgeEl = badge(_('无效参数'), 'err');
else if (e.match)
badgeEl = badge(_('已生效'), 'ok');
else
badgeEl = badge(_('未应用'), 'warn');
var delBtn = E('button', { 'class': 'cbi-button cbi-button-remove' }, _('删除'));
rows.push(E('tr', {}, [
E('td', {}, [ E('code', {}, e.key) ]),
E('td', {}, [ E('code', {}, e.value) ]),
E('td', {}, [ E('code', {}, (e.current == null) ? '—' : e.current) ]),
E('td', {}, badgeEl),
E('td', { 'style': 'white-space:nowrap' }, [
E('button', {
'class': 'cbi-button cbi-button-edit',
'click': ui.createHandlerFn(this, 'showEditForm', { isNew: false, entry: e })
}, _('编辑')),
' ',
e.disabled
? E('button', { 'class': 'cbi-button cbi-button-apply', 'click': ui.createHandlerFn(this, 'toggleEntry', e, false) }, _('启用'))
: E('button', { 'class': 'cbi-button cbi-button-remove', 'click': ui.createHandlerFn(this, 'toggleEntry', e, true) }, _('禁用')),
' ',
delBtn
])
]));
(function(view, entry, btn) {
btn.addEventListener('click', function() {
view.armButton(btn, _('确认删除?'), function() { view.removeEntry(entry); });
});
})(this, e, delBtn);
}
dom.content(this.customTableBody, rows);
},
showEditForm: function(entry) {
/* "添加参数" button calls this with null -> use a blank new-entry
* state, otherwise renderEditForm would bail out and the form would
* never open (latent bug since v1.0, other entries always passed an
* object so it never surfaced until now). */
this.editing = (entry != null) ? entry : { isNew: true };
this.editFromBrowse = (entry != null && entry.fromBrowse === true);
this.renderEditForm();
if (this.editing != null && this.editFormBox.scrollIntoView)
this.editFormBox.scrollIntoView({ block: 'nearest' });
},
renderEditForm: function() {
var self = this;
var st = this.editing;
if (st == null) {
this.editFormBox.style.display = 'none';
dom.content(this.editFormBox, []);
return;
}
this.editFormBox.style.display = '';
var isNew = (st.isNew === true);
var key = (st.entry != null) ? st.entry.key : (st.key || '');
var keyInput = E('input', {
'type': 'text', 'class': 'cbi-input-text', 'style': 'width:100%',
'placeholder': 'net.ipv4.tcp_fastopen', 'value': key
});
var valInput = E('input', {
'type': 'text', 'class': 'cbi-input-text', 'style': 'width:100%',
'placeholder': '3', 'value': (st.entry != null) ? st.entry.value : (st.value || '')
});
var applyChk = E('input', { 'type': 'checkbox' });
applyChk.checked = true;
var disabledChk = E('input', { 'type': 'checkbox' });
disabledChk.checked = (!isNew && st.entry.disabled == true);
var errBox = this.errorBox('', '');
errBox.style.display = 'none';
var title = isNew
? (st.fromMain ? _('自定义覆盖主配置:%s').format(key) : _('添加内核参数'))
: _('编辑内核参数:%s').format(key);
var note = isNew
? (st.fromMain
? _('将把该参数写入自定义配置以覆盖主配置取值,主配置文件本身保持不变。')
: _('若参数在当前内核中不存在(例如模块未加载),仍可保存,但会标记为无效。'))
: _('修改参数名保存后将替换原条目(先写入新参数名,再删除旧参数名)。');
var saveBtn = E('button', { 'class': 'btn cbi-button-positive important' }, _('保存'));
saveBtn.addEventListener('click', function() { self.saveEditForm(keyInput, valInput, applyChk, disabledChk, errBox); });
var editDupBox = E('div', { 'style': 'display:none' });
this.editDupBox = editDupBox;
keyInput.addEventListener('input', function() {
if (self.editDupTimer != null)
window.clearTimeout(self.editDupTimer);
var v = keyInput.value;
self.editDupTimer = window.setTimeout(function() {
self.refreshEditDup((v || '').trim());
}, 400);
});
dom.content(this.editFormBox, [
E('div', { 'class': 'lsc-card' }, [
E('strong', {}, title),
E('div', { 'class': 'cbi-value', 'style': 'margin-top:8px' }, [
E('label', { 'class': 'cbi-value-title' }, _('参数名')),
E('div', { 'class': 'cbi-value-field' }, keyInput)
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('值')),
E('div', { 'class': 'cbi-value-field' }, valInput)
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, ' '),
E('div', { 'class': 'cbi-value-field' }, [
E('label', { 'style': 'display:inline-flex;align-items:center;gap:6px' }, [ applyChk, _('立即写入内核(运行时生效)') ]),
E('label', { 'style': 'display:inline-flex;align-items:center;gap:6px;margin-left:16px' }, [ disabledChk, _('禁用该条目(写入配置但注释掉)') ])
])
]),
E('div', { 'style': 'color:#666;margin-top:4px' }, note),
editDupBox,
errBox,
E('div', { 'style': 'margin-top:8px' }, [
saveBtn, ' ',
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(self, 'hideEditForm') }, _('取消'))
])
])
]);
if (key != '')
this.refreshEditDup(key);
},
hideEditForm: function() {
this.editing = null;
if (this.editDupTimer != null) {
window.clearTimeout(this.editDupTimer);
this.editDupTimer = null;
}
this.renderEditForm();
/* edit was started from the browse section: scroll back there so the
* user does not get lost at the top of the page */
if (this.editFromBrowse) {
this.editFromBrowse = false;
if (this.browseSectionBox != null && this.browseSectionBox.scrollIntoView)
this.browseSectionBox.scrollIntoView({ block: 'start' });
}
},
/* Summarize where a key is defined besides the custom config, and whether
* any of those files load AFTER the custom config (=> would override it). */
summarizeDup: function(d) {
var customPath = '/etc/sysctl.d/99-luci-sysctl.conf';
var files = (this.statusData != null && this.statusData.files != null) ? this.statusData.files : [];
var paths = [];
var i;
for (i = 0; i < files.length; i++)
paths.push(files[i].path);
var customIdx = paths.indexOf(customPath);
var others = [];
var shadowed = false;
for (i = 0; i < d.entries.length; i++) {
var e = d.entries[i];
if (e.path == customPath)
continue;
others.push(e.path.replace('/etc/', '') + ' (' + e.value + ')');
if (!e.disabled && customIdx >= 0 && paths.indexOf(e.path) > customIdx)
shadowed = true;
}
return { others: others, shadowed: shadowed };
},
/* Inline warning inside the add/edit form: warn BEFORE saving that the
* key is also defined in other config files (and whether those files
* would override the custom entry). */
refreshEditDup: function(key) {
var self = this;
var box = this.editDupBox;
if (box == null)
return;
if (key == null || key == '' || !KEY_RE.test(key)) {
box.style.display = 'none';
dom.content(box, []);
return;
}
callDupCheck(key).then(function(res) {
var dups = (res != null && res.dups != null) ? res.dups : [];
if (dups.length == 0 || self.editing == null) {
box.style.display = 'none';
dom.content(box, []);
return;
}
var sum = self.summarizeDup(dups[0]);
if (sum.others.length == 0) {
box.style.display = 'none';
dom.content(box, []);
return;
}
var msg = sum.shadowed
? _('注意:该参数在自定义配置之后加载的文件中也有定义(%s),保存后会被覆盖、不会生效。请直接编辑对应文件或删除其同名条目。').format(sum.others.join('、'))
: _('该参数在其他文件中也有定义(%s)。自定义配置加载顺序靠后,保存后以这里的值为准。').format(sum.others.join('、'));
box.style.display = '';
dom.content(box, [
E('div', { 'style': (sum.shadowed ? 'color:#b3261e' : 'color:#a35a00') + ';margin-top:4px' }, msg)
]);
}).catch(function() {
box.style.display = 'none';
dom.content(box, []);
});
},
saveEditForm: function(keyInput, valInput, applyChk, disabledChk, errBox) {
var self = this;
var st = this.editing;
if (st == null)
return;
var isNew = (st.isNew === true);
var origKey = (st.entry != null) ? st.entry.key : null;
var key = (keyInput.value || '').trim();
var val = (valInput.value || '').trim();
var disabled = disabledChk.checked;
var applyNow = applyChk.checked && !disabled;
errBox.style.display = 'none';
if (!KEY_RE.test(key)) {
errBox.textContent = '';
errBox.appendChild(E('p', {}, _('参数名格式无效,仅允许字母、数字、点、下划线和连字符。')));
errBox.style.display = '';
return;
}
if (val == '') {
errBox.textContent = '';
errBox.appendChild(E('p', {}, _('值不能为空。')));
errBox.style.display = '';
return;
}
var p = callSet(key, val, disabled, applyNow);
if (!isNew && key != origKey) {
p = p.then(function(res) {
if (res == null || res.code != 0)
return res;
return callRemove(origKey).then(function() {
return res;
}).catch(function() {
res.rename_leftover = origKey;
return res;
});
});
}
p.then(function(res) {
if (res == null || res.code != 0) {
errBox.textContent = '';
errBox.appendChild(self.errorBox(_('保存失败:%s').format((res != null && res.error) ? res.error : _('未知错误'))));
errBox.style.display = '';
return;
}
var notes = [];
if (res.rename_leftover)
notes.push(E('p', { 'style': 'color:#a80' }, _('旧参数 %s 删除失败,请在本页手动删除该条目。').format(res.rename_leftover)));
if (!res.exists)
notes.push(E('p', { 'style': 'color:#a80' }, _('参数 %s 在当前内核中不存在(可能模块未加载),配置已保存但不会生效。').format(key)));
else if (applyNow && res.applied === 'readonly')
notes.push(E('p', { 'style': 'color:#a80' }, _('参数 %s 为只读,无法在运行时修改(配置已保存,重启后尝试应用)。').format(key)));
else if (applyNow && res.applied === false)
notes.push(E('p', { 'style': 'color:#a80' }, _('参数 %s 已写入配置,但运行时校验不一致,请检查取值格式。').format(key)));
var hasWarn = (res.rename_leftover != null) || (!res.exists) || (applyNow && (res.applied === 'readonly' || res.applied === false));
var saveAutoHide = hasWarn ? 8000 : 4000;
if (notes.length == 0)
notes.push(E('p', {}, (st.fromMain && isNew) ? _('已创建覆盖条目:%s。') : _('参数 %s 已保存。').format(key)));
self.hideEditForm();
self.reloadList();
self.showApplyResult([ E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, notes) ], saveAutoHide);
/* post-save heads-up: same key also defined in other files? */
callDupCheck(key).then(function(dres) {
var dups = (dres != null && dres.dups != null) ? dres.dups : [];
if (dups.length == 0)
return;
var sum = self.summarizeDup(dups[0]);
if (sum.others.length == 0)
return;
var msg = sum.shadowed
? _('注意:该参数在自定义配置之后加载的文件中也有定义(%s),你的取值会被覆盖。').format(sum.others.join('、'))
: _('该参数在其他文件中也有定义(%s),自定义配置加载靠后,以你的值为准。').format(sum.others.join('、'));
notes.push(E('p', { 'style': 'color:' + (sum.shadowed ? '#b3261e' : '#a35a00') }, msg));
self.showApplyResult([ E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, notes) ], 8000);
}).catch(function() {});
}).catch(function(e) {
errBox.textContent = '';
errBox.appendChild(self.errorBox(_('保存失败:%s').format(e.message), self.backendErrorHint(e.message)));
errBox.style.display = '';
});
},
reloadList: function() {
var self = this;
return Promise.all([ callList(), callStatus(), callPresetStatus() ]).then(function(data) {
var list = (data[0] != null) ? data[0] : {};
self.customEntries = list.custom || [];
self.mainEntries = list.main || [];
self.statusData = (data[1] != null) ? data[1] : {};
self.presetData = (data[2] != null) ? data[2] : {};
self.refreshCustomTable();
self.refreshPresetStatus();
self.refreshSourceChips();
/* re-render an open file panel so its "当前值" column reflects
* the kernel state AFTER an apply/refresh (values were stale
* otherwise, making applied settings look ineffective) */
if (self.fileViewPath != null)
self.renderFilePanel();
}).catch(function(e) {
ui.addNotification(null, E('p', {}, _('加载失败:%s').format(e.message)), 'error');
});
},
refreshSourceChips: function() {
var self = this;
var files = (this.statusData != null && this.statusData.files != null) ? this.statusData.files : [];
if (this.sourceChipsBox == null)
return;
var chips = [];
/* Plugin-managed files (98-online-preset.conf, 99-luci-sysctl.conf)
* are managed through their dedicated sections; opening them via the
* file panel is refused by the backend, so hide their chips. */
var pluginManaged = {
'/etc/sysctl.d/99-luci-sysctl.conf': true,
'/etc/sysctl.d/98-online-preset.conf': true
};
for (var i = 0; i < files.length; i++) {
var f = files[i];
if (pluginManaged[f.path])
continue;
chips.push(E('button', {
'class': 'lsc-chip' + (this.fileViewPath == f.path ? ' lsc-chip-active' : ''),
'title': _('点击查看并可编辑该文件内的参数'),
'click': ui.createHandlerFn(self, 'showFileView', f.path)
}, '%s (%d)'.format(f.path.replace('/etc/', ''), f.count)));
}
dom.content(this.sourceChipsBox, chips);
},
toggleEntry: function(entry, disabled) {
var self = this;
return callSet(entry.key, entry.value, disabled, false).then(function(res) {
if (res == null || res.code != 0) {
ui.addNotification(null,
E('p', {}, _('操作失败:%s').format((res != null && res.error) ? res.error : 'unknown error')),
'error');
}
self.reloadList();
}).catch(function(e) {
ui.addNotification(null, E('p', {}, _('操作失败:%s').format(e.message)), 'error');
});
},
removeEntry: function(entry) {
var self = this;
return callRemove(entry.key).then(function(res) {
if (res == null || res.code != 0) {
ui.addNotification(null,
E('p', {}, _('删除失败:%s').format((res != null && res.error) ? res.error : 'unknown error')),
'error');
}
self.reloadList();
}).catch(function(e) {
ui.addNotification(null, E('p', {}, _('删除失败:%s').format(e.message)), 'error');
});
},
/* Unified result-bar display: shows nodes, optional auto-hide after
* autoHideMs (0 = stay until replaced). Re-calling resets the timer. */
showApplyResult: function(nodes, autoHideMs) {
if (this.applyResultTimer != null) {
window.clearTimeout(this.applyResultTimer);
this.applyResultTimer = null;
}
dom.content(this.applyResultBox, nodes);
this.applyResultBox.style.display = '';
if (autoHideMs > 0) {
var box = this.applyResultBox;
this.applyResultTimer = window.setTimeout(function() {
box.style.display = 'none';
dom.content(box, []);
}, autoHideMs);
}
},
hideApplyResult: function() {
if (this.applyResultTimer != null) {
window.clearTimeout(this.applyResultTimer);
this.applyResultTimer = null;
}
this.applyResultBox.style.display = 'none';
dom.content(this.applyResultBox, []);
},
applyConfig: function() {
var self = this;
this.showApplyResult([ E('p', { 'style': 'color:#777;padding:6px' }, _('正在应用全部配置,请稍候…')) ], 0);
if (this.applyResultBox.scrollIntoView)
this.applyResultBox.scrollIntoView({ block: 'nearest' });
return callApply().then(function(res) {
var errors = (res != null && res.errors != null) ? res.errors : [];
var content;
if (res == null || res.code != 0) {
content = self.errorBox(_('应用失败:%s').format((res != null && res.error) ? res.error : 'unknown error'));
}
else if (errors.length == 0) {
content = E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, [
E('p', {}, _('全部配置已成功应用。')),
E('p', { 'style': 'color:#777;font-size:12px;margin:2px 0 0' }, _('(本提示将在数秒后自动消失)'))
]);
}
else {
var items = [];
for (var i = 0; i < errors.length; i++) {
var er = errors[i];
var parts = [ E('strong', {}, er.file) ];
/* real command errors (permission denied etc.) */
if (er.output)
parts.push(E('pre', { 'style': 'white-space:pre-wrap;margin:4px 0' }, er.output));
/* kernel did not accept these values (echo was silent,
* but read-back differs) */
if (er.not_applied != null && er.not_applied.length > 0) {
var naItems = [];
for (var j = 0; j < er.not_applied.length; j++) {
var na = er.not_applied[j];
naItems.push(E('li', {}, _('%s:写入 %s,内核当前 %s').format(
na.key, na.value, na.current)));
}
parts.push(E('p', { 'style': 'margin:4px 0' },
_('以下参数内核未接受(可能被限制、随后被覆盖或需要重启生效):')));
parts.push(E('ul', { 'style': 'margin:2px 0' }, naItems));
}
items.push(E('li', {}, parts));
}
content = E('div', { 'class': 'alert-message warning', 'style': 'margin:6px 0' }, [
E('p', {}, _('应用完成,以下内容需要留意(常见原因:参数不存在、只读、被内核拒绝或被其他文件覆盖):')),
E('ul', {}, items)
]);
}
self.showApplyResult([ content ], (res != null && res.code == 0 && errors.length == 0) ? 4000 : 0);
if (self.applyResultBox.scrollIntoView)
self.applyResultBox.scrollIntoView({ block: 'nearest' });
self.reloadList();
}).catch(function(e) {
self.showApplyResult([ self.errorBox(_('应用失败:%s').format(e.message), self.backendErrorHint(e.message)) ], 0);
});
},
/* ---------- per-file viewing / editing (inline) ---------- */
showFileView: function(path) {
this.fileViewPath = (this.fileViewPath == path) ? null : path;
this.fileEditing = null;
this.refreshSourceChips();
this.renderFilePanel();
},
renderFilePanel: function() {
var self = this;
var path = this.fileViewPath;
var box = this.fileViewBox;
if (path == null) {
box.style.display = 'none';
dom.content(box, []);
return;
}
box.style.display = '';
dom.content(box, [
E('h3', { 'style': 'text-align:center' }, _('文件内容:%s').format(path)),
E('p', { 'style': 'color:#777;text-align:center' }, _('加载中…'))
]);
callFileView(path).then(function(res) {
if (self.fileViewPath != path)
return;
if (res == null || res.code != 0) {
dom.content(box, [
E('h3', { 'style': 'text-align:center' }, _('文件内容:%s').format(path)),
self.errorBox((res != null && res.error) ? res.error : _('未知错误'), self.backendErrorHint(res != null ? res.error : ''))
]);
return;
}
var rows = [];
for (var i = 0; i < (res.entries || []).length; i++) {
var e = res.entries[i];
var editing = (self.fileEditing != null && self.fileEditing.key == e.key);
if (editing) {
rows.push(self.renderFileEditRow(res.path, e));
continue;
}
var ops;
if (res.editable) {
var delBtn = E('button', { 'class': 'cbi-button cbi-button-remove' }, _('删除'));
ops = [
E('button', { 'class': 'cbi-button cbi-button-edit', 'click': ui.createHandlerFn(self, 'editFileEntry', path, e) }, _('编辑')),
' ',
e.disabled
? E('button', { 'class': 'cbi-button cbi-button-apply', 'click': ui.createHandlerFn(self, 'fileToggle', path, e, false) }, _('启用'))
: E('button', { 'class': 'cbi-button cbi-button-remove', 'click': ui.createHandlerFn(self, 'fileToggle', path, e, true) }, _('禁用')),
' ',
delBtn
];
(function(view, p, entry, btn) {
btn.addEventListener('click', function() {
view.armButton(btn, _('确认删除?'), function() { view.fileDelete(p, entry); });
});
})(self, res.path, e, delBtn);
}
else {
ops = [ E('button', {
'class': 'cbi-button cbi-button-add',
'click': ui.createHandlerFn(self, 'showEditForm', { isNew: true, key: e.key, value: e.value, fromMain: true })
}, _('自定义')) ];
}
rows.push(E('tr', {}, [
E('td', {}, [ E('code', {}, (e.disabled ? '# ' : '') + e.key) ]),
E('td', {}, [ E('code', {}, e.value) ]),
E('td', {}, [ E('code', {
/* value differs from kernel state: likely overridden by a
* later config file (or write failed) -> highlight it */
'style': (!e.disabled && e.current != null && e.current != e.value) ? 'color:#a35a00;font-weight:600' : ''
}, (e.disabled || e.current == null) ? '—' : e.current) ]),
E('td', { 'style': 'white-space:nowrap' }, ops)
]));
}
if (rows.length == 0)
rows.push(E('tr', {}, E('td', { 'colspan': 4, 'style': 'text-align:center;color:#777;padding:12px' },
_('(空文件)'))));
var note = res.editable
? _('此文件中的参数可直接编辑/禁用/删除,保存即写入 %s;点击"应用配置"后对内核生效。').format(res.path)
: _('此文件由插件管理,不支持直接编辑:在线预设请在下方"在线预设"区更新,自定义参数请在上方表格中管理。');
dom.content(box, [
E('h3', { 'style': 'text-align:center' }, _('文件内容:%s').format(res.path)),
E('p', { 'style': 'margin:4px 0;color:#666;text-align:center' }, note),
E('div', { 'class': 'lsc-tablewrap' }, E('table', { 'class': 'lsc-table' }, [
E('thead', {}, E('tr', {}, [
E('th', {}, _('参数')),
E('th', {}, _('配置值')),
E('th', {}, _('当前值')),
E('th', {}, _('操作'))
])),
E('tbody', rows)
])),
E('div', { 'style': 'margin-top:8px;text-align:center' }, [
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(self, 'hideFileView') }, _('收起'))
])
]);
}).catch(function(e) {
if (self.fileViewPath != path)
return;
dom.content(box, [
E('h3', { 'style': 'text-align:center' }, _('文件内容:%s').format(path)),
self.errorBox(_('读取失败:%s').format(e.message), self.backendErrorHint(e.message))
]);
});
},
renderFileEditRow: function(path, entry) {
var self = this;
var valInput = E('input', {
'type': 'text', 'class': 'cbi-input-text', 'style': 'width:95%', 'value': entry.value
});
var disabledChk = E('input', { 'type': 'checkbox' });
disabledChk.checked = (entry.disabled == true);
var saveBtn = E('button', { 'class': 'cbi-button cbi-button-positive important' }, _('保存'));
saveBtn.addEventListener('click', function() {
var val = (valInput.value || '').trim();
if (val == '') {
alert(_('值不能为空。'));
return;
}
callFileSet(path, entry.key, val, disabledChk.checked).then(function(res) {
if (res == null || res.code != 0) {
alert(_('保存失败:%s').format((res != null && res.error) ? res.error : _('未知错误')));
return;
}
self.fileEditing = null;
self.renderFilePanel();
self.reloadList();
}).catch(function(e) {
alert(_('保存失败:%s').format(e.message));
});
});
return E('tr', { 'style': 'background:#fafafa' }, [
E('td', {}, [ E('code', {}, entry.key) ]),
E('td', { 'colspan': 2 }, [
valInput, ' ',
E('label', { 'style': 'display:inline-flex;align-items:center;gap:4px' }, [ disabledChk, _('禁用') ])
]),
E('td', { 'style': 'white-space:nowrap' }, [
saveBtn, ' ',
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(self, 'cancelFileEdit') }, _('取消'))
])
]);
},
editFileEntry: function(path, entry) {
this.fileEditing = entry;
this.renderFilePanel();
},
cancelFileEdit: function() {
this.fileEditing = null;
this.renderFilePanel();
},
fileToggle: function(path, entry, disabled) {
var self = this;
return callFileSet(path, entry.key, entry.value, disabled).then(function(res) {
if (res == null || res.code != 0) {
ui.addNotification(null,
E('p', {}, _('操作失败:%s').format((res != null && res.error) ? res.error : _('未知错误'))),
'error');
}
self.renderFilePanel();
self.reloadList();
}).catch(function(e) {
ui.addNotification(null, E('p', {}, _('操作失败:%s').format(e.message)), 'error');
});
},
fileDelete: function(path, entry) {
var self = this;
return callFileDelete(path, entry.key).then(function(res) {
if (res == null || res.code != 0) {
ui.addNotification(null,
E('p', {}, _('删除失败:%s').format((res != null && res.error) ? res.error : _('未知错误'))),
'error');
}
self.renderFilePanel();
self.reloadList();
}).catch(function(e) {
ui.addNotification(null, E('p', {}, _('删除失败:%s').format(e.message)), 'error');
});
},
hideFileView: function() {
this.fileViewPath = null;
this.fileEditing = null;
this.refreshSourceChips();
this.renderFilePanel();
},
/* ---------- duplicate-key detection & cleanup ---------- */
showDupCheck: function() {
var self = this;
this.dupBox.style.display = '';
dom.content(this.dupBox, [
E('h3', { 'style': 'text-align:center' }, _('同名参数检测')),
E('p', { 'style': 'color:#777;text-align:center' }, _('扫描中…'))
]);
if (this.dupBox.scrollIntoView)
this.dupBox.scrollIntoView({ block: 'start' });
callDupCheck('').then(function(res) {
self.renderDupResults((res != null && res.dups != null) ? res.dups : []);
}).catch(function(e) {
dom.content(self.dupBox, [
E('h3', { 'style': 'text-align:center' }, _('同名参数检测')),
self.errorBox(_('扫描失败:%s').format(e.message), self.backendErrorHint(e.message))
]);
});
},
hideDupCheck: function() {
this.dupBox.style.display = 'none';
dom.content(this.dupBox, []);
},
renderDupResults: function(dups) {
var self = this;
var rows = [];
var customPath = '/etc/sysctl.d/99-luci-sysctl.conf';
var collapseBtn = E('div', { 'style': 'text-align:center;margin-top:8px' }, [
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(self, 'hideDupCheck') }, _('收起'))
]);
if (dups.length == 0) {
dom.content(this.dupBox, [
E('h3', { 'style': 'text-align:center' }, _('同名参数检测')),
E('p', { 'style': 'text-align:center;color:#777;padding:8px' },
_('未发现在多个文件中重复定义的参数。')),
collapseBtn
]);
return;
}
for (var i = 0; i < dups.length; i++) {
var d = dups[i];
var entRows = [];
var finalEntry = null;
for (var j = 0; j < d.entries.length; j++) {
var e = d.entries[j];
if (!e.disabled)
finalEntry = e;
entRows.push(E('div', {
'style': 'display:flex;align-items:center;justify-content:center;gap:8px;margin:3px 0'
}, [
E('code', {}, e.path.replace('/etc/', '')),
E('span', { 'style': 'color:#999' }, '='),
E('code', {}, e.value),
e.disabled
? badge(_('已禁用'), 'off')
: E('span', { 'style': 'color:#18794e;font-size:12px' }, _('生效来源')),
(e.path != customPath)
? E('button', {
'class': 'cbi-button cbi-button-remove',
'title': _('从该文件删除此条目'),
'click': ui.createHandlerFn(self, 'dupDeleteEntry', e, d.key)
}, _('删除'))
: E('span', { 'style': 'color:#34618e;font-size:12px' }, _('自定义'))
]));
}
/* files order == application order; compare against custom conf */
var files = (this.statusData != null && this.statusData.files != null) ? this.statusData.files : [];
var paths = [];
for (var k = 0; k < files.length; k++)
paths.push(files[k].path);
var customIdx = paths.indexOf(customPath);
var shadowed = (finalEntry != null && customIdx >= 0 && paths.indexOf(finalEntry.path) > customIdx);
var warnText = (shadowed && finalEntry != null)
? _('生效来源(%s)加载在自定义配置之后:“添加参数”里设置该参数不会生效,请直接编辑该文件或删除其同名条目。').format(finalEntry.path.replace('/etc/', ''))
: _('自定义配置加载顺序靠后,“添加参数”设置该参数时会覆盖以上取值。');
rows.push(E('tr', {}, [
E('td', {}, [ E('code', {}, d.key) ]),
E('td', {}, E('div', {}, entRows)),
E('td', { 'style': 'max-width:300px' }, E('div', {
'style': 'color:' + (shadowed ? '#b3261e' : '#666')
}, warnText))
]));
}
dom.content(this.dupBox, [
E('h3', { 'style': 'text-align:center' }, _('同名参数检测')),
E('p', { 'style': 'color:#666;margin:4px 0;text-align:center' },
_('以下参数在多个配置文件中重复定义,按应用顺序以最后一个为准:')),
E('div', { 'class': 'lsc-tablewrap' }, E('table', { 'class': 'lsc-table' }, [
E('thead', {}, E('tr', {}, [
E('th', {}, _('参数名')),
E('th', {}, _('各文件定义(按应用顺序)')),
E('th', {}, _('说明'))
])),
E('tbody', rows)
])),
collapseBtn
]);
},
dupDeleteEntry: function(e, key) {
var self = this;
return callFileDelete(e.path, key).then(function(res) {
if (res == null || res.code != 0) {
ui.addNotification(null,
E('p', {}, _('删除失败:%s').format((res != null && res.error) ? res.error : _('未知错误'))),
'error');
return;
}
self.reloadList();
self.showDupCheck();
}).catch(function(err) {
ui.addNotification(null, E('p', {}, _('删除失败:%s').format(err.message)), 'error');
});
},
/* ---------- section 2: online preset ---------- */
renderPresetSection: function() {
var self = this;
this.presetStatusLine = E('div', { 'style': 'margin:6px 0;padding:6px 10px;background:#f5f5f5;border-radius:3px' });
this.presetResultBox = E('div', { 'style': 'display:none' });
this.presetUrlInput = E('input', {
'type': 'text', 'class': 'cbi-input-text', 'style': 'width:100%',
'placeholder': 'https://github.com/user/repo/blob/main/sysctl.conf(也支持 raw 地址)'
});
this.presetPrefixInput = E('input', {
'type': 'text', 'class': 'cbi-input-text', 'style': 'width:260px',
'placeholder': 'https://gh-proxy.org/'
});
var delBtn = E('button', { 'class': 'cbi-button cbi-button-remove' }, _('移除预设'));
delBtn.addEventListener('click', function() {
self.armButton(delBtn, _('确认移除?'), function() { self.presetRemove(); });
});
return E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('在线预设')),
E('div', { 'class': 'cbi-section-descr' }, [
E('p', { 'style': 'margin:4px 0' }, _(
'从在线源(GitHub 等)获取现成的 sysctl 优化配置,预览确认后一键导入。')),
E('p', { 'style': 'margin:4px 0' }, _(
'预设保存于 %s,由本插件整体管理(更新时全量替换)。如需覆盖个别条目,请在上方"自定义参数"中添加,其加载顺序更靠后、优先级更高。').format('/etc/sysctl.d/98-online-preset.conf'))
]),
this.presetStatusLine,
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('配置源 URL')),
E('div', { 'class': 'cbi-value-field' }, this.presetUrlInput)
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('镜像前缀(可选)')),
E('div', { 'class': 'cbi-value-field' }, [
this.presetPrefixInput,
E('div', { 'style': 'color:#777;margin-top:2px' },
_('直连 GitHub 不可用时填写镜像前缀,最终地址为 前缀 + 原始 URL。'))
])
]),
E('div', { 'class': 'cbi-page-actions' }, [
E('button', { 'class': 'cbi-button cbi-button-apply', 'click': ui.createHandlerFn(this, 'presetFetchPreview') }, _('获取并预览')),
' ',
E('button', { 'class': 'cbi-button cbi-button-find', 'click': ui.createHandlerFn(this, 'presetCheckUpdates') }, _('检查更新')),
' ',
delBtn
]),
this.presetResultBox
]);
},
refreshPresetStatus: function() {
var p = this.presetData || {};
var line;
if (p.present) {
line = [ E('strong', {}, _('已导入') + ''), _('%d 条参数').format(p.count || 0) ];
if (p.source) {
line.push(E('span', { 'style': 'color:#777' }, ' · ' + _('来源') + ' '));
line.push(E('span', { 'style': 'word-break:break-all' }, p.source));
}
if (p.fetched) {
line.push(E('span', { 'style': 'color:#777' }, ' · ' + _('更新于') + ' '));
line.push(this.formatEpoch(p.fetched));
}
}
else {
line = [ E('span', { 'style': 'color:#777' }, _('尚未导入在线预设。')) ];
}
dom.content(this.presetStatusLine, line);
},
formatEpoch: function(s) {
var n = Number(s);
if (s == null || s === '' || isNaN(n) || n <= 0)
return '—';
try {
return new Date(n * 1000).toLocaleString();
} catch (e) {
return String(s);
}
},
presetFetchPreview: function() {
var self = this;
var url = (this.presetUrlInput.value || '').trim();
if (url == '') {
this.showPresetResult(this.errorBox(_('请先填写配置源 URL。')));
return;
}
this.showPresetResult(E('p', { 'style': 'color:#777;padding:6px' }, _('正在获取配置源信息,请稍候…')));
return callPresetList(url, (this.presetPrefixInput.value || '').trim()).then(function(lres) {
if (lres != null && lres.is_dir) {
self.renderPresetDirList(lres);
return;
}
return self.fetchAndPreviewPresetFile(url);
}).catch(function(e) {
self.showPresetResult(self.errorBox(_('获取失败:%s').format(e.message), self.backendErrorHint(e.message)));
});
},
showPresetResult: function(content) {
this.presetResultBox.style.display = '';
dom.content(this.presetResultBox, [ content ]);
},
fetchAndPreviewPresetFile: function(url) {
var self = this;
var prefix = (this.presetPrefixInput.value || '').trim();
this.showPresetResult(E('p', { 'style': 'color:#777;padding:6px' }, _('正在下载并解析预设,请稍候…')));
return callPresetFetch(url, prefix).then(function(res) {
if (res == null || res.code != 0) {
self.showPresetResult(self.errorBox(
(res != null && res.error) ? res.error : _('未知错误'),
self.backendErrorHint(res != null ? res.error : '')));
return;
}
self.renderPresetPreview(res);
}).catch(function(e) {
self.showPresetResult(self.errorBox(_('获取失败:%s').format(e.message), self.backendErrorHint(e.message)));
});
},
renderPresetDirList: function(res) {
var self = this;
var rows = [];
for (var i = 0; i < (res.files || []).length; i++) {
(function(f) {
var btn = E('button', {
'class': 'lsc-chip',
'style': 'font-size:13px;padding:5px 14px'
}, f.name + (f.size > 0 ? ' · ' + (f.size / 1024).toFixed(1) + ' KB' : ''));
btn.addEventListener('click', function() { self.fetchAndPreviewPresetFile(f.url); });
rows.push(E('li', { 'style': 'margin:2px 0' }, btn));
})(res.files[i]);
}
this.presetResultBox.style.display = '';
dom.content(this.presetResultBox, [
E('h3', {}, _('预设目录')),
E('p', { 'style': 'margin:4px 0;color:#666' },
_('该地址是一个目录,发现 %d 个预设文件。点击要导入的文件(下载时同样使用镜像前缀):').format(res.files.length)),
E('ul', { 'style': 'margin:8px 0;padding-left:4px;list-style:none' }, rows),
E('div', { 'style': 'margin-top:10px' }, [
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'hidePresetResult') }, _('收起'))
])
]);
},
renderPresetPreview: function(res) {
var self = this;
var rows = [];
var notes = [];
var i;
var cmap = {};
for (i = 0; i < (res.conflicts || []).length; i++)
cmap[res.conflicts[i].key] = res.conflicts[i].file;
for (i = 0; i < (res.params || []).length; i++) {
var it = res.params[i];
var conflictFile = cmap[it.key] || '';
rows.push(E('tr', {}, [
E('td', {}, [ E('code', {}, it.key) ]),
E('td', {}, [ E('code', {}, it.value) ]),
E('td', { 'style': 'color:#a80' }, conflictFile ? _('覆盖本地 %s').format(conflictFile.replace('/etc/', '')) : '')
]));
}
notes.push(E('p', {}, _('共解析出 %d 条参数,来源:%s。').format(res.params.length, res.url)));
if ((res.conflicts || []).length > 0)
notes.push(E('p', { 'class': 'alert-message warning', 'style': 'margin:6px 0' },
_('%d 条参数与本地现有配置同名,导入后将覆盖其取值。').format(res.conflicts.length)));
if ((res.invalid || []).length > 0)
notes.push(E('p', { 'class': 'alert-message warning', 'style': 'margin:6px 0' },
_('%d 行无法解析已忽略(常见为说明文字或格式错误的行)。').format(res.invalid.length)));
var importBtn = E('button', { 'class': 'btn cbi-button-positive important' }, _('导入到路由器'));
importBtn.addEventListener('click', function() { self.presetImport(res.url); });
dom.content(this.presetResultBox, [
E('h3', {}, _('预设预览')),
E('div', {}, notes),
E('div', { 'style': 'max-height:320px;overflow:auto' }, E('div', { 'class': 'lsc-tablewrap' }, E('table', { 'class': 'lsc-table' }, [
E('thead', {}, E('tr', {}, [ E('th', {}, _('参数')), E('th', {}, _('值')), E('th', {}, _('冲突')) ])),
E('tbody', rows)
]))),
E('div', { 'style': 'margin-top:10px' }, [
importBtn, ' ',
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'hidePresetResult') }, _('取消'))
])
]);
},
presetImport: function(url) {
var self = this;
return callPresetImport(url, '').then(function(res) {
if (res == null || res.code != 0) {
dom.content(self.presetResultBox, [ self.errorBox(_('导入失败:%s').format((res != null && res.error) ? res.error : _('未知错误'))) ]);
return;
}
dom.content(self.presetResultBox, [
E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, [
E('p', {}, _('已导入 %d 条预设参数到 %s。点击"应用配置"可立即生效。').format(res.count, res.path))
])
]);
self.reloadList();
}).catch(function(e) {
dom.content(self.presetResultBox, [ self.errorBox(_('导入失败:%s').format(e.message), self.backendErrorHint(e.message)) ]);
});
},
presetCheckUpdates: function() {
var self = this;
dom.content(this.presetResultBox, [ E('p', { 'style': 'color:#777;padding:6px' }, _('正在对比在线版本与本地预设,请稍候…')) ]);
this.presetResultBox.style.display = '';
return callPresetCheck().then(function(res) {
if (res == null || res.code != 0) {
dom.content(self.presetResultBox, [ self.errorBox(
(res != null && res.error) ? res.error : _('未知错误'),
self.backendErrorHint(res != null ? res.error : '')) ]);
return;
}
if ((res.added || []).length == 0 && (res.changed || []).length == 0 && (res.removed || []).length == 0) {
dom.content(self.presetResultBox, [
E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, [
E('p', {}, _('在线预设已是最新,共 %d 条参数。').format(res.remote_count))
])
]);
return;
}
self.renderPresetDiff(res);
}).catch(function(e) {
dom.content(self.presetResultBox, [ self.errorBox(_('检查更新失败:%s').format(e.message), self.backendErrorHint(e.message)) ]);
});
},
renderPresetDiff: function(res) {
var self = this;
var blocks = [];
var i;
var mkTable = function(headers, rows) {
return E('div', { 'class': 'lsc-tablewrap' }, E('table', { 'class': 'lsc-table' }, [
E('thead', {}, E('tr', {}, headers.map(function(h) { return E('th', {}, h); }))),
E('tbody', rows)
]));
};
if ((res.added || []).length > 0) {
var addRows = [];
for (i = 0; i < res.added.length; i++)
addRows.push(E('tr', {}, [
E('td', {}, [ E('code', {}, res.added[i].key) ]),
E('td', {}, [ E('code', {}, res.added[i].value) ])
]));
blocks.push(E('div', {}, [
E('h4', {}, _('新增 %d 条').format(res.added.length)),
mkTable([ _('参数'), _('值') ], addRows)
]));
}
if ((res.changed || []).length > 0) {
var chgRows = [];
for (i = 0; i < res.changed.length; i++)
chgRows.push(E('tr', {}, [
E('td', {}, [ E('code', {}, res.changed[i].key) ]),
E('td', {}, [ E('code', {}, res.changed[i].from) ]),
E('td', {}, [ E('code', {}, res.changed[i].to) ])
]));
blocks.push(E('div', {}, [
E('h4', {}, _('变更 %d 条').format(res.changed.length)),
mkTable([ _('参数'), _('当前值'), _('新值') ], chgRows)
]));
}
if ((res.removed || []).length > 0) {
var rmRows = [];
for (i = 0; i < res.removed.length; i++)
rmRows.push(E('tr', {}, [
E('td', {}, [ E('code', {}, res.removed[i].key) ]),
E('td', {}, [ E('code', {}, res.removed[i].old) ])
]));
blocks.push(E('div', {}, [
E('h4', {}, _('移除 %d 条').format(res.removed.length)),
mkTable([ _('参数'), _('当前值') ], rmRows)
]));
}
var applyBtn = E('button', { 'class': 'btn cbi-button-positive important' }, _('应用更新'));
applyBtn.addEventListener('click', function() { self.presetImport(res.url); });
dom.content(this.presetResultBox, [
E('h3', {}, _('发现在线预设更新')),
E('p', {}, _('在线版本共 %d 条,本地 %d 条。更新会整体替换 %s。').format(res.remote_count, res.local_count, '/etc/sysctl.d/98-online-preset.conf')),
E('div', { 'style': 'max-height:380px;overflow:auto' }, blocks),
E('div', { 'style': 'margin-top:10px' }, [
applyBtn, ' ',
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'hidePresetResult') }, _('取消'))
])
]);
},
hidePresetResult: function() {
this.presetResultBox.style.display = 'none';
dom.content(this.presetResultBox, []);
},
presetRemove: function() {
var self = this;
return callPresetRemove().then(function(res) {
if (res == null || res.code != 0) {
ui.addNotification(null,
E('p', {}, _('移除失败:%s').format((res != null && res.error) ? res.error : 'unknown error')),
'error');
}
self.reloadList();
}).catch(function(e) {
ui.addNotification(null, E('p', {}, _('移除失败:%s').format(e.message)), 'error');
});
},
/* ---------- section 3: browse / search ---------- */
renderBrowseSection: function() {
var self = this;
this.browseTableBody = E('tbody', {});
this.crumbs = E('div', { 'style': 'margin:6px 0' });
this.searchInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'style': 'width:280px;display:inline-block',
'placeholder': _('搜索参数名或值(至少 2 个字符)')
});
this.searchInput.addEventListener('input', function() { self.onSearchInput(); });
var searchBtn = E('button', { 'class': 'cbi-button cbi-button-find', 'click': ui.createHandlerFn(this, 'doSearch') }, _('搜索'));
var table = E('table', { 'class': 'lsc-table' }, [
E('thead', {}, E('tr', {}, [
E('th', {}, _('参数')),
E('th', {}, _('当前值')),
E('th', {}, _('操作'))
])),
this.browseTableBody
]);
var box = E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('浏览内核参数')),
E('div', { 'class': 'cbi-section-descr' },
_('浏览 /proc/sys 下的实时参数。点击目录进入子级,也可直接搜索;点击“自定义”可将参数加入上方自定义列表。')),
E('div', { 'style': 'margin:8px 0' }, [ this.searchInput, ' ', searchBtn ]),
this.crumbs,
E('div', { 'class': 'lsc-tablewrap' }, table)
]);
this.browseSectionBox = box;
return box;
},
onSearchInput: function() {
var self = this;
if (this.searchTimer != null)
window.clearTimeout(this.searchTimer);
this.searchTimer = window.setTimeout(function() { self.doSearch(); }, 500);
},
doSearch: function() {
var self = this;
var q = (this.searchInput.value || '').trim();
if (this.searchTimer != null) {
window.clearTimeout(this.searchTimer);
this.searchTimer = null;
}
if (q.length < 2) {
this.browsePrefix = '';
this.loadBrowse('');
return;
}
this.browsePrefix = null;
this.crumbsUpdate();
dom.content(this.browseTableBody, E('tr', {},
E('td', { 'colspan': 3, 'style': 'text-align:center;color:#777;padding:12px' }, _('搜索中…'))));
callSearch(q, 500).then(function(res) {
var items = (res != null && res.items != null) ? res.items : [];
self.renderSearchResults(items, (res != null && res.truncated == true), q);
}).catch(function(e) {
dom.content(self.browseTableBody, E('tr', {},
E('td', { 'colspan': 3, 'class': 'alert-message error' }, _('搜索失败:%s').format(e.message))));
});
},
renderSearchResults: function(items, truncated, q) {
var rows = [];
items.sort(function(a, b) { return (a.key < b.key) ? -1 : (a.key > b.key) ? 1 : 0; });
for (var i = 0; i < items.length; i++)
rows.push(this.renderParamRow(items[i].key, items[i].value));
if (rows.length == 0)
rows.push(E('tr', {}, E('td', { 'colspan': 3, 'style': 'text-align:center;color:#777;padding:12px' },
_('没有匹配“%s”的参数。').format(q))));
if (truncated)
rows.push(E('tr', {}, E('td', { 'colspan': 3, 'style': 'color:#e80' },
_('结果过多已截断,请输入更精确的关键词。'))));
dom.content(this.browseTableBody, rows);
},
renderParamRow: function(key, value) {
var self = this;
return E('tr', { 'class': 'cbi-section-table-row' }, [
E('td', {}, [ E('code', {}, key) ]),
E('td', {}, [ E('code', {}, value) ]),
E('td', {}, E('button', {
'class': 'cbi-button cbi-button-add',
'click': ui.createHandlerFn(self, 'showEditForm', { isNew: true, key: key, value: value, fromBrowse: true })
}, _('自定义')))
]);
},
loadBrowse: function(prefix) {
var self = this;
dom.content(this.browseTableBody, E('tr', {},
E('td', { 'colspan': 3, 'style': 'text-align:center;color:#777;padding:12px' }, _('加载中…'))));
this.crumbsUpdate();
callBrowse(prefix).then(function(res) {
var items = (res != null && res.items != null) ? res.items : [];
var rows = [];
for (var i = 0; i < items.length; i++) {
var it = items[i];
if (it.type == 'group') {
rows.push(E('tr', { 'class': 'cbi-section-table-row' }, [
E('td', {}, E('a', {
'href': '#',
'click': ui.createHandlerFn(self, 'browseInto', it.prefix)
}, [ E('strong', {}, it.name + '/') ])),
E('td', {}, E('span', { 'style': 'color:#999' }, _('目录'))),
E('td', {}, E('a', {
'href': '#',
'style': 'text-decoration:none',
'click': ui.createHandlerFn(self, 'browseInto', it.prefix)
}, _('进入')))
]));
}
else {
rows.push(self.renderParamRow(it.key, it.value));
}
}
if (rows.length == 0)
rows.push(E('tr', {}, E('td', { 'colspan': 3, 'style': 'text-align:center;color:#777;padding:12px' },
_('(空)'))));
dom.content(self.browseTableBody, rows);
}).catch(function(e) {
dom.content(self.browseTableBody, E('tr', {},
E('td', { 'colspan': 3, 'class': 'alert-message error' }, _('加载失败:%s').format(e.message))));
});
},
browseInto: function(prefix) {
this.browsePrefix = prefix;
this.loadBrowse(prefix);
},
crumbsUpdate: function() {
var self = this;
var parts = [ E('a', {
'href': '#',
'click': ui.createHandlerFn(this, 'browseInto', '')
}, _('根目录')) ];
if (this.browsePrefix == null) {
parts.push(E('span', { 'style': 'color:#777' }, ' ' + _('搜索') + ': ' + this.searchInput.value));
}
else if (this.browsePrefix != '') {
var segs = this.browsePrefix.replace(/\.$/, '').split('.');
var acc = '';
for (var i = 0; i < segs.length; i++) {
acc += segs[i] + '.';
parts.push(E('span', { 'style': 'color:#777' }, ' '));
parts.push(E('a', {
'href': '#',
'click': ui.createHandlerFn(this, 'browseInto', acc)
}, segs[i]));
}
/* one-click step back to the parent directory */
var parentPrefix = (segs.length > 1)
? segs.slice(0, segs.length - 1).join('.') + '.'
: '';
parts.push(E('span', { 'style': 'color:#777' }, ' '));
parts.push(E('a', {
'href': '#',
'style': 'margin-left:12px',
'click': ui.createHandlerFn(this, 'browseInto', parentPrefix)
}, _('← 返回上一级')));
}
dom.content(this.crumbs, parts);
}
});