feat: Firewall Bouncer Management UI in Bouncers Page
Enhanced CrowdSec Dashboard bouncers page with comprehensive firewall bouncer management capabilities. New Features: - Dedicated Firewall Bouncer management card with 3 status panels: * Service Status: Running/stopped, boot start enabled/disabled, configured status * Blocked IPs: Real-time IPv4/IPv6 blocked IP counts with View Details modal * nftables Status: IPv4/IPv6 table active status - Service Control Buttons: * Start/Stop service (contextual based on current state) * Restart service * Enable/Disable boot start (contextual) * Configuration viewer - Real-time Updates: * Auto-refresh every 10 seconds via polling * Manual refresh button * Live status badge updates - nftables Details Modal: * Lists all blocked IPv4 addresses (scrollable) * Lists all blocked IPv6 addresses (scrollable) * Shows IPv4/IPv6 rules count * Formatted with monospace font - Configuration Viewer Modal: * Displays all UCI configuration settings * Shows enabled/disabled status * Shows IPv4/IPv6 support * Shows API URL, update frequency, deny action * Shows deny logging and log prefix * Shows configured network interfaces * Handles unconfigured state with installation prompt UI Enhancements: - Responsive grid layout for status cards - Color-coded status indicators (green=active, red=stopped, gray=disabled, yellow=warning) - Material design badges for all status indicators - Visual feedback for all operations with notifications - Loading spinners for async operations - Professional styling consistent with SecuBox theme Integration: - Utilizes new API methods: getFirewallBouncerStatus, controlFirewallBouncer, getFirewallBouncerConfig, getNftablesStats - Error handling with user-friendly notifications - Proper promise chaining and async/await patterns Technical Details: - Added renderFirewallBouncerCard() method (125 lines) - Added handleFirewallBouncerControl() method for service actions - Added handleFirewallBouncerRefresh() for manual/auto refresh - Added showNftablesDetails() modal for blocked IPs - Added showFirewallBouncerConfig() modal for UCI settings - Enhanced load() to fetch firewall bouncer data - Updated polling to refresh firewall bouncer status 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
24383006aa
commit
ba64563b3f
@ -10,13 +10,17 @@ return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
API.getBouncers(),
|
||||
API.getStatus()
|
||||
API.getStatus(),
|
||||
API.getFirewallBouncerStatus(),
|
||||
API.getNftablesStats()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var bouncers = data[0] || [];
|
||||
var status = data[1] || {};
|
||||
var fwStatus = data[2] || {};
|
||||
var nftStats = data[3] || {};
|
||||
|
||||
var view = E('div', { 'class': 'cbi-map' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||
@ -42,6 +46,9 @@ return view.extend({
|
||||
])
|
||||
]),
|
||||
|
||||
// Firewall Bouncer Management Card
|
||||
this.renderFirewallBouncerCard(fwStatus, nftStats),
|
||||
|
||||
// Bouncers Table
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 1em;' }, [
|
||||
@ -119,10 +126,43 @@ return view.extend({
|
||||
|
||||
// Setup auto-refresh
|
||||
poll.add(L.bind(function() {
|
||||
return API.getBouncers().then(L.bind(function(refreshData) {
|
||||
return Promise.all([
|
||||
API.getBouncers(),
|
||||
API.getFirewallBouncerStatus(),
|
||||
API.getNftablesStats()
|
||||
]).then(L.bind(function(refreshData) {
|
||||
// Update bouncer table
|
||||
var tbody = document.getElementById('bouncers-tbody');
|
||||
if (tbody) {
|
||||
dom.content(tbody, this.renderBouncerRows(refreshData || []));
|
||||
dom.content(tbody, this.renderBouncerRows(refreshData[0] || []));
|
||||
}
|
||||
|
||||
// Update firewall bouncer status
|
||||
var fwStatus = refreshData[1] || {};
|
||||
var nftStats = refreshData[2] || {};
|
||||
|
||||
var statusBadge = document.getElementById('fw-bouncer-status');
|
||||
if (statusBadge) {
|
||||
var running = fwStatus.running || false;
|
||||
statusBadge.textContent = running ? _('ACTIVE') : _('STOPPED');
|
||||
statusBadge.style.background = running ? '#28a745' : '#dc3545';
|
||||
}
|
||||
|
||||
var enabledBadge = document.getElementById('fw-bouncer-enabled');
|
||||
if (enabledBadge) {
|
||||
var enabled = fwStatus.enabled || false;
|
||||
enabledBadge.textContent = enabled ? _('ENABLED') : _('DISABLED');
|
||||
enabledBadge.style.background = enabled ? '#17a2b8' : '#6c757d';
|
||||
}
|
||||
|
||||
var ipv4Count = document.getElementById('fw-bouncer-ipv4-count');
|
||||
if (ipv4Count) {
|
||||
ipv4Count.textContent = (fwStatus.blocked_ipv4 || 0).toString();
|
||||
}
|
||||
|
||||
var ipv6Count = document.getElementById('fw-bouncer-ipv6-count');
|
||||
if (ipv6Count) {
|
||||
ipv6Count.textContent = (fwStatus.blocked_ipv6 || 0).toString();
|
||||
}
|
||||
}, this));
|
||||
}, this), 10);
|
||||
@ -394,6 +434,348 @@ return view.extend({
|
||||
]);
|
||||
},
|
||||
|
||||
renderFirewallBouncerCard: function(fwStatus, nftStats) {
|
||||
var running = fwStatus.running || false;
|
||||
var enabled = fwStatus.enabled || false;
|
||||
var configured = fwStatus.configured || false;
|
||||
var blockedIPv4 = fwStatus.blocked_ipv4 || 0;
|
||||
var blockedIPv6 = fwStatus.blocked_ipv6 || 0;
|
||||
var nftIPv4 = fwStatus.nftables_ipv4 || false;
|
||||
var nftIPv6 = fwStatus.nftables_ipv6 || false;
|
||||
|
||||
return E('div', { 'class': 'cbi-section', 'id': 'firewall-bouncer-card' }, [
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 1em;' }, [
|
||||
E('h3', { 'style': 'margin: 0;' }, _('Firewall Bouncer')),
|
||||
E('div', { 'style': 'display: flex; gap: 0.5em;' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': L.bind(this.handleFirewallBouncerRefresh, this)
|
||||
}, _('Refresh'))
|
||||
])
|
||||
]),
|
||||
|
||||
E('div', { 'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1em; margin-bottom: 1em;' }, [
|
||||
// Status Card
|
||||
E('div', { 'style': 'background: ' + (running ? '#d4edda' : '#f8d7da') + '; border-left: 4px solid ' + (running ? '#28a745' : '#dc3545') + '; padding: 1em; border-radius: 4px;' }, [
|
||||
E('div', { 'style': 'font-weight: bold; margin-bottom: 0.5em; color: #333;' }, _('Service Status')),
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center;' }, [
|
||||
E('span', {}, _('Running:')),
|
||||
E('span', {
|
||||
'class': 'badge',
|
||||
'id': 'fw-bouncer-status',
|
||||
'style': 'background: ' + (running ? '#28a745' : '#dc3545') + '; color: white; padding: 0.25em 0.6em; border-radius: 3px;'
|
||||
}, running ? _('ACTIVE') : _('STOPPED'))
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-top: 0.5em;' }, [
|
||||
E('span', {}, _('Boot Start:')),
|
||||
E('span', {
|
||||
'class': 'badge',
|
||||
'id': 'fw-bouncer-enabled',
|
||||
'style': 'background: ' + (enabled ? '#17a2b8' : '#6c757d') + '; color: white; padding: 0.25em 0.6em; border-radius: 3px;'
|
||||
}, enabled ? _('ENABLED') : _('DISABLED'))
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-top: 0.5em;' }, [
|
||||
E('span', {}, _('Configured:')),
|
||||
E('span', {
|
||||
'class': 'badge',
|
||||
'style': 'background: ' + (configured ? '#28a745' : '#ffc107') + '; color: ' + (configured ? 'white' : '#333') + '; padding: 0.25em 0.6em; border-radius: 3px;'
|
||||
}, configured ? _('YES') : _('NO'))
|
||||
])
|
||||
]),
|
||||
|
||||
// Blocked IPs Card
|
||||
E('div', { 'style': 'background: #e8f4f8; border-left: 4px solid #0088cc; padding: 1em; border-radius: 4px;' }, [
|
||||
E('div', { 'style': 'font-weight: bold; margin-bottom: 0.5em; color: #333;' }, _('Blocked IPs')),
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center;' }, [
|
||||
E('span', {}, _('IPv4:')),
|
||||
E('span', {
|
||||
'id': 'fw-bouncer-ipv4-count',
|
||||
'style': 'font-size: 1.5em; color: #dc3545; font-weight: bold;'
|
||||
}, blockedIPv4.toString())
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-top: 0.5em;' }, [
|
||||
E('span', {}, _('IPv6:')),
|
||||
E('span', {
|
||||
'id': 'fw-bouncer-ipv6-count',
|
||||
'style': 'font-size: 1.5em; color: #dc3545; font-weight: bold;'
|
||||
}, blockedIPv6.toString())
|
||||
]),
|
||||
E('div', { 'style': 'margin-top: 0.75em; padding-top: 0.75em; border-top: 1px solid #d1e7f0;' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'style': 'width: 100%; font-size: 0.9em;',
|
||||
'click': L.bind(this.showNftablesDetails, this, nftStats)
|
||||
}, _('View Details'))
|
||||
])
|
||||
]),
|
||||
|
||||
// nftables Status Card
|
||||
E('div', { 'style': 'background: #fff3cd; border-left: 4px solid #ffc107; padding: 1em; border-radius: 4px;' }, [
|
||||
E('div', { 'style': 'font-weight: bold; margin-bottom: 0.5em; color: #333;' }, _('nftables Status')),
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center;' }, [
|
||||
E('span', {}, _('IPv4 Table:')),
|
||||
E('span', {
|
||||
'class': 'badge',
|
||||
'style': 'background: ' + (nftIPv4 ? '#28a745' : '#6c757d') + '; color: white; padding: 0.25em 0.6em; border-radius: 3px;'
|
||||
}, nftIPv4 ? _('ACTIVE') : _('INACTIVE'))
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-top: 0.5em;' }, [
|
||||
E('span', {}, _('IPv6 Table:')),
|
||||
E('span', {
|
||||
'class': 'badge',
|
||||
'style': 'background: ' + (nftIPv6 ? '#28a745' : '#6c757d') + '; color: white; padding: 0.25em 0.6em; border-radius: 3px;'
|
||||
}, nftIPv6 ? _('ACTIVE') : _('INACTIVE'))
|
||||
])
|
||||
])
|
||||
]),
|
||||
|
||||
// Control Buttons
|
||||
E('div', { 'style': 'display: flex; gap: 0.5em; flex-wrap: wrap;' }, [
|
||||
running ?
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative',
|
||||
'click': L.bind(this.handleFirewallBouncerControl, this, 'stop')
|
||||
}, _('Stop Service')) :
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'click': L.bind(this.handleFirewallBouncerControl, this, 'start')
|
||||
}, _('Start Service')),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': L.bind(this.handleFirewallBouncerControl, this, 'restart')
|
||||
}, _('Restart')),
|
||||
enabled ?
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': L.bind(this.handleFirewallBouncerControl, this, 'disable')
|
||||
}, _('Disable Boot Start')) :
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-apply',
|
||||
'click': L.bind(this.handleFirewallBouncerControl, this, 'enable')
|
||||
}, _('Enable Boot Start')),
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': L.bind(this.showFirewallBouncerConfig, this)
|
||||
}, _('Configuration'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
handleFirewallBouncerControl: function(action) {
|
||||
var actionLabels = {
|
||||
'start': _('Starting'),
|
||||
'stop': _('Stopping'),
|
||||
'restart': _('Restarting'),
|
||||
'enable': _('Enabling'),
|
||||
'disable': _('Disabling')
|
||||
};
|
||||
|
||||
ui.showModal(_('Firewall Bouncer Control'), [
|
||||
E('p', {}, _('%s firewall bouncer...').format(actionLabels[action] || action)),
|
||||
E('div', { 'class': 'spinning' })
|
||||
]);
|
||||
|
||||
return API.controlFirewallBouncer(action).then(L.bind(function(result) {
|
||||
ui.hideModal();
|
||||
|
||||
if (result && result.success) {
|
||||
ui.addNotification(null, E('p', result.message || _('Operation completed successfully')), 'info');
|
||||
this.handleFirewallBouncerRefresh();
|
||||
} else {
|
||||
ui.addNotification(null, E('p', result.error || _('Operation failed')), 'error');
|
||||
}
|
||||
}, this)).catch(function(err) {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', err.message || err), 'error');
|
||||
});
|
||||
},
|
||||
|
||||
handleFirewallBouncerRefresh: function() {
|
||||
return Promise.all([
|
||||
API.getFirewallBouncerStatus(),
|
||||
API.getNftablesStats()
|
||||
]).then(L.bind(function(data) {
|
||||
var fwStatus = data[0] || {};
|
||||
var nftStats = data[1] || {};
|
||||
|
||||
// Update status badges
|
||||
var statusBadge = document.getElementById('fw-bouncer-status');
|
||||
if (statusBadge) {
|
||||
var running = fwStatus.running || false;
|
||||
statusBadge.textContent = running ? _('ACTIVE') : _('STOPPED');
|
||||
statusBadge.style.background = running ? '#28a745' : '#dc3545';
|
||||
}
|
||||
|
||||
var enabledBadge = document.getElementById('fw-bouncer-enabled');
|
||||
if (enabledBadge) {
|
||||
var enabled = fwStatus.enabled || false;
|
||||
enabledBadge.textContent = enabled ? _('ENABLED') : _('DISABLED');
|
||||
enabledBadge.style.background = enabled ? '#17a2b8' : '#6c757d';
|
||||
}
|
||||
|
||||
// Update blocked IP counts
|
||||
var ipv4Count = document.getElementById('fw-bouncer-ipv4-count');
|
||||
if (ipv4Count) {
|
||||
ipv4Count.textContent = (fwStatus.blocked_ipv4 || 0).toString();
|
||||
}
|
||||
|
||||
var ipv6Count = document.getElementById('fw-bouncer-ipv6-count');
|
||||
if (ipv6Count) {
|
||||
ipv6Count.textContent = (fwStatus.blocked_ipv6 || 0).toString();
|
||||
}
|
||||
|
||||
// Re-render the entire card to update buttons
|
||||
var card = document.getElementById('firewall-bouncer-card');
|
||||
if (card) {
|
||||
dom.content(card, this.renderFirewallBouncerCard(fwStatus, nftStats).childNodes);
|
||||
}
|
||||
|
||||
ui.addNotification(null, E('p', _('Firewall bouncer status refreshed')), 'info');
|
||||
}, this)).catch(function(err) {
|
||||
ui.addNotification(null, E('p', _('Failed to refresh: %s').format(err.message || err)), 'error');
|
||||
});
|
||||
},
|
||||
|
||||
showNftablesDetails: function(nftStats) {
|
||||
var ipv4Blocked = nftStats.ipv4_blocked || [];
|
||||
var ipv6Blocked = nftStats.ipv6_blocked || [];
|
||||
var ipv4Rules = nftStats.ipv4_rules || 0;
|
||||
var ipv6Rules = nftStats.ipv6_rules || 0;
|
||||
|
||||
ui.showModal(_('nftables Blocked IPs'), [
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h4', {}, _('IPv4 Blocked Addresses (%d)').format(ipv4Blocked.length)),
|
||||
ipv4Blocked.length > 0 ?
|
||||
E('div', { 'style': 'max-height: 200px; overflow-y: auto; background: #f5f5f5; padding: 0.5em; border-radius: 4px; margin-bottom: 1em;' },
|
||||
ipv4Blocked.map(function(ip) {
|
||||
return E('div', { 'style': 'font-family: monospace; padding: 0.25em 0;' }, ip);
|
||||
})
|
||||
) :
|
||||
E('p', { 'style': 'color: #999; margin-bottom: 1em;' }, _('No IPv4 addresses blocked')),
|
||||
|
||||
E('h4', {}, _('IPv6 Blocked Addresses (%d)').format(ipv6Blocked.length)),
|
||||
ipv6Blocked.length > 0 ?
|
||||
E('div', { 'style': 'max-height: 200px; overflow-y: auto; background: #f5f5f5; padding: 0.5em; border-radius: 4px; margin-bottom: 1em;' },
|
||||
ipv6Blocked.map(function(ip) {
|
||||
return E('div', { 'style': 'font-family: monospace; padding: 0.25em 0;' }, ip);
|
||||
})
|
||||
) :
|
||||
E('p', { 'style': 'color: #999; margin-bottom: 1em;' }, _('No IPv6 addresses blocked')),
|
||||
|
||||
E('div', { 'style': 'background: #e8f4f8; padding: 1em; border-radius: 4px;' }, [
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; margin-bottom: 0.5em;' }, [
|
||||
E('strong', {}, _('IPv4 Rules:')),
|
||||
E('span', {}, ipv4Rules.toString())
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between;' }, [
|
||||
E('strong', {}, _('IPv6 Rules:')),
|
||||
E('span', {}, ipv6Rules.toString())
|
||||
])
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': ui.hideModal
|
||||
}, _('Close'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
showFirewallBouncerConfig: function() {
|
||||
ui.showModal(_('Loading Configuration...'), [
|
||||
E('div', { 'class': 'spinning' })
|
||||
]);
|
||||
|
||||
return API.getFirewallBouncerConfig().then(function(config) {
|
||||
if (!config.configured) {
|
||||
ui.hideModal();
|
||||
ui.showModal(_('Firewall Bouncer Configuration'), [
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('p', { 'style': 'color: #ffc107; font-weight: bold;' },
|
||||
_('⚠️ Firewall bouncer is not configured yet.')),
|
||||
E('p', {},
|
||||
_('Please install the secubox-app-crowdsec-bouncer package to configure the firewall bouncer.'))
|
||||
]),
|
||||
E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': ui.hideModal
|
||||
}, _('Close'))
|
||||
])
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
ui.hideModal();
|
||||
ui.showModal(_('Firewall Bouncer Configuration'), [
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Enabled')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('span', {
|
||||
'class': 'badge',
|
||||
'style': 'background: ' + (config.enabled === '1' ? '#28a745' : '#dc3545') + '; color: white; padding: 0.25em 0.6em;'
|
||||
}, config.enabled === '1' ? _('YES') : _('NO'))
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('IPv4 Support')),
|
||||
E('div', { 'class': 'cbi-value-field' }, config.ipv4 === '1' ? _('Enabled') : _('Disabled'))
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('IPv6 Support')),
|
||||
E('div', { 'class': 'cbi-value-field' }, config.ipv6 === '1' ? _('Enabled') : _('Disabled'))
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('API URL')),
|
||||
E('div', { 'class': 'cbi-value-field' }, E('code', {}, config.api_url || 'N/A'))
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Update Frequency')),
|
||||
E('div', { 'class': 'cbi-value-field' }, config.update_frequency || 'N/A')
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Deny Action')),
|
||||
E('div', { 'class': 'cbi-value-field' }, config.deny_action || 'drop')
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Deny Logging')),
|
||||
E('div', { 'class': 'cbi-value-field' }, config.deny_log === '1' ? _('Enabled') : _('Disabled'))
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Log Prefix')),
|
||||
E('div', { 'class': 'cbi-value-field' }, E('code', {}, config.log_prefix || 'N/A'))
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Interfaces')),
|
||||
E('div', { 'class': 'cbi-value-field' },
|
||||
config.interfaces && config.interfaces.length > 0 ?
|
||||
config.interfaces.join(', ') :
|
||||
_('None configured')
|
||||
)
|
||||
]),
|
||||
E('div', { 'class': 'cbi-section', 'style': 'background: #e8f4f8; padding: 1em; margin-top: 1em; border-radius: 4px;' }, [
|
||||
E('p', { 'style': 'margin: 0;' }, [
|
||||
E('strong', {}, _('Note:')),
|
||||
' ',
|
||||
_('To modify these settings, edit /etc/config/crowdsec using UCI commands or the configuration file.')
|
||||
])
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': ui.hideModal
|
||||
}, _('Close'))
|
||||
])
|
||||
]);
|
||||
}).catch(function(err) {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', _('Failed to load configuration: %s').format(err.message || err)), 'error');
|
||||
});
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
|
||||
Loading…
Reference in New Issue
Block a user