🎈 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,305 @@
.hm-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0 0 1.25rem;
}
.hm-filter {
appearance: none;
box-sizing: border-box;
width: min(100%, 28rem);
min-height: 2.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color-medium, rgba(128, 128, 128, 0.45));
border-radius: 4px;
outline: 0;
background: var(--background-color-high, rgba(128, 128, 128, 0.06));
color: inherit;
font: inherit;
}
.hm-filter:focus {
border-color: var(--primary-color-medium, currentColor);
box-shadow: 0 0 0 2px rgba(128, 128, 128, 0.18);
}
.hm-route-count {
white-space: nowrap;
opacity: 0.72;
}
.hm-endpoint {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
min-width: 0;
}
.hm-endpoint-value,
.hm-destination {
overflow-wrap: anywhere;
}
.hm-port-summary {
opacity: 0.72;
white-space: nowrap;
}
.hm-port-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.hm-port-list code,
.hm-destination code {
white-space: nowrap;
}
.hm-destination {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.hm-badge,
.hm-state {
display: inline-flex;
align-items: center;
width: max-content;
min-height: 1.6rem;
padding: 0 0.45rem;
border: 1px solid rgba(128, 128, 128, 0.45);
border-radius: 4px;
font-size: 0.82em;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
}
.hm-state-off {
opacity: 0.6;
}
.hm-state-danger {
border-color: var(--error-color, #b94a48);
color: var(--error-color, #b94a48);
}
.hm-filter-empty {
margin-top: 1rem;
}
.cbi-section-table-row[hidden],
.hm-filter-empty[hidden] {
display: none !important;
}
.hm-route-name {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
min-width: 0;
}
.hm-service-cell {
display: flex;
align-items: flex-start;
flex-direction: column;
gap: 0.4rem;
}
.hm-mobile-only {
display: none;
}
.hm-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
flex-wrap: wrap;
}
.hm-notification {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.hm-incident-report {
box-sizing: border-box;
max-height: min(60vh, 36rem);
margin: 1rem 0;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.hm-inline-alert {
margin-bottom: 1rem;
}
.hm-status-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
margin: 1.25rem 0 2rem;
border: 1px solid rgba(128, 128, 128, 0.35);
border-radius: 4px;
overflow: hidden;
}
.hm-status-item {
min-width: 0;
padding: 1rem;
background: var(--background-color-low, rgba(128, 128, 128, 0.08));
}
.hm-status-label {
display: block;
margin-bottom: 0.35rem;
font-size: 0.85em;
opacity: 0.72;
}
.hm-status-value {
display: block;
overflow-wrap: anywhere;
font-size: 1.05em;
font-weight: 600;
}
.hm-listener-table code {
white-space: nowrap;
}
.hm-section-heading {
margin-top: 2rem;
}
.hm-section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.hm-section-title-row h3 {
margin: 0;
}
.hm-section-heading > p {
margin: 0.25rem 0 0.75rem;
}
.hm-backup-latest {
margin-left: 0.5rem;
}
.hm-backup-table .cbi-section-actions {
text-align: right;
}
.hm-raw-editor {
box-sizing: border-box;
width: 100%;
min-height: 32rem;
font-family: monospace;
line-height: 1.45;
tab-size: 2;
}
@media (max-width: 900px) {
.hm-status-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.hm-toolbar {
align-items: stretch;
flex-direction: column;
}
.hm-filter {
width: 100%;
}
.hm-status-grid {
grid-template-columns: 1fr;
}
.hm-section-title-row {
align-items: stretch;
flex-direction: column;
}
.hm-section-title-row .cbi-button {
width: 100%;
}
.hm-actions > .btn,
.hm-actions > .cbi-button {
flex: 1 1 100%;
}
.hm-mobile-only {
display: inline-flex;
}
.cbi-section-table-row > .td[data-name="_status"] {
display: none !important;
}
.hm-route-table .cbi-section-table-row[data-sid] {
display: grid !important;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem 1rem;
padding: 1rem !important;
}
.hm-route-table .cbi-section-table-row[data-sid] > .td {
display: block;
box-sizing: border-box;
width: auto !important;
min-width: 0 !important;
padding: 0 !important;
border: 0 !important;
}
.hm-route-table .cbi-section-table-row[data-sid] > .td::before {
display: none !important;
content: none !important;
}
.hm-route-table .cbi-section-table-row[data-sid] > .td[data-name="_service"],
.hm-route-table .cbi-section-table-row[data-sid] > .td[data-name="_endpoint"] {
grid-column: 1 / -1;
}
.hm-route-table .cbi-section-table-row[data-sid] > .td[data-name="_destination"] {
align-self: center;
grid-column: 1 / -1;
}
.hm-route-table .cbi-section-table-row[data-sid] > .cbi-section-actions {
align-self: center;
grid-column: 1 / -1;
}
.hm-route-table .cbi-section-table-row[data-sid] > .td[data-name="_firewall"] {
grid-column: 1 / -1;
}
.hm-route-table .cbi-section-table-row[data-sid] > .cbi-section-actions > div {
display: flex;
gap: 0.35rem;
}
.hm-raw-editor {
min-height: 24rem;
}
}
@@ -0,0 +1,97 @@
'use strict';
'require baseclass';
'require fs';
'require rpc';
'require ui';
var callUciCommit = rpc.declare({
object: 'uci',
method: 'commit',
params: [ 'config' ],
reject: true
});
var APPLY_LOCK_BUSY = 75;
function recoveryId(output) {
var path = String(output || '').trim();
var id = path.split('/').pop();
if (!/^\d{8}-\d{6}$/.test(id))
throw new Error(_('Unable to create a recovery point.'));
return id;
}
return baseclass.extend({
ensureStyles: function() {
if (document.querySelector('link[data-haproxy-manager-style]'))
return;
document.head.appendChild(E('link', {
'rel': 'stylesheet',
'href': L.resource('haproxy-manager/style.css'),
'data-haproxy-manager-style': '1'
}));
},
notify: function(message, level) {
ui.addNotification(null, E('pre', { 'class': 'hm-notification' }, String(message || '').trim()), level || 'info');
},
notifyError: function(error) {
this.notify(error && error.message ? error.message : String(error), 'danger');
},
exec: function(path, args) {
return fs.exec(path, args || []).then(function(result) {
if (!result.code)
return result;
var message = String(result.stderr || result.stdout ||
_('Command failed with code %d.').format(result.code)).trim();
var error = new Error(message);
error.code = result.code;
error.stdout = result.stdout;
error.stderr = result.stderr;
throw error;
});
},
commitAndApply: function() {
var backupId;
var committed = false;
return this.exec('/usr/libexec/haproxy-manager/backup', []).then(function(result) {
backupId = recoveryId(result.stdout);
return callUciCommit('haproxy_manager');
}).then(function() {
committed = true;
return this.exec('/usr/libexec/haproxy-manager/apply', [ '--backup', backupId ]);
}.bind(this)).catch(function(error) {
if (!committed || !backupId || error.code !== APPLY_LOCK_BUSY)
throw error;
return this.exec('/usr/libexec/haproxy-manager/rollback', [ backupId ]).catch(function() {
return null;
}).then(function() {
throw error;
});
}.bind(this));
},
saveAndApply: function(map) {
return map.save(null, true).then(function() {
return this.commitAndApply();
}.bind(this));
},
notifyApplied: function() {
this.notify(_('Changes saved and applied.'), 'info');
},
reloadAfterApply: function(delay) {
window.setTimeout(function() {
window.location.reload();
}, delay || 2500);
}
});
@@ -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
});