mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 02:15:13 +08:00
🎈 Sync 2026-09-09 01:06:03
This commit is contained in:
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
|
||||
});
|
||||
+136
@@ -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
|
||||
});
|
||||
Reference in new issue
Block a user