diff --git a/applications/luci-app-sysctl/Makefile b/applications/luci-app-sysctl/Makefile index 4065c46aed..d6dd866547 100644 --- a/applications/luci-app-sysctl/Makefile +++ b/applications/luci-app-sysctl/Makefile @@ -13,7 +13,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-sysctl -PKG_VERSION:=1.5.9 +PKG_VERSION:=1.7.6 PKG_RELEASE:=1 PKG_LICENSE:=Apache-2.0 @@ -25,9 +25,12 @@ LUCI_DESCRIPTION:=View live kernel parameters and manage custom sysctl entries \ Supports browsing /proc/sys, searching parameters, one-click apply and \ immediate runtime activation of single parameters. LUCI_DEPENDS:=+luci-base +rpcd +rpcd-mod-ucode -LUCI_CONFFILES:=/etc/sysctl.d/99-luci-sysctl.conf LUCI_PKGARCH:=all +define Package/luci-app-sysctl/conffiles +/etc/sysctl.d/99-luci-sysctl.conf +endef + include $(TOPDIR)/feeds/luci/luci.mk # call BuildPackage - OpenWrt buildroot signature diff --git a/applications/luci-app-sysctl/htdocs/luci-static/resources/view/sysctl.js b/applications/luci-app-sysctl/htdocs/luci-static/resources/view/sysctl.js index 4ca9a7d3a1..d98a66a6b9 100644 --- a/applications/luci-app-sysctl/htdocs/luci-static/resources/view/sysctl.js +++ b/applications/luci-app-sysctl/htdocs/luci-static/resources/view/sysctl.js @@ -40,6 +40,7 @@ var callPresetList = rpc.declare({ 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_.\/-]*$/; @@ -75,7 +76,8 @@ var LSC_CSS = [ '.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; }' + '.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({ @@ -179,6 +181,7 @@ return view.extend({ }, _('暂无自定义参数,点击下方“添加参数”开始。')); 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' }); @@ -205,11 +208,12 @@ return view.extend({ 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.applyResultBox, + this.dupBox, E('div', { 'class': 'cbi-page-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-add', @@ -221,6 +225,11 @@ return view.extend({ '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') @@ -294,7 +303,11 @@ return view.extend({ }, showEditForm: function(entry) { - this.editing = (entry != null) ? entry : null; + /* "添加参数" 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(); @@ -350,6 +363,20 @@ return view.extend({ 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), @@ -369,6 +396,7 @@ return view.extend({ ]) ]), E('div', { 'style': 'color:#666;margin-top:4px' }, note), + editDupBox, errBox, E('div', { 'style': 'margin-top:8px' }, [ saveBtn, ' ', @@ -376,10 +404,19 @@ return view.extend({ ]) ]) ]); + + 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 @@ -392,6 +429,83 @@ return view.extend({ } }, + /* 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; @@ -458,14 +572,36 @@ return view.extend({ 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(); - dom.content(self.applyResultBox, [ E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, notes) ]); - self.applyResultBox.style.display = ''; + 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))); @@ -486,6 +622,12 @@ return view.extend({ 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'); }); @@ -556,11 +698,44 @@ return view.extend({ }); }, + /* 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; - dom.content(this.applyResultBox, [ E('p', { 'style': 'color:#777;padding:6px' }, _('正在应用全部配置,请稍候…')) ]); - this.applyResultBox.style.display = ''; + 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 : []; @@ -571,28 +746,55 @@ return view.extend({ } else if (errors.length == 0) { content = E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, [ - E('p', {}, _('全部配置已成功应用。')) + 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++) - items.push(E('li', {}, [ - E('strong', {}, errors[i].file), - E('pre', { 'style': 'white-space:pre-wrap;margin:4px 0' }, errors[i].output || '') - ])); + 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('p', {}, _('应用完成,以下内容需要留意(常见原因:参数不存在、只读、被内核拒绝或被其他文件覆盖):')), E('ul', {}, items) ]); } - dom.content(self.applyResultBox, [ content ]); + 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) { - dom.content(self.applyResultBox, [ self.errorBox(_('应用失败:%s').format(e.message), self.backendErrorHint(e.message)) ]); + self.showApplyResult([ self.errorBox(_('应用失败:%s').format(e.message), self.backendErrorHint(e.message)) ], 0); }); }, @@ -676,7 +878,11 @@ return view.extend({ rows.push(E('tr', {}, [ E('td', {}, [ E('code', {}, (e.disabled ? '# ' : '') + e.key) ]), E('td', {}, [ E('code', {}, e.value) ]), - E('td', {}, [ E('code', {}, (e.disabled || e.current == null) ? '—' : e.current) ]), + 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) ])); } @@ -814,6 +1020,139 @@ return view.extend({ 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() { diff --git a/applications/luci-app-sysctl/root/usr/share/rpcd/acl.d/luci-app-sysctl.json b/applications/luci-app-sysctl/root/usr/share/rpcd/acl.d/luci-app-sysctl.json index 307873a883..d565343cd8 100644 --- a/applications/luci-app-sysctl/root/usr/share/rpcd/acl.d/luci-app-sysctl.json +++ b/applications/luci-app-sysctl/root/usr/share/rpcd/acl.d/luci-app-sysctl.json @@ -11,8 +11,9 @@ "preset_status", "preset_fetch", "preset_check", - "file_view", - "preset_list" + "file_view", + "preset_list", + "dup_check" ] } }, diff --git a/applications/luci-app-sysctl/root/usr/share/rpcd/ucode/luci.sysctl b/applications/luci-app-sysctl/root/usr/share/rpcd/ucode/luci.sysctl index ce0a57d2af..fb453b5de5 100644 --- a/applications/luci-app-sysctl/root/usr/share/rpcd/ucode/luci.sysctl +++ b/applications/luci-app-sysctl/root/usr/share/rpcd/ucode/luci.sysctl @@ -549,6 +549,32 @@ function file_delete_key(path, key) { return { code: 0, removed: removed }; } +// Strip the normal "key = value" echo lines that sysctl -p prints to stdout +// for every successfully applied entry. What remains (if anything) is a +// genuine error message like "sysctl: permission denied on key '...'". +// NOTE: ucode regex has no non-capturing groups; plain pattern is fine here. +function strip_echo(out) { + if (out == null || length(out) == 0) + return ''; + + let lines = split(out, '\n'); + let rest = []; + + for (let i = 0; i < length(lines); i++) { + let t = trim(lines[i]); + + if (t == null || length(t) == 0) + continue; + + if (match(t, /^[A-Za-z0-9_][A-Za-z0-9_.\/-]*[ \t]*=[ \t]*\S/) != null) + continue; + + push(rest, t); + } + + return join('\n', rest); +} + const methods = { status: { args: {}, @@ -763,10 +789,39 @@ const methods = { let out = (p != null) ? p.read('all') : null; let rc = (p != null) ? p.close() : -1; - out = (out == null) ? '' : trim(out); + out = strip_echo((out == null) ? '' : trim(out)); + + /* busybox `sysctl -e -p` echoes every line and silently + * swallows write failures -> echo alone proves nothing. + * Read back each enabled entry from /proc/sys instead: + * anything that differs was NOT accepted by the kernel. */ + let not_applied = []; + let entries = parse_conf(confs[i]); + + for (let j = 0; j < length(entries); j++) { + if (entries[j].disabled) + continue; - if (rc != 0 || length(out) > 0) - push(errors, { file: confs[i], code: rc, output: out }); + let cur = read_current(entries[j].key); + + if (cur == null) + continue; // key unknown to this kernel, -e skips it by design + + if (cur != entries[j].value) + push(not_applied, { + key: entries[j].key, + value: entries[j].value, + current: cur + }); + } + + if (rc != 0 || length(out) > 0 || length(not_applied) > 0) + push(errors, { + file: confs[i], + code: rc, + output: out, + not_applied: not_applied + }); } return { code: 0, errors: errors }; @@ -1012,6 +1067,57 @@ const methods = { return file_delete_key(p, key); } + }, + + // Locate a sysctl key across ALL config sources, in application order. + // Without args: return only keys defined in >= 2 files (duplicate map). + // With args.key: return that key's definitions even if defined once + // (used by the add/edit form to warn about shadowing overrides). + // Entries are ordered by file load order -> the LAST enabled entry wins. + dup_check: { + args: { key: '' }, + call: function(request) { + let a = (request.args != null) ? request.args : {}; + let want = trim(a.key); + let confs = collect_confs(); + let by_key = {}; + let order = []; + + for (let i = 0; i < length(confs); i++) { + let path = confs[i]; + let entries = parse_conf(path); + + for (let j = 0; j < length(entries); j++) { + let k = entries[j].key; + + if (want != null && length(want) > 0 && k != want) + continue; + + if (by_key[k] == null) { + by_key[k] = []; + push(order, k); + } + + push(by_key[k], { + path: path, + value: entries[j].value, + disabled: entries[j].disabled + }); + } + } + + let min_defs = (want != null && length(want) > 0) ? 1 : 2; + let dups = []; + + for (let i = 0; i < length(order); i++) { + let k = order[i]; + + if (length(by_key[k]) >= min_defs) + push(dups, { key: k, entries: by_key[k] }); + } + + return { code: 0, dups: dups }; + } } };