🎈 Sync 2026-09-09 01:06:03

This commit is contained in:
github-actions[bot] committed 2026-09-09 01:06:03 +08:00
1 parent 6701c0a72f
commit 6dadf1e827
35 files changed
+5887

No files matched your search

@@ -0,0 +1,88 @@
'use strict';
/* global hmUi */
'require view';
'require fs';
'require ui';
'require haproxy-manager.ui as hmUi';
return view.extend({
load: function() {
return Promise.all([
fs.read('/etc/haproxy.cfg').catch(function() {
return '';
})
]);
},
applyRaw: function(textarea) {
ui.showModal(_('Apply raw configuration?'), [
E('p', _('The file will be validated and backed up before HAProxy restarts.')),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn cbi-button',
'type': 'button',
'click': ui.hideModal
}, _('Cancel')),
' ',
E('button', {
'class': 'btn cbi-button cbi-button-apply',
'type': 'button',
'click': ui.createHandlerFn(this, function() {
return fs.write('/tmp/haproxy-manager-raw.cfg', textarea.value).then(function() {
return hmUi.exec('/usr/libexec/haproxy-manager/apply-raw-file', [ '/tmp/haproxy-manager-raw.cfg' ]);
}).then(function(r) {
ui.hideModal();
hmUi.notify(r.stdout || _('Applied'), 'info');
}).catch(function(err) {
ui.hideModal();
hmUi.notifyError(err);
});
})
}, _('Apply'))
])
]);
},
render: function(data) {
var cfg = data[0];
var textarea = E('textarea', {
'id': 'haproxy-raw-config',
'class': 'cbi-input-textarea hm-raw-editor',
'spellcheck': 'false',
'aria-label': _('Raw HAProxy configuration')
}, cfg || '');
hmUi.ensureStyles();
return E('div', { 'class': 'cbi-map' }, [
E('h2', _('Raw HAProxy Config')),
E('div', { 'class': 'cbi-map-descr' }, _('Expert editor for /etc/haproxy.cfg.')),
E('div', { 'class': 'alert-message warning' }, _('Generated routes can overwrite manual changes to this file.')),
textarea,
E('div', { 'class': 'cbi-page-actions hm-actions' }, [
E('button', {
'class': 'btn cbi-button cbi-button-action',
'type': 'button',
'click': ui.createHandlerFn(this, function() {
return fs.write('/tmp/haproxy-manager-raw.cfg', textarea.value).then(function() {
return hmUi.exec('/usr/libexec/haproxy-manager/validate', [ '/tmp/haproxy-manager-raw.cfg' ]);
}).then(function(r) {
hmUi.notify(r.stdout || _('Config is valid'), 'info');
}).catch(function(err) {
hmUi.notifyError(err);
});
})
}, _('Validate')),
E('button', {
'class': 'btn cbi-button cbi-button-apply',
'type': 'button',
'click': ui.createHandlerFn(this, function() { this.applyRaw(textarea); })
}, _('Apply raw config'))
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,464 @@
'use strict';
/* global hmUi */
'require view';
'require dom';
'require form';
'require ui';
'require haproxy-manager.ui as hmUi';
function listValue(value) {
if (Array.isArray(value))
return value;
if (value == null || value === '')
return [];
return String(value).trim().split(/\s+/);
}
function routeKind(map, sectionId) {
return map.data.get('haproxy_manager', sectionId, 'kind') ||
map.data.get('haproxy_manager', sectionId, 'protocol') || 'web';
}
function kindLabel(kind) {
switch (kind) {
case 'web': return _('Web');
case 'ssh': return _('SSH');
case 'rdp': return _('Remote Desktop');
case 'custom': return _('Custom TCP');
case 'http': return _('HTTP only');
case 'https': return _('HTTPS only');
case 'both': return _('Legacy Web');
default: return _('TCP');
}
}
function portMapIsValid(value) {
var match = String(value || '').match(/^(\d+):(\d+)$/);
return !!match && +match[1] >= 1 && +match[1] <= 65535 && +match[2] >= 1 && +match[2] <= 65535;
}
function parseFirewall(output) {
var result = { ports: {}, conflicts: [], conflict_count: '0', enabled: '0', policy: '', managed_rule: '0' };
String(output || '').split(/\r?\n/).forEach(function(line) {
var fields = line.split('\t');
if (fields[0] == 'port')
result.ports[fields[1]] = true;
else if (fields[0] == 'conflict')
result.conflicts.push({ section: fields[1], name: fields[2], ports: fields[3] });
else if (fields[0] == 'conflicts')
result.conflict_count = fields[1] || '0';
else if (fields[0])
result[fields[0]] = fields.slice(1).join('\t');
});
return result;
}
function specContains(spec, port) {
return String(spec || '').split(/[\s,]+/).some(function(token) {
var range = token.split('-').map(Number);
return range.length == 1 ? range[0] == port : port >= range[0] && port <= range[1];
});
}
function routePorts(map, sectionId, kind) {
var main = function(option, fallback) {
return map.data.get('haproxy_manager', 'main', option) || fallback;
};
switch (kind) {
case 'web':
return [
map.data.get('haproxy_manager', sectionId, 'web_http') == '0' ? null : main('http_port', '80'),
map.data.get('haproxy_manager', sectionId, 'web_https') == '0' ? null : main('https_port', '443')
].filter(Boolean);
case 'ssh':
return [map.data.get('haproxy_manager', sectionId, 'ssh_listen_port') || '22'];
case 'rdp':
return [map.data.get('haproxy_manager', sectionId, 'rdp_listen_port') || '3389'];
case 'custom':
return listValue(map.data.get('haproxy_manager', sectionId, 'port_map')).map(function(item) {
return item.split(':')[0];
});
case 'http': return [main('http_port', '80')];
case 'https': return [main('https_port', '443')];
case 'both': return [main('http_port', '80'), main('https_port', '443')];
default:
return [map.data.get('haproxy_manager', sectionId, 'listen_port') ||
map.data.get('haproxy_manager', sectionId, 'backend_port')].filter(Boolean);
}
}
return view.extend({
load: function() {
return Promise.all([
hmUi.exec('/usr/libexec/haproxy-manager/firewall-plan', []).catch(function() {
return { stdout: '' };
})
]);
},
render: function(data) {
var m, s, o;
var firewall = parseFirewall(data[0].stdout);
hmUi.ensureStyles();
m = new form.Map('haproxy_manager', _('HAProxy Services'),
_('Publish Web, SSH, Remote Desktop, and custom TCP services from one place.'));
s = m.section(form.GridSection, 'route', _('Services'));
s.anonymous = true;
s.addremove = true;
s.sortable = true;
s.nodescriptions = true;
s.addbtntitle = _('Add service');
o = s.option(form.Flag, 'enabled', _('Enabled'));
o.default = '1';
o.modalonly = true;
o = s.option(form.Value, 'name', _('Service name'));
o.placeholder = _('My service');
o.rmempty = true;
o.modalonly = true;
o = s.option(form.ListValue, 'kind', _('Service type'));
o.value('web', _('Web (HTTP + HTTPS)'));
o.value('ssh', _('SSH'));
o.value('rdp', _('Remote Desktop'));
o.value('custom', _('Custom TCP'));
o.default = 'web';
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'host', _('Domain'));
o.placeholder = 'example.org';
o.datatype = 'hostname';
o.rmempty = false;
o.modalonly = true;
o.depends('kind', 'web');
o = s.option(form.Value, 'backend_host', _('Destination host'));
o.placeholder = '192.0.2.10';
o.datatype = 'or(ipaddr,hostname)';
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Flag, 'web_http', _('Publish HTTP'));
o.default = '1';
o.modalonly = true;
o.depends('kind', 'web');
o = s.option(form.Value, 'backend_http_port', _('Destination HTTP port'));
o.datatype = 'port';
o.default = '80';
o.rmempty = false;
o.modalonly = true;
o.depends({ kind: 'web', web_http: '1' });
o = s.option(form.Flag, 'web_https', _('Publish HTTPS'));
o.default = '1';
o.modalonly = true;
o.depends('kind', 'web');
o = s.option(form.Value, 'backend_https_port', _('Destination HTTPS port'));
o.datatype = 'port';
o.default = '443';
o.rmempty = false;
o.modalonly = true;
o.depends({ kind: 'web', web_https: '1' });
o = s.option(form.Value, 'ssh_listen_port', _('Public SSH port'));
o.datatype = 'port';
o.default = '22';
o.rmempty = false;
o.modalonly = true;
o.depends('kind', 'ssh');
o = s.option(form.Value, 'ssh_backend_port', _('Destination SSH port'));
o.datatype = 'port';
o.default = '22';
o.rmempty = false;
o.modalonly = true;
o.depends('kind', 'ssh');
o = s.option(form.Value, 'rdp_listen_port', _('Public RDP port'));
o.datatype = 'port';
o.default = '3389';
o.rmempty = false;
o.modalonly = true;
o.depends('kind', 'rdp');
o = s.option(form.Value, 'rdp_backend_port', _('Destination RDP port'));
o.datatype = 'port';
o.default = '3389';
o.rmempty = false;
o.modalonly = true;
o.depends('kind', 'rdp');
o = s.option(form.DynamicList, 'port_map', _('TCP port mappings'));
o.placeholder = '8443:443';
o.rmempty = false;
o.modalonly = true;
o.depends('kind', 'custom');
o.validate = function(sectionId, value) {
return portMapIsValid(value) || _('Use the public:destination format, for example 8443:443.');
};
o = s.option(form.DummyValue, '_status', _('Status'));
o.modalonly = false;
o.textvalue = function(sectionId) {
var enabled = m.data.get('haproxy_manager', sectionId, 'enabled') != '0';
return E('span', {
'class': 'hm-state %s'.format(enabled ? 'hm-state-on' : 'hm-state-off')
}, enabled ? _('On') : _('Off'));
};
o = s.option(form.DummyValue, '_service', _('Service'));
o.modalonly = false;
o.textvalue = function(sectionId) {
var kind = routeKind(m, sectionId);
var name = m.data.get('haproxy_manager', sectionId, 'name') ||
m.data.get('haproxy_manager', sectionId, 'host') || kindLabel(kind);
var enabled = m.data.get('haproxy_manager', sectionId, 'enabled') != '0';
return E('div', { 'class': 'hm-service-cell' }, [
E('div', { 'class': 'hm-route-name' }, [
E('strong', name),
E('span', {
'class': 'hm-state hm-mobile-only %s'.format(enabled ? 'hm-state-on' : 'hm-state-off')
}, enabled ? _('On') : _('Off'))
]),
E('span', { 'class': 'hm-badge' }, kindLabel(kind))
]);
};
o = s.option(form.DummyValue, '_endpoint', _('Public endpoint'));
o.modalonly = false;
o.textvalue = function(sectionId) {
var kind = routeKind(m, sectionId);
var ports = routePorts(m, sectionId, kind);
var host = m.data.get('haproxy_manager', sectionId, 'host');
if (kind == 'web' || kind == 'http' || kind == 'https' || kind == 'both') {
return E('div', { 'class': 'hm-endpoint' }, [
E('span', { 'class': 'hm-endpoint-value' }, host || _('Not set')),
E('span', { 'class': 'hm-port-summary' }, ports.map(function(port) { return ':' + port; }).join(' + '))
]);
}
return E('div', { 'class': 'hm-port-list' }, ports.map(function(port) {
return E('code', ':' + port);
}));
};
o = s.option(form.DummyValue, '_destination', _('Destination'));
o.modalonly = false;
o.textvalue = function(sectionId) {
var kind = routeKind(m, sectionId);
var host = m.data.get('haproxy_manager', sectionId, 'backend_host') || _('Not set');
var mappings = [];
if (kind == 'web') {
if (m.data.get('haproxy_manager', sectionId, 'web_http') != '0')
mappings.push(_('HTTP') + ' :' + (m.data.get('haproxy_manager', sectionId, 'backend_http_port') || '80'));
if (m.data.get('haproxy_manager', sectionId, 'web_https') != '0')
mappings.push(_('HTTPS') + ' :' + (m.data.get('haproxy_manager', sectionId, 'backend_https_port') || '443'));
}
else if (kind == 'ssh')
mappings.push(':' + (m.data.get('haproxy_manager', sectionId, 'ssh_backend_port') || '22'));
else if (kind == 'rdp')
mappings.push(':' + (m.data.get('haproxy_manager', sectionId, 'rdp_backend_port') || '3389'));
else if (kind == 'custom')
mappings = listValue(m.data.get('haproxy_manager', sectionId, 'port_map')).map(function(item) { return ':' + item.split(':')[1]; });
else
mappings.push(':' + (m.data.get('haproxy_manager', sectionId, 'backend_port') || ''));
return E('div', { 'class': 'hm-destination' }, [
E('code', host),
E('span', mappings.join(' / '))
]);
};
o = s.option(form.DummyValue, '_firewall', _('Firewall'));
o.modalonly = false;
o.textvalue = function(sectionId) {
var kind = routeKind(m, sectionId);
var ports = routePorts(m, sectionId, kind).map(Number);
var conflict = firewall.conflicts.some(function(item) {
return ports.some(function(port) { return specContains(item.ports, port); });
});
var state = firewall.enabled != '1' ? _('Manual') : conflict ? _('Conflict') :
(firewall.managed_rule == '1' || firewall.policy == 'ACCEPT' ? _('Ready') : _('Pending'));
return E('span', {
'class': 'hm-state %s'.format(conflict ? 'hm-state-danger' : firewall.enabled == '1' ? 'hm-state-on' : 'hm-state-off')
}, state);
};
function applied(reload) {
hmUi.notifyApplied();
if (reload)
hmUi.reloadAfterApply();
}
function applyMap(reload) {
return hmUi.saveAndApply(m).then(function() {
applied(reload);
}).catch(function(error) {
hmUi.notifyError(error);
});
}
var baseHandleDrop = s.handleDrop;
var baseHandleTouchEnd = s.handleTouchEnd;
s.handleModalSave = function(modalMap, ev) {
var mapNode = this.getActiveModalMap();
var activeMap = dom.findClassInstance(mapNode);
return activeMap.save(null, true).then(function() {
return hmUi.commitAndApply();
}).then(function() {
return this.handleModalCancel(modalMap, ev, true);
}.bind(this)).then(function() {
applied(true);
}).catch(function(error) {
hmUi.notifyError(error);
});
};
s.handleRemove = function(sectionId) {
ui.showModal(_('Delete service?'), [
E('p', _('The service will be removed from HAProxy and the firewall immediately.')),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn cbi-button',
'click': ui.hideModal
}, _('Cancel')),
' ',
E('button', {
'class': 'btn cbi-button cbi-button-negative important',
'click': ui.createHandlerFn(this, function() {
ui.hideModal();
m.data.remove('haproxy_manager', sectionId);
return applyMap(true);
})
}, _('Delete and apply'))
])
]);
};
function applyReorder() {
window.setTimeout(function() {
applyMap(true);
}, 100);
}
s.handleDrop = function(ev) {
var shouldApply = !!(ev.currentTarget && ev.currentTarget.matches &&
ev.currentTarget.matches('.drag-over-above, .drag-over-below'));
var result = baseHandleDrop.call(this, ev);
if (shouldApply)
applyReorder();
return result;
};
s.handleTouchEnd = function(ev) {
var row = ev.target && ev.target.closest ? ev.target.closest('.tr') : null;
var shouldApply = !!(document.querySelector('.touchsort-element') && row &&
row.parentNode.querySelector('.drag-over-above, .drag-over-below'));
var result = baseHandleTouchEnd.call(this, ev);
if (shouldApply)
applyReorder();
return result;
};
return m.render().then(function(node) {
var filterInput = E('input', {
'id': 'haproxy-route-filter',
'class': 'cbi-input-text hm-filter',
'type': 'search',
'placeholder': _('Filter services'),
'aria-label': _('Filter services')
});
var countNode = E('span', { 'class': 'hm-route-count' });
var emptyNode = E('p', {
'class': 'alert-message notice hm-filter-empty',
'hidden': ''
}, _('No matching services'));
var toolbar = E('div', { 'class': 'hm-toolbar' }, [ filterInput, countNode ]);
var section = node.querySelector('.cbi-section');
function updateFilter() {
var query = filterInput.value.trim().toLowerCase();
var rows = node.querySelectorAll('.cbi-section-table-row[data-sid]');
var visible = 0;
for (var i = 0; i < rows.length; i++) {
var match = !query || rows[i].textContent.toLowerCase().indexOf(query) > -1;
rows[i].hidden = !match;
visible += match ? 1 : 0;
}
countNode.textContent = _('%d services').format(visible);
emptyNode.hidden = visible > 0 || !query;
}
filterInput.addEventListener('input', updateFilter);
if (section) {
var table = section.querySelector('.table');
if (table)
table.classList.add('hm-route-table');
section.insertBefore(toolbar, table || section.firstChild);
section.appendChild(emptyNode);
}
if (firewall.conflicts.length) {
node.insertBefore(E('div', { 'class': 'alert-message warning hm-inline-alert' }, [
E('strong', _('Firewall conflict detected.')),
' ',
E('a', { 'href': L.url('admin/services/haproxy-manager/settings') }, _('Review firewall settings'))
]), node.firstChild.nextSibling);
}
node.appendChild(E('div', { 'class': 'cbi-page-actions hm-actions' }, [
E('button', {
'class': 'btn cbi-button cbi-button-action',
'type': 'button',
'click': ui.createHandlerFn(this, function() {
return hmUi.exec('/usr/libexec/haproxy-manager/generate', [ '/tmp/haproxy-manager-preview.cfg' ]).then(function() {
return hmUi.exec('/usr/libexec/haproxy-manager/validate', [ '/tmp/haproxy-manager-preview.cfg' ]);
}).then(function() {
return hmUi.exec('/usr/libexec/haproxy-manager/firewall-plan', []);
}).then(function(r) {
var plan = parseFirewall(r.stdout);
hmUi.notify(plan.conflicts.length ?
_('Configuration is valid. Firewall conflicts: %d').format(plan.conflicts.length) :
_('Configuration and firewall are ready.'), plan.conflicts.length ? 'warning' : 'info');
}).catch(function(err) {
hmUi.notifyError(err);
});
})
}, _('Check configuration')),
E('button', {
'class': 'btn cbi-button cbi-button-apply',
'type': 'button',
'click': ui.createHandlerFn(this, function() {
return applyMap(true);
})
}, _('Synchronize now'))
]));
updateFilter();
return node;
}.bind(this));
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,136 @@
'use strict';
/* global hmUi */
'require view';
'require form';
'require ui';
'require haproxy-manager.ui as hmUi';
return view.extend({
render: function() {
var m, s, o;
hmUi.ensureStyles();
m = new form.Map('haproxy_manager', _('HAProxy Settings'),
_('Configure shared Web entry ports, router access, and firewall automation.'));
s = m.section(form.NamedSection, 'main', 'settings', _('HAProxy service'));
s.anonymous = true;
o = s.option(form.Flag, 'enabled', _('Enable managed configuration'));
o.default = '0';
o = s.option(form.Value, 'wan_interface', _('WAN network interface'));
o.default = 'wan';
o.datatype = 'uciname';
o.rmempty = false;
o.description = _('The selected logical interface is monitored for address changes. In multi-WAN setups, select the interface that receives public connections.');
o = s.option(form.Value, 'wan_bind_ip', _('WAN bind address'));
o.placeholder = _('Automatic');
o.default = 'auto';
o.datatype = 'or(ipaddr,hostname)';
o.rmempty = false;
s = m.section(form.NamedSection, 'main', 'settings', _('Web entry ports'));
s.anonymous = true;
s.description = _('Every Web service shares these public ports. SSH, Remote Desktop, and Custom TCP services use the ports configured on each service.');
o = s.option(form.Value, 'http_port', _('Public HTTP port'));
o.datatype = 'port';
o.default = '80';
o.rmempty = false;
o = s.option(form.Value, 'https_port', _('Public HTTPS port'));
o.datatype = 'port';
o.default = '443';
o.rmempty = false;
s = m.section(form.NamedSection, 'main', 'settings', _('Router access'));
s.anonymous = true;
o = s.option(form.Flag, 'manage_uhttpd_bind', _('Keep LuCI on the LAN address'));
o.default = '0';
o.description = _('Recommended when HAProxy uses public ports 80 or 443. LuCI remains available from the local network.');
o = s.option(form.Value, 'lan_bind_ip', _('LuCI LAN address'));
o.placeholder = _('Automatic');
o.default = 'auto';
o.datatype = 'or(ipaddr,hostname)';
o.depends('manage_uhttpd_bind', '1');
s = m.section(form.NamedSection, 'main', 'settings', _('Firewall automation'));
s.anonymous = true;
o = s.option(form.Flag, 'manage_firewall', _('Open HAProxy ports on WAN automatically'));
o.default = '0';
o.description = _('Creates and updates only firewall rules owned by HAProxy Manager.');
o = s.option(form.Value, 'firewall_zone', _('WAN firewall zone'));
o.default = 'wan';
o.rmempty = false;
o.depends('manage_firewall', '1');
o = s.option(form.ListValue, 'firewall_conflict_mode', _('Existing port-forward conflicts'));
o.value('warn', _('Stop and show conflicts'));
o.value('disable', _('Disable conflicting forwards during apply'));
o.default = 'warn';
o.rmempty = false;
o.depends('manage_firewall', '1');
s = m.section(form.NamedSection, 'main', 'settings', _('Recovery'));
s.anonymous = true;
s.description = _('A restorable snapshot is created before migration and every apply.');
o = s.option(form.Flag, 'auto_recover', _('Recover HAProxy after networking becomes ready'));
o.default = '1';
o.description = _('Reconciles generated listeners after WAN address changes, retries a stopped service, and retains diagnostics for the seven latest incidents. Raw configurations are never overwritten.');
o = s.option(form.Flag, 'webhook_enabled', _('Send recovery webhook notifications'));
o.default = '0';
o.description = _('Sends only the incident identifier, result, action, interface, and reason. Configuration, addresses, and diagnostic logs are never included.');
o = s.option(form.Value, 'webhook_url', _('Webhook URL'));
o.password = true;
o.rmempty = false;
o.depends('webhook_enabled', '1');
o.validate = function(sectionId, value) {
return /^https?:\/\/[^/\s]+(?:\/\S*)?$/.test(value || '') || _('Enter an HTTP or HTTPS URL without spaces.');
};
return m.render().then(function(node) {
var recoverySection = node.querySelectorAll('.cbi-section');
var lastSection = recoverySection.length ? recoverySection[recoverySection.length - 1] : null;
if (lastSection) {
lastSection.appendChild(E('p', [
E('a', {
'class': 'btn cbi-button',
'href': L.url('admin/services/haproxy-manager/status') + '#recovery'
}, _('Open recovery'))
]));
}
node.appendChild(E('div', { 'class': 'cbi-page-actions hm-actions' }, [
E('button', {
'class': 'btn cbi-button cbi-button-apply',
'type': 'button',
'click': ui.createHandlerFn(this, function() {
return hmUi.saveAndApply(m).then(function() {
hmUi.notifyApplied();
hmUi.reloadAfterApply();
}).catch(function(err) {
hmUi.notifyError(err);
});
})
}, _('Save settings'))
]));
return node;
}.bind(this));
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,294 @@
'use strict';
/* global hmUi */
'require view';
'require ui';
'require haproxy-manager.ui as hmUi';
function parseStatus(output) {
var status = { listeners: [] };
String(output || '').split(/\r?\n/).forEach(function(line) {
var fields = line.split('\t');
if (fields[0] == 'listener') {
status.listeners.push({
protocol: fields[1] || '',
address: fields[2] || '',
process: fields.slice(3).join('\t') || ''
});
}
else if (fields[0])
status[fields[0]] = fields.slice(1).join('\t');
});
return status;
}
function parseBackups(output) {
return String(output || '').split(/\r?\n/).map(function(line) {
var fields = line.split('\t');
return fields[0] == 'backup' ? { id: fields[1], latest: fields[2] == '1', files: fields[3] } : null;
}).filter(Boolean);
}
function parseIncidents(output) {
return String(output || '').split(/\r?\n/).map(function(line) {
var fields = line.split('\t');
return fields[0] == 'incident' ? {
id: fields[1],
latest: fields[2] == '1',
result: fields[3] || '',
action: fields[4] || '',
interface: fields[5] || '',
reason: fields[6] || ''
} : null;
}).filter(Boolean);
}
function parseFirewall(output) {
var result = { conflicts: '0', enabled: '0', policy: '' };
String(output || '').split(/\r?\n/).forEach(function(line) {
var fields = line.split('\t');
if (fields[0] && fields[0] != 'conflict' && fields[0] != 'port')
result[fields[0]] = fields.slice(1).join('\t');
});
return result;
}
function backupDate(id) {
var match = String(id || '').match(/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/);
return match ? '%s-%s-%s %s:%s:%s'.format(match[1], match[2], match[3], match[4], match[5], match[6]) : id;
}
function statusItem(label, value) {
return E('div', { 'class': 'hm-status-item' }, [
E('span', { 'class': 'hm-status-label' }, label),
E('span', { 'class': 'hm-status-value' }, value)
]);
}
return view.extend({
load: function() {
return Promise.all([
hmUi.exec('/usr/libexec/haproxy-manager/status', []).catch(function(err) {
return { stdout: '', stderr: err.message || String(err), code: 1 };
}),
hmUi.exec('/usr/libexec/haproxy-manager/backups', []).catch(function() { return { stdout: '' }; }),
hmUi.exec('/usr/libexec/haproxy-manager/firewall-plan', []).catch(function() { return { stdout: '' }; }),
hmUi.exec('/usr/libexec/haproxy-manager/incidents', []).catch(function() { return { stdout: '' }; })
]);
},
showIncident: function(incidentId) {
return hmUi.exec('/usr/libexec/haproxy-manager/incident', [ incidentId ]).then(function(result) {
ui.showModal(_('Incident diagnostics'), [
E('p', _('Diagnostic report for %s.').format(backupDate(incidentId))),
E('pre', { 'class': 'hm-incident-report' }, result.stdout || _('Not available')),
E('div', { 'class': 'right' }, E('button', {
'class': 'btn cbi-button cbi-button-action',
'type': 'button',
'click': ui.hideModal
}, _('Close')))
]);
}).catch(function(err) {
hmUi.notifyError(err);
});
},
showRestore: function(backupId) {
ui.showModal(_('Restore configuration?'), [
E('p', _('HAProxy, firewall, and LuCI settings will be restored from %s.').format(backupDate(backupId))),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn cbi-button',
'type': 'button',
'click': ui.hideModal
}, _('Cancel')),
' ',
E('button', {
'class': 'btn cbi-button cbi-button-negative important',
'type': 'button',
'click': ui.createHandlerFn(this, function() {
return hmUi.exec('/usr/libexec/haproxy-manager/rollback', [ backupId ]).then(function(r) {
ui.hideModal();
hmUi.notify(r.stdout || _('Restored'), 'info');
hmUi.reloadAfterApply(3500);
}).catch(function(err) {
ui.hideModal();
hmUi.notifyError(err);
});
})
}, _('Restore'))
])
]);
},
render: function(data) {
var res = data[0];
var status = parseStatus(res.stdout);
var backups = parseBackups(data[1].stdout);
var firewall = parseFirewall(data[2].stdout);
var incidents = parseIncidents(data[3].stdout);
var running = status.service == 'running';
var autoRecovery = status.auto_recovery != '0';
var recoveryResults = {
'recovered': _('Recovered'),
'failed': _('Failed'),
'invalid-config': _('Invalid configuration'),
'backup-failed': _('Recovery point failed'),
'detected': _('In progress')
};
recoveryResults.reconciled = _('WAN address reconciled');
recoveryResults['rolled-back'] = _('Failed, configuration restored');
recoveryResults['rollback-failed'] = _('Failed, restore also failed');
recoveryResults['address-unavailable'] = _('WAN address unavailable');
recoveryResults['invalid-generated-config'] = _('Generated configuration is invalid');
recoveryResults['install-failed'] = _('Configuration installation failed');
var lastRecovery = status.last_incident ? '%s - %s%s'.format(
backupDate(status.last_incident),
recoveryResults[status.last_incident_result] || status.last_incident_result,
status.last_incident_interface ? ' (%s)'.format(status.last_incident_interface) : ''
) : _('No incidents recorded');
var firewallText = firewall.enabled != '1' ? _('Manual') : +firewall.conflicts > 0 ?
_('%d conflicts').format(+firewall.conflicts) : _('Managed');
var modes = {
generated: _('Generated routes'),
raw: _('Raw configuration'),
none: _('Not managed')
};
var listeners = status.listeners.map(function(listener) {
return E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td', 'data-title': _('Protocol') }, listener.protocol),
E('td', { 'class': 'td', 'data-title': _('Address') }, E('code', listener.address)),
E('td', { 'class': 'td', 'data-title': _('Process') }, listener.process)
]);
});
var backupRows = backups.map(function(backup) {
return E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td', 'data-title': _('Created') }, [
E('span', backupDate(backup.id)),
backup.latest ? E('span', { 'class': 'hm-badge hm-backup-latest' }, _('Latest')) : ''
]),
E('td', { 'class': 'td', 'data-title': _('Contents') }, _('%d configuration files').format(+backup.files || 0)),
E('td', { 'class': 'td cbi-section-actions' }, E('button', {
'class': 'btn cbi-button cbi-button-action',
'type': 'button',
'click': ui.createHandlerFn(this, function() { this.showRestore(backup.id); })
}, _('Restore')))
]);
}.bind(this));
var incidentRows = incidents.map(function(incident) {
var result = recoveryResults[incident.result] || incident.result || _('Not available');
var context = [ incident.action, incident.interface ].filter(Boolean).join(' / ');
return E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td', 'data-title': _('Created') }, [
E('span', backupDate(incident.id)),
incident.latest ? E('span', { 'class': 'hm-badge hm-backup-latest' }, _('Latest')) : ''
]),
E('td', { 'class': 'td', 'data-title': _('Result') }, result),
E('td', { 'class': 'td', 'data-title': _('Trigger') }, context || incident.reason || '-'),
E('td', { 'class': 'td cbi-section-actions' }, E('button', {
'class': 'btn cbi-button cbi-button-action',
'type': 'button',
'click': ui.createHandlerFn(this, function() { return this.showIncident(incident.id); })
}, _('View')))
]);
}.bind(this));
hmUi.ensureStyles();
if (!listeners.length) {
listeners.push(E('tr', { 'class': 'tr placeholder' }, [
E('td', { 'class': 'td', 'colspan': '3' }, _('No HAProxy or LuCI listeners detected.'))
]));
}
if (!backupRows.length) {
backupRows.push(E('tr', { 'class': 'tr placeholder' }, [
E('td', { 'class': 'td', 'colspan': '3' }, _('No recovery points yet.'))
]));
}
if (!incidentRows.length) {
incidentRows.push(E('tr', { 'class': 'tr placeholder' }, [
E('td', { 'class': 'td', 'colspan': '4' }, _('No incidents recorded'))
]));
}
return E('div', { 'class': 'cbi-map' }, [
E('h2', _('HAProxy Status')),
E('div', { 'class': 'cbi-map-descr' }, _('Service health, active listeners, and recovery points.')),
res.code && res.stderr ? E('div', { 'class': 'alert-message error' }, res.stderr) : '',
E('div', { 'class': 'hm-status-grid' }, [
statusItem(_('Service'), E('span', {
'class': 'hm-state %s'.format(running ? 'hm-state-on' : 'hm-state-off')
}, running ? _('Running') : _('Stopped'))),
statusItem(_('WAN address'), status.wan_ip || _('Not set')),
statusItem(_('HAProxy version'), status.version || _('Not available')),
statusItem(_('Automatic recovery'), E('span', {
'class': 'hm-state %s'.format(autoRecovery ? 'hm-state-on' : 'hm-state-off')
}, autoRecovery ? _('Enabled') : _('Disabled'))),
statusItem(_('Configuration mode'), modes[status.active_mode] || status.active_mode || modes.none),
statusItem(_('Last recovery'), lastRecovery),
statusItem(_('Firewall'), E('span', {
'class': 'hm-state %s'.format(+firewall.conflicts > 0 ? 'hm-state-danger' : firewall.enabled == '1' ? 'hm-state-on' : 'hm-state-off')
}, firewallText))
]),
E('h3', _('Active listeners')),
E('table', { 'class': 'table hm-listener-table' }, [
E('thead', {}, [ E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('Protocol')),
E('th', { 'class': 'th' }, _('Address')),
E('th', { 'class': 'th' }, _('Process'))
]) ]),
E('tbody', {}, listeners)
]),
E('div', { 'class': 'hm-section-heading' }, [
E('h3', _('Recovery incidents')),
E('p', { 'class': 'cbi-section-descr' }, _('The seven latest automatic recovery reports are retained on the router.'))
]),
E('table', { 'class': 'table hm-incident-table' }, [
E('thead', {}, [ E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('Created')),
E('th', { 'class': 'th' }, _('Result')),
E('th', { 'class': 'th' }, _('Trigger')),
E('th', { 'class': 'th' })
]) ]),
E('tbody', {}, incidentRows)
]),
E('div', { 'id': 'recovery', 'class': 'hm-section-heading' }, [
E('div', { 'class': 'hm-section-title-row' }, [
E('h3', _('Recovery points')),
E('button', {
'class': 'btn cbi-button cbi-button-add',
'type': 'button',
'click': ui.createHandlerFn(this, function() {
return hmUi.exec('/usr/libexec/haproxy-manager/backup', []).then(function() {
window.location.reload();
}).catch(function(err) { hmUi.notifyError(err); });
})
}, _('Create recovery point'))
]),
E('p', { 'class': 'cbi-section-descr' }, _('Restore HAProxy, firewall, and LuCI settings from a previous snapshot.'))
]),
E('table', { 'class': 'table hm-backup-table' }, [
E('thead', {}, [ E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('Created')),
E('th', { 'class': 'th' }, _('Contents')),
E('th', { 'class': 'th' })
]) ]),
E('tbody', {}, backupRows)
]),
E('div', { 'class': 'cbi-page-actions hm-actions' }, [
E('button', {
'class': 'btn cbi-button cbi-button-action',
'type': 'button',
'click': function() { window.location.reload(); }
}, _('Refresh'))
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});