mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 10:15:13 +08:00
🍓 Sync 2026-09-15 02:13:06
This commit is contained in:
1 parent
0e5393594c
commit
1dd96ac2bb
146 files changed
+26541
-508
No files matched your search
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require rpc';
|
||||
'require poll';
|
||||
|
||||
var callGetLog = rpc.declare({
|
||||
object: 'luci.linkback',
|
||||
method: 'get_log'
|
||||
});
|
||||
|
||||
return view.extend({
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null,
|
||||
|
||||
render: function() {
|
||||
var area = E('textarea', {
|
||||
id: 'linkback_log_content',
|
||||
style: 'width:100%; height:500px; background:#f4f4f4; color:#333; padding:10px; border:1px solid #ccc; border-radius:3px; font-family:monospace; font-size:12px; overflow-y:auto; white-space:pre-wrap; word-break:break-all; margin-top:10px;',
|
||||
readonly: 'readonly'
|
||||
}, _('Loading logs...'));
|
||||
|
||||
poll.add(L.bind(this.updateLogs, this), 5);
|
||||
|
||||
return E('div', { 'class': 'cbi-map' }, [
|
||||
E('h2', {}, _('LinkBack 链路守护') + ' - ' + _('Log')),
|
||||
E('div', { 'class': 'cbi-map-descr' }, _('Real-time routing adjustments and health check state transitions from the linkbackd daemon.')),
|
||||
E('fieldset', { 'class': 'cbi-section' }, [
|
||||
E('legend', {}, _('System Logs')),
|
||||
E('div', { 'class': 'cbi-section-node' }, area)
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
updateLogs: function() {
|
||||
return callGetLog().then(function(res) {
|
||||
var area = document.getElementById('linkback_log_content');
|
||||
if (area) {
|
||||
area.value = res && res.log ? res.log : _('No logs available.');
|
||||
area.scrollTop = area.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require rpc';
|
||||
'require poll';
|
||||
'require ui';
|
||||
'require dom';
|
||||
|
||||
var callGetStatus = rpc.declare({
|
||||
object: 'luci.linkback',
|
||||
method: 'get_status'
|
||||
});
|
||||
|
||||
var callManageService = rpc.declare({
|
||||
object: 'luci.linkback',
|
||||
method: 'manage_service',
|
||||
params: [ 'action' ]
|
||||
});
|
||||
|
||||
return view.extend({
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null,
|
||||
|
||||
injectStyle: function() {
|
||||
if (document.getElementById('linkback_status_style'))
|
||||
return;
|
||||
|
||||
var css = [
|
||||
'.linkback-dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px;vertical-align:middle}',
|
||||
'.linkback-dot-green{background-color:#4CAF50;box-shadow:0 0 6px #4CAF50}',
|
||||
'.linkback-dot-red{background-color:#f44336;box-shadow:0 0 6px #f44336}',
|
||||
'.linkback-dot-grey{background-color:#9e9e9e}',
|
||||
'.linkback-indicator{display:inline-flex;align-items:center;padding:4px 10px;border-radius:12px;font-size:11px;font-weight:bold;background-color:rgba(0,0,0,.05)}',
|
||||
'.linkback-badge{display:inline-block;padding:4px 8px;border-radius:4px;font-size:11px;font-weight:bold;color:#fff;text-align:center}',
|
||||
'.linkback-badge-active{background-color:#4CAF50}',
|
||||
'.linkback-badge-standby{background-color:#2196F3}',
|
||||
'.linkback-badge-faulted{background-color:#f44336}'
|
||||
].join('');
|
||||
|
||||
document.head.appendChild(E('style', { id: 'linkback_status_style' }, css));
|
||||
},
|
||||
|
||||
load: function() {
|
||||
return callGetStatus();
|
||||
},
|
||||
|
||||
render: function(status) {
|
||||
this.injectStyle();
|
||||
|
||||
var container = E('div', { 'class': 'cbi-map' }, [
|
||||
E('h2', {}, _('LinkBack 链路守护') + ' - ' + _('Overview')),
|
||||
E('div', { 'class': 'cbi-map-descr' }, _('Real-time health state, priority mapping, and service control for Multi-WAN default routing metrics.'))
|
||||
]);
|
||||
|
||||
// --- Service Status Section (Aligned with lucky style) ---
|
||||
var stateText = _('Collecting...');
|
||||
var stateColor = '#999';
|
||||
|
||||
if (status && status.links && status.links.length > 0) {
|
||||
stateText = _('Running');
|
||||
stateColor = 'green';
|
||||
} else {
|
||||
stateText = _('Stopped');
|
||||
stateColor = 'red';
|
||||
}
|
||||
|
||||
var isRunning = (status && status.links && status.links.length > 0);
|
||||
|
||||
var statusSection = E('fieldset', { 'class': 'cbi-section' }, [
|
||||
E('legend', {}, _('Service Status')),
|
||||
E('div', { 'class': 'cbi-section-node' }, [
|
||||
E('table', { 'class': 'table cbi-section-table' }, [
|
||||
E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td left', 'width': '33%' }, _('Running State')),
|
||||
E('td', { 'class': 'td left' }, [
|
||||
E('span', {
|
||||
id: 'linkback-service-state',
|
||||
style: 'font-weight:bold; color:' + stateColor + '; margin-right:15px;'
|
||||
}, stateText),
|
||||
E('button', {
|
||||
id: 'linkback-restart-btn',
|
||||
'class': 'btn cbi-button cbi-button-apply',
|
||||
style: 'display:' + (isRunning ? 'inline-block' : 'none') + ';',
|
||||
click: ui.createHandlerFn(this, 'handleServiceAction', 'restart')
|
||||
}, _('Restart'))
|
||||
])
|
||||
])
|
||||
])
|
||||
])
|
||||
]);
|
||||
container.appendChild(statusSection);
|
||||
|
||||
// --- Link Status Section (Aligned with lucky style) ---
|
||||
var linkSection = E('fieldset', { 'class': 'cbi-section' }, [
|
||||
E('legend', {}, _('Link Status')),
|
||||
E('div', { 'class': 'cbi-section-node' }, [
|
||||
E('div', { id: 'linkback-status-box' }, [
|
||||
E('p', { 'class': 'spinning' }, _('Loading real-time link status...'))
|
||||
])
|
||||
])
|
||||
]);
|
||||
container.appendChild(linkSection);
|
||||
|
||||
// Initial render
|
||||
this.updateStatusView(status);
|
||||
|
||||
// Start polling every 3 seconds
|
||||
poll.add(L.bind(function() {
|
||||
return callGetStatus().then(L.bind(function(res) {
|
||||
this.updateStatusView(res);
|
||||
}, this));
|
||||
}, this), 3);
|
||||
|
||||
return container;
|
||||
},
|
||||
|
||||
updateStatusView: function(status) {
|
||||
var box = document.getElementById('linkback-status-box');
|
||||
if (!box)
|
||||
return;
|
||||
|
||||
// Update service state indicator
|
||||
var stateEl = document.getElementById('linkback-service-state');
|
||||
if (stateEl) {
|
||||
if (status && status.links && status.links.length > 0) {
|
||||
stateEl.textContent = _('Running');
|
||||
stateEl.style.color = 'green';
|
||||
} else {
|
||||
stateEl.textContent = _('Stopped');
|
||||
stateEl.style.color = 'red';
|
||||
}
|
||||
}
|
||||
|
||||
var restartBtn = document.getElementById('linkback-restart-btn');
|
||||
if (restartBtn) {
|
||||
if (status && status.links && status.links.length > 0) {
|
||||
restartBtn.style.display = 'inline-block';
|
||||
} else {
|
||||
restartBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (!status || !status.links || status.links.length === 0) {
|
||||
dom.content(box, [
|
||||
E('p', { style: 'color:#f44336; font-weight:bold; padding:20px;' },
|
||||
_('LinkBack daemon is not running or no links are configured. Please check Settings page.'))
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
var activeLink = status.active_link || 'none';
|
||||
|
||||
// Create standard LuCI section table
|
||||
var table = E('table', { 'class': 'table cbi-section-table' }, [
|
||||
E('tr', { 'class': 'tr table-titles' }, [
|
||||
E('th', { 'class': 'th', 'style': 'text-align:center; width:5%;' }, '#'),
|
||||
E('th', { 'class': 'th', 'style': 'text-align:left;' }, _('Link Name')),
|
||||
E('th', { 'class': 'th', 'style': 'text-align:left;' }, _('Status')),
|
||||
E('th', { 'class': 'th', 'style': 'text-align:left;' }, _('Routing Attributes')),
|
||||
E('th', { 'class': 'th', 'style': 'text-align:left;' }, _('Latency Details'))
|
||||
])
|
||||
]);
|
||||
|
||||
status.links.forEach(function(link, index) {
|
||||
var roleTitle = '';
|
||||
var roleClass = '';
|
||||
|
||||
if (link.is_up && link.healthy) {
|
||||
if (link.name === activeLink) {
|
||||
roleTitle = _('Active');
|
||||
roleClass = 'linkback-badge-active';
|
||||
} else {
|
||||
roleTitle = _('Standby');
|
||||
roleClass = 'linkback-badge-standby';
|
||||
}
|
||||
} else {
|
||||
roleTitle = _('Faulted');
|
||||
roleClass = 'linkback-badge-faulted';
|
||||
}
|
||||
|
||||
// Health check indicators
|
||||
var checkBadges = [];
|
||||
|
||||
if (link.check_type === 'ping' && link.ping) {
|
||||
var dotClass = link.ping.ok ? 'linkback-dot-green' : 'linkback-dot-red';
|
||||
checkBadges.push(E('span', { 'class': 'linkback-indicator', 'style': 'margin-bottom:4px; margin-right:6px;' }, [
|
||||
E('span', { 'class': 'linkback-dot ' + dotClass }),
|
||||
'Ping: ' + (link.ping.ok ? link.ping.rtt + 'ms' : _('Failed'))
|
||||
]));
|
||||
}
|
||||
else if (link.check_type === 'dns' && link.dns) {
|
||||
var dotClass = link.dns.ok ? 'linkback-dot-green' : 'linkback-dot-red';
|
||||
checkBadges.push(E('span', { 'class': 'linkback-indicator', 'style': 'margin-bottom:4px; margin-right:6px;' }, [
|
||||
E('span', { 'class': 'linkback-dot ' + dotClass }),
|
||||
'DNS: ' + (link.dns.ok ? link.dns.rtt + 'ms' : _('Failed'))
|
||||
]));
|
||||
}
|
||||
else if (link.check_type === 'tcp' && link.tcp) {
|
||||
var dotClass = link.tcp.ok ? 'linkback-dot-green' : 'linkback-dot-red';
|
||||
checkBadges.push(E('span', { 'class': 'linkback-indicator', 'style': 'margin-bottom:4px; margin-right:6px;' }, [
|
||||
E('span', { 'class': 'linkback-dot ' + dotClass }),
|
||||
'TCP: ' + (link.tcp.ok ? link.tcp.rtt + 'ms' : _('Failed'))
|
||||
]));
|
||||
}
|
||||
|
||||
if (checkBadges.length === 0) {
|
||||
checkBadges.push(E('span', { 'style': 'color:#999;' }, '-'));
|
||||
}
|
||||
|
||||
// Route and metric attributes
|
||||
var routeDetails = E('div', { 'style': 'font-size:12px; line-height:1.6;' }, [
|
||||
E('div', {}, [
|
||||
E('strong', {}, _('Gateway') + ': '),
|
||||
link.gateway || '-'
|
||||
]),
|
||||
E('div', {}, [
|
||||
E('strong', {}, _('Priority') + ': '),
|
||||
String(link.priority),
|
||||
' | ',
|
||||
E('strong', {}, _('Metric') + ': '),
|
||||
link.metric + ' → ' + link.current_metric
|
||||
])
|
||||
]);
|
||||
|
||||
var row = E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td', 'style': 'text-align:center; font-weight:bold; vertical-align:middle;' }, String(index + 1)),
|
||||
E('td', { 'class': 'td', 'style': 'font-weight:bold; vertical-align:middle;' }, [
|
||||
E('span', { 'style': 'font-size:14px;' }, link.name),
|
||||
link.device ? E('span', { 'style': 'font-size:11px; color:#666; margin-left:6px; background:#eee; padding:2px 4px; border-radius:3px;' }, link.device) : ''
|
||||
]),
|
||||
E('td', { 'class': 'td', 'style': 'vertical-align:middle;' }, [
|
||||
E('span', { 'class': 'linkback-badge ' + roleClass }, roleTitle)
|
||||
]),
|
||||
E('td', { 'class': 'td', 'style': 'vertical-align:middle;' }, routeDetails),
|
||||
E('td', { 'class': 'td', 'style': 'vertical-align:middle;' }, E('div', { 'style': 'display:flex; flex-wrap:wrap; align-items:center;' }, checkBadges))
|
||||
]);
|
||||
|
||||
table.appendChild(row);
|
||||
});
|
||||
|
||||
dom.content(box, [ table ]);
|
||||
},
|
||||
|
||||
handleServiceAction: function(action) {
|
||||
ui.showModal(null, [
|
||||
E('p', { 'class': 'spinning' }, _('Executing service action: %s ...').format(action))
|
||||
]);
|
||||
|
||||
return callManageService(action).then(function(res) {
|
||||
ui.hideModal();
|
||||
if (res && res.success) {
|
||||
ui.addNotification(null, E('p', _('Service action completed successfully.')), 'info');
|
||||
} else {
|
||||
ui.addNotification(null, E('p', _('Failed to execute service action.')), 'error');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', _('RPC error: %s').format(err.message)), 'error');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require uci';
|
||||
'require form';
|
||||
'require ui';
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
uci.load('linkback'),
|
||||
uci.load('network')
|
||||
]);
|
||||
},
|
||||
|
||||
// Comprehensive validation: check all conditions required for the service
|
||||
// to be safely enabled. Returns an error message string, or null if OK.
|
||||
_validateServiceConfig: function() {
|
||||
var link_sections = uci.sections('linkback', 'link') || [];
|
||||
|
||||
// Rule 1: At least 2 interfaces
|
||||
if (link_sections.length < 2) {
|
||||
return _('Cannot enable service: At least 2 monitored WAN interfaces must be configured for failover switcher.');
|
||||
}
|
||||
|
||||
// Rule 2: ALL interfaces must have a health check configured
|
||||
for (var i = 0; i < link_sections.length; i++) {
|
||||
var s_id = link_sections[i]['.name'];
|
||||
var iface_name = uci.get('linkback', s_id, 'name') || s_id;
|
||||
var has_check = uci.get('linkback', s_id, 'ping_targets') ||
|
||||
uci.get('linkback', s_id, 'dns_server') ||
|
||||
uci.get('linkback', s_id, 'tcp_target');
|
||||
if (!has_check) {
|
||||
return _('Cannot enable service: Interface "%s" has no health check configured.').format(iface_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 3: No duplicate priorities
|
||||
var priorities = {};
|
||||
for (var i = 0; i < link_sections.length; i++) {
|
||||
var s_id = link_sections[i]['.name'];
|
||||
var iface_name = uci.get('linkback', s_id, 'name') || s_id;
|
||||
var prio = uci.get('linkback', s_id, 'priority') || '1';
|
||||
if (priorities[prio]) {
|
||||
return _('Cannot enable service: Interfaces "%s" and "%s" have the same priority %s.').format(priorities[prio], iface_name, prio);
|
||||
}
|
||||
priorities[prio] = iface_name;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var m, s, o;
|
||||
var self = this;
|
||||
|
||||
// 智能判断当前是否为中文环境,并提供实时校验的翻译 fallback
|
||||
var is_zh = (_('Base Metric') === '默认跃点' || _('Base Metric') === '默认跃点 (Metric)');
|
||||
var t_priority_empty = is_zh ? '优先级不能为空。' : _('Priority must not be empty.');
|
||||
var t_priority_conflict = is_zh ? '优先级 %s 与接口 "%s" 冲突。优先级必须是唯一的。' : _('Priority %s conflicts with interface "%s". Priorities must be unique.');
|
||||
|
||||
|
||||
// Helper function to expand table column controls and eliminate right empty space
|
||||
var makeTableColumnExpand = function(opt, width) {
|
||||
var origRender = opt.render;
|
||||
opt.render = function(option_index, section_id, in_table) {
|
||||
return Promise.resolve(origRender.call(this, option_index, section_id, in_table)).then(function(node) {
|
||||
if (in_table && node) {
|
||||
if (width) {
|
||||
node.style.width = width;
|
||||
}
|
||||
var input = node.querySelector('input, select, .cbi-dropdown');
|
||||
if (input) {
|
||||
input.style.width = '100%';
|
||||
input.style.maxWidth = 'none';
|
||||
}
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
m = new form.Map('linkback',
|
||||
_('LinkBack 链路守护') + ' - ' + _('Settings'),
|
||||
_('Configure Multi-WAN failover service, health check parameters, and monitored link interfaces.'));
|
||||
|
||||
// --- Global Settings Section ---
|
||||
s = m.section(form.TypedSection, 'global', _('Global Settings'));
|
||||
s.anonymous = true;
|
||||
|
||||
// Enable switch (总开关) - must be the first option
|
||||
o = s.option(form.Flag, 'enabled', _('Enable Service'),
|
||||
_('Master switch to enable or disable the LinkBack failover daemon.'));
|
||||
o.rmempty = false;
|
||||
// Intercept at UCI write level to validate before enabling.
|
||||
o.write = function(section_id, value) {
|
||||
if (value === '1') {
|
||||
var err = self._validateServiceConfig();
|
||||
if (err) {
|
||||
ui.addNotification(null, E('p', err), 'error');
|
||||
uci.set('linkback', section_id, 'enabled', '0');
|
||||
return;
|
||||
}
|
||||
}
|
||||
uci.set('linkback', section_id, 'enabled', value);
|
||||
};
|
||||
|
||||
// --- Monitored Links Section ---
|
||||
s = m.section(form.GridSection, 'link', _('Monitored WAN Interfaces'),
|
||||
_('Add and prioritize your WAN interfaces. Lower priority number means higher preference (e.g., 1 = primary, 2 = backup).'));
|
||||
s.anonymous = true;
|
||||
s.addremove = true;
|
||||
|
||||
// Custom dynamic Modal title for gorgeous UX
|
||||
s.modaltitle = function(section_id) {
|
||||
var parent_title = _('LinkBack 链路守护') + ' - ' + _('Settings');
|
||||
var is_new = (this.map.addedSection === section_id) || !uci.get('linkback', section_id, 'name');
|
||||
if (is_new) {
|
||||
return parent_title + ' - ' + _('Add Monitored Interface');
|
||||
} else {
|
||||
var name = uci.get('linkback', section_id, 'name') || section_id;
|
||||
return parent_title + ' - ' + _('Edit Monitored Interface') + ' (' + name + ')';
|
||||
}
|
||||
};
|
||||
|
||||
// When service is already enabled and user modifies/deletes links,
|
||||
// re-validate on the GridSection level. If the resulting config is invalid,
|
||||
// show a warning and auto-disable the service to maintain safety.
|
||||
var origRemove = s.handleRemove;
|
||||
s.handleRemove = function(section_id, ev) {
|
||||
return origRemove.apply(this, arguments).then(function() {
|
||||
var enabled = uci.get('linkback', '@global[0]', 'enabled');
|
||||
if (enabled === '1') {
|
||||
var err = self._validateServiceConfig();
|
||||
if (err) {
|
||||
ui.addNotification(null, E('p',
|
||||
_('Service has been auto-disabled because the configuration is no longer valid: ') + err
|
||||
), 'warning');
|
||||
uci.set('linkback', '@global[0]', 'enabled', '0');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 1. Enabled (Enabled as the first column)
|
||||
o = s.option(form.Flag, 'enabled', _('Enabled'));
|
||||
o.default = '1';
|
||||
o.rmempty = false;
|
||||
makeTableColumnExpand(o, '8%');
|
||||
|
||||
// 2. Interface name - dynamic dropdown (No description for main column)
|
||||
o = s.option(form.ListValue, 'name', _('Interface'));
|
||||
o.rmempty = false;
|
||||
|
||||
// Once-off load of all network interfaces safely avoiding race conditions
|
||||
var network_interfaces = {};
|
||||
uci.sections('network', 'interface').forEach(function(sec) {
|
||||
var n = sec['.name'];
|
||||
if (n !== 'loopback' && n !== 'lan') {
|
||||
network_interfaces[n] = true;
|
||||
o.value(n);
|
||||
}
|
||||
});
|
||||
|
||||
// Safeguard to show already configured interfaces even if they were deleted from network config
|
||||
uci.sections('linkback', 'link').forEach(function(sec) {
|
||||
if (sec.name && !network_interfaces[sec.name]) {
|
||||
o.value(sec.name, _('%s (configured)').format(sec.name));
|
||||
}
|
||||
});
|
||||
|
||||
// 动态隐藏已被配置的接口:通过在 renderWidget 中进行局部变量过滤与实例化 ui.Select,
|
||||
// 完全不修改全局的 this.keylist 和 this.keyvalues,保证 100% 线程安全和无副作用
|
||||
o.renderWidget = function(section_id, option_index, cfgvalue) {
|
||||
// 找出已经被其他 Section 选中的网卡(已配置接口名)
|
||||
var used_names = {};
|
||||
uci.sections('linkback', 'link').forEach(function(sec) {
|
||||
if (sec['.name'] !== section_id && sec.name) {
|
||||
used_names[sec.name] = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 动态生成仅针对当前渲染 section_id 的 choices 和 keylist
|
||||
var filtered_choices = {};
|
||||
var filtered_keylist = [];
|
||||
if (Array.isArray(this.keylist)) {
|
||||
for (var i = 0; i < this.keylist.length; i++) {
|
||||
var key = this.keylist[i];
|
||||
if (!used_names[key]) {
|
||||
filtered_keylist.push(key);
|
||||
filtered_choices[key] = this.vallist[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判断当前是编辑已有配置还是添加新配置
|
||||
// 如果在 UCI 中已经保存了 name,则是编辑状态,此时网卡不可编辑
|
||||
var is_edit = !!uci.get('linkback', section_id, 'name');
|
||||
|
||||
// 直接在局部变量中实例化 ui.Select,避免任何时序与引用问题
|
||||
var widget = new ui.Select((cfgvalue != null) ? cfgvalue : this.default, filtered_choices, {
|
||||
id: this.cbid(section_id),
|
||||
size: this.size,
|
||||
sort: filtered_keylist,
|
||||
widget: this.widget,
|
||||
optional: this.optional,
|
||||
orientation: this.orientation,
|
||||
placeholder: this.placeholder,
|
||||
validate: (typeof(this.getValidator) === 'function') ? this.getValidator(section_id) : (this.validate ? this.validate.bind(this, section_id) : null),
|
||||
disabled: is_edit ? true : ((this.readonly != null) ? this.readonly : this.map.readonly)
|
||||
});
|
||||
|
||||
return widget.render();
|
||||
};
|
||||
|
||||
o.validate = function(section_id, value) {
|
||||
var added = false;
|
||||
uci.sections('linkback', 'link').forEach(function(sec) {
|
||||
if (sec['.name'] !== section_id && sec.name === value) {
|
||||
added = true;
|
||||
}
|
||||
});
|
||||
if (added) {
|
||||
return _('This interface has already been configured.');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
makeTableColumnExpand(o, '25%');
|
||||
|
||||
// 3. Priority (No description for main column)
|
||||
o = s.option(form.Value, 'priority', _('Priority'));
|
||||
o.datatype = 'uinteger';
|
||||
o.default = '1';
|
||||
o.rmempty = false;
|
||||
o.validate = function(section_id, value) {
|
||||
if (value == null || value === '') {
|
||||
return t_priority_empty;
|
||||
}
|
||||
var self_opt = this;
|
||||
var has_conflict = false;
|
||||
var conflicting_iface = '';
|
||||
uci.sections('linkback', 'link').forEach(function(sec) {
|
||||
var sid = sec['.name'];
|
||||
if (sid !== section_id) {
|
||||
var other_val = self_opt.formvalue(sid);
|
||||
if (other_val == null || other_val === '') {
|
||||
other_val = uci.get('linkback', sid, 'priority');
|
||||
}
|
||||
if (other_val != null && other_val !== '' && String(other_val) === String(value)) {
|
||||
has_conflict = true;
|
||||
conflicting_iface = uci.get('linkback', sid, 'name') || sid;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (has_conflict) {
|
||||
return t_priority_conflict.format(value, conflicting_iface);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
makeTableColumnExpand(o, '15%');
|
||||
|
||||
// 4. Metric (Read-only, generated from priority * 10)
|
||||
var metric_title = _('Base Metric');
|
||||
if (metric_title === '默认跃点 (Metric)') {
|
||||
metric_title = '默认跃点';
|
||||
}
|
||||
o = s.option(form.DummyValue, 'metric', metric_title);
|
||||
o.cfgvalue = function(section_id) {
|
||||
var prio = uci.get('linkback', section_id, 'priority');
|
||||
var prio_val = parseInt(prio, 10);
|
||||
if (isNaN(prio_val) || prio_val <= 0) {
|
||||
prio_val = 1;
|
||||
}
|
||||
return prio_val * 10;
|
||||
};
|
||||
makeTableColumnExpand(o, '15%');
|
||||
|
||||
// 5. Dummy display option for Check Type in main Grid table (read-only)
|
||||
o = s.option(form.DummyValue, 'check_type_disp', _('Check Type'));
|
||||
o.modalonly = false;
|
||||
o.cfgvalue = function(section_id) {
|
||||
if (uci.get('linkback', section_id, 'ping_targets'))
|
||||
return _('Ping Probe');
|
||||
if (uci.get('linkback', section_id, 'dns_server'))
|
||||
return _('DNS Probe');
|
||||
if (uci.get('linkback', section_id, 'tcp_target'))
|
||||
return _('TCP Probe');
|
||||
return _('-- Not Configured --');
|
||||
};
|
||||
makeTableColumnExpand(o, '20%');
|
||||
|
||||
// 6. Check Type Dropdown (Virtual field, Modal only to avoid Save & Apply side-effects)
|
||||
o = s.option(form.ListValue, 'check_type', _('Check Type'));
|
||||
o.value('ping', _('Ping Probe'));
|
||||
o.value('dns', _('DNS Probe'));
|
||||
o.value('tcp', _('TCP Probe'));
|
||||
o.default = 'ping';
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
// Derive the check type from which probe target is configured in UCI.
|
||||
o.cfgvalue = function(section_id) {
|
||||
if (uci.get('linkback', section_id, 'ping_targets'))
|
||||
return 'ping';
|
||||
if (uci.get('linkback', section_id, 'dns_server'))
|
||||
return 'dns';
|
||||
if (uci.get('linkback', section_id, 'tcp_target'))
|
||||
return 'tcp';
|
||||
return 'ping'; // Default to ping if not configured
|
||||
};
|
||||
|
||||
// Only write when the check type actually CHANGES from the current state.
|
||||
o.write = function(section_id, value) {
|
||||
var current = uci.get('linkback', section_id, 'ping_targets') ? 'ping' :
|
||||
(uci.get('linkback', section_id, 'dns_server') ? 'dns' :
|
||||
(uci.get('linkback', section_id, 'tcp_target') ? 'tcp' : ''));
|
||||
var next = (value == null) ? 'ping' : String(value);
|
||||
|
||||
// No change - do nothing
|
||||
if (next === current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear old type's fields and any legacy weights/thresholds
|
||||
uci.remove('linkback', section_id, 'weight_threshold');
|
||||
uci.remove('linkback', section_id, 'ping_weight');
|
||||
uci.remove('linkback', section_id, 'dns_weight');
|
||||
uci.remove('linkback', section_id, 'tcp_weight');
|
||||
|
||||
if (current === 'ping' || current === '') {
|
||||
uci.remove('linkback', section_id, 'ping_targets');
|
||||
}
|
||||
if (current === 'dns' || current === '') {
|
||||
uci.remove('linkback', section_id, 'dns_server');
|
||||
uci.remove('linkback', section_id, 'dns_domain');
|
||||
}
|
||||
if (current === 'tcp' || current === '') {
|
||||
uci.remove('linkback', section_id, 'tcp_target');
|
||||
uci.remove('linkback', section_id, 'tcp_port');
|
||||
}
|
||||
};
|
||||
|
||||
// 7. Ping Probe Parameters
|
||||
o = s.option(form.Value, 'ping_targets', _('Ping Targets'),
|
||||
_('Comma-separated list of IPs to ping (e.g., 223.5.5.5,8.8.8.8).'));
|
||||
o.rmempty = true;
|
||||
o.modalonly = true;
|
||||
o.depends('check_type', 'ping');
|
||||
o.validate = function(section_id, value) {
|
||||
if (!value) return true;
|
||||
var ips = value.replace(/\s+/g, '').split(',');
|
||||
for (var i = 0; i < ips.length; i++) {
|
||||
var ipPattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
if (!ipPattern.test(ips[i])) {
|
||||
return _('Invalid IP address: "%s"').format(ips[i]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
o.write = function(section_id, value) {
|
||||
if (value != null) {
|
||||
var cleaned = String(value).replace(/\s+/g, '');
|
||||
uci.set('linkback', section_id, 'ping_targets', cleaned);
|
||||
} else {
|
||||
uci.remove('linkback', section_id, 'ping_targets');
|
||||
}
|
||||
};
|
||||
|
||||
// 7. DNS Probe Parameters
|
||||
o = s.option(form.Value, 'dns_server', _('DNS Server'),
|
||||
_('DNS server IP for UDP query probe (e.g., 119.29.29.29).'));
|
||||
o.datatype = 'ip4addr';
|
||||
o.rmempty = true;
|
||||
o.modalonly = true;
|
||||
o.depends('check_type', 'dns');
|
||||
|
||||
o = s.option(form.Value, 'dns_domain', _('DNS Domain'),
|
||||
_('Domain name to resolve for DNS probe (e.g., www.baidu.com).'));
|
||||
o.rmempty = true;
|
||||
o.modalonly = true;
|
||||
o.depends('check_type', 'dns');
|
||||
|
||||
// 8. TCP Probe Parameters
|
||||
o = s.option(form.Value, 'tcp_target', _('TCP Target'),
|
||||
_('Target IP for TCP handshake probe.'));
|
||||
o.datatype = 'ip4addr';
|
||||
o.rmempty = true;
|
||||
o.modalonly = true;
|
||||
o.depends('check_type', 'tcp');
|
||||
|
||||
o = s.option(form.Value, 'tcp_port', _('TCP Port'),
|
||||
_('Target port for TCP handshake probe.'));
|
||||
o.datatype = 'port';
|
||||
o.rmempty = true;
|
||||
o.modalonly = true;
|
||||
o.depends('check_type', 'tcp');
|
||||
|
||||
// 9. Individual health check options (Modal only)
|
||||
o = s.option(form.Value, 'check_interval', _('Check Interval (s)'),
|
||||
_('Time in seconds between each health check cycle for this link.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.default = '5';
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'check_timeout', _('Check Timeout (s)'),
|
||||
_('Maximum wait time in seconds for each individual check probe for this link.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.default = '3';
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'recovery_delay', _('Recovery Delay'),
|
||||
_('Number of consecutive successful checks required before marking this link as healthy (failback anti-flap).'));
|
||||
o.datatype = 'uinteger';
|
||||
o.default = '3';
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'failover_delay', _('Failover Delay'),
|
||||
_('Number of consecutive failed checks required before marking this link as faulted (failover anti-flap).'));
|
||||
o.datatype = 'uinteger';
|
||||
o.default = '2';
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
return m.render();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user