Fixes: - HAProxy: Prevent duplicate server names when both inline and separate server UCI sections exist for same backend - Streamlit: Force --server.headless=true in start script (required for server) - Dashboard: Optimize get_dashboard_data RPC call (6.56s → 0.09s) by using fast catalog counting instead of slow appstore list command - Exposure: Add themed dashboard with SecuBox styling - ACL: Add missing RPCD permissions for various LuCI apps Version bumps: - luci-app-exposure: 1.0.0-r3 - secubox-core: 0.10.0-r5 - secubox-app-haproxy: 1.0.0-r18 - secubox-app-streamlit: 1.0.0-r2 - Portal: v0.15.51 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1092 lines
50 KiB
JavaScript
1092 lines
50 KiB
JavaScript
'use strict';
|
|
'require view';
|
|
'require dom';
|
|
'require ui';
|
|
'require poll';
|
|
'require exposure/api as api';
|
|
|
|
/**
|
|
* SecuBox Service Exposure Manager - Overview Dashboard
|
|
* Manages Tor Hidden Services and HAProxy SSL backends
|
|
* Progressive loading with debug console output
|
|
* Copyright (C) 2025 CyberMind.fr
|
|
*/
|
|
|
|
return view.extend({
|
|
title: _('Service Exposure Manager'),
|
|
|
|
data: null,
|
|
pollRegistered: false,
|
|
loadStartTime: null,
|
|
DEBUG: true,
|
|
|
|
// === Debug Logging with timestamps ===
|
|
log: function(step, message, data) {
|
|
if (!this.DEBUG) return;
|
|
var elapsed = this.loadStartTime ? (Date.now() - this.loadStartTime) + 'ms' : '0ms';
|
|
var prefix = '%c[Exposure ' + elapsed + '] %c' + step + '%c';
|
|
if (data !== undefined) {
|
|
console.log(prefix, 'color: #64ffda; font-weight: bold;', 'color: #9b59b6; font-weight: bold;', 'color: #8892b0;', message, data);
|
|
} else {
|
|
console.log(prefix, 'color: #64ffda; font-weight: bold;', 'color: #9b59b6; font-weight: bold;', 'color: #8892b0;', message);
|
|
}
|
|
},
|
|
|
|
load: function() {
|
|
var self = this;
|
|
this.loadStartTime = Date.now();
|
|
this.log('INIT', 'Starting dashboard load');
|
|
|
|
// Load CSS first
|
|
var cssLink = document.querySelector('link[href*="exposure/dashboard.css"]');
|
|
if (!cssLink) {
|
|
var link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = L.resource('exposure/dashboard.css');
|
|
document.head.appendChild(link);
|
|
this.log('CSS', 'Stylesheet injected');
|
|
}
|
|
|
|
// Progressive data loading with individual error handling
|
|
return this.loadDataProgressively();
|
|
},
|
|
|
|
loadDataProgressively: function() {
|
|
var self = this;
|
|
|
|
// Initialize result container
|
|
var result = {
|
|
status: { services: {}, tor: {}, ssl: {} },
|
|
scan: { services: [] },
|
|
conflicts: { conflicts: [] },
|
|
tor: { services: [] },
|
|
ssl: { backends: [] }
|
|
};
|
|
|
|
// Create promises with individual timing and error handling
|
|
// Priority: status > ssl > tor > scan > conflicts
|
|
|
|
self.log('FETCH', '1/5 Status (priority: critical)');
|
|
var p1 = api.status().then(function(data) {
|
|
result.status = data || result.status;
|
|
self.log('STATUS', 'Complete', {
|
|
total: (result.status.services || {}).total || 0,
|
|
external: (result.status.services || {}).external || 0,
|
|
tor: (result.status.tor || {}).count || 0,
|
|
ssl: (result.status.ssl || {}).count || 0
|
|
});
|
|
return data;
|
|
}).catch(function(err) {
|
|
self.log('ERROR', 'Status fetch failed: ' + err.message);
|
|
return result.status;
|
|
});
|
|
|
|
self.log('FETCH', '2/5 SSL Backends (priority: high)');
|
|
var p2 = api.sslList().then(function(data) {
|
|
result.ssl = data || result.ssl;
|
|
var backends = result.ssl.backends || [];
|
|
self.log('SSL', 'Complete', {
|
|
count: backends.length,
|
|
domains: backends.map(function(b) { return b.domain; })
|
|
});
|
|
return data;
|
|
}).catch(function(err) {
|
|
self.log('ERROR', 'SSL list failed: ' + err.message);
|
|
return result.ssl;
|
|
});
|
|
|
|
self.log('FETCH', '3/5 Tor Services (priority: high)');
|
|
var p3 = api.torList().then(function(data) {
|
|
result.tor = data || result.tor;
|
|
var services = result.tor.services || [];
|
|
self.log('TOR', 'Complete', {
|
|
count: services.length,
|
|
onions: services.map(function(s) { return s.service + ': ' + (s.onion || '').substring(0, 16) + '...'; })
|
|
});
|
|
return data;
|
|
}).catch(function(err) {
|
|
self.log('ERROR', 'Tor list failed: ' + err.message);
|
|
return result.tor;
|
|
});
|
|
|
|
self.log('FETCH', '4/5 Service Scan (priority: medium)');
|
|
var p4 = api.scan().then(function(data) {
|
|
result.scan = data || result.scan;
|
|
var services = Array.isArray(data) ? data : (data.services || []);
|
|
var external = services.filter(function(s) { return s.external; });
|
|
self.log('SCAN', 'Complete', {
|
|
total: services.length,
|
|
external: external.length,
|
|
processes: external.slice(0, 5).map(function(s) { return s.name || s.process; })
|
|
});
|
|
return data;
|
|
}).catch(function(err) {
|
|
self.log('ERROR', 'Scan failed: ' + err.message);
|
|
return result.scan;
|
|
});
|
|
|
|
self.log('FETCH', '5/5 Conflicts (priority: low)');
|
|
var p5 = api.conflicts().then(function(data) {
|
|
result.conflicts = data || result.conflicts;
|
|
var conflicts = Array.isArray(data) ? data : (data.conflicts || []);
|
|
self.log('CONFLICTS', 'Complete', { count: conflicts.length });
|
|
return data;
|
|
}).catch(function(err) {
|
|
self.log('ERROR', 'Conflicts check failed: ' + err.message);
|
|
return result.conflicts;
|
|
});
|
|
|
|
// Wait for all with graceful degradation
|
|
return Promise.all([p1, p2, p3, p4, p5]).then(function() {
|
|
var totalTime = Date.now() - self.loadStartTime;
|
|
self.log('LOADED', 'All data fetched in ' + totalTime + 'ms');
|
|
return [result.status, result.scan, result.conflicts, result.tor, result.ssl];
|
|
});
|
|
},
|
|
|
|
render: function(data) {
|
|
var self = this;
|
|
var renderStart = Date.now();
|
|
this.log('RENDER', 'Starting DOM construction');
|
|
|
|
var status = data[0] || {};
|
|
var scanResult = data[1] || {};
|
|
var conflictsResult = data[2] || {};
|
|
var torResult = data[3] || {};
|
|
var sslResult = data[4] || {};
|
|
|
|
// Normalize data
|
|
this.log('RENDER', 'Normalizing data structures');
|
|
var services = Array.isArray(scanResult) ? scanResult : (scanResult.services || []);
|
|
var conflicts = Array.isArray(conflictsResult) ? conflictsResult : (conflictsResult.conflicts || []);
|
|
var torServices = Array.isArray(torResult) ? torResult : (torResult.services || []);
|
|
var sslBackends = Array.isArray(sslResult) ? sslResult : (sslResult.backends || []);
|
|
|
|
this.log('DATA', 'Final counts', {
|
|
services: services.length,
|
|
conflicts: conflicts.length,
|
|
tor: torServices.length,
|
|
ssl: sslBackends.length
|
|
});
|
|
|
|
var exposableServices = services.filter(function(svc) { return svc.external; });
|
|
|
|
// Build exposure lookup maps
|
|
var exposedTor = {};
|
|
var exposedSsl = {};
|
|
torServices.forEach(function(t) { exposedTor[t.service] = t; });
|
|
sslBackends.forEach(function(s) { exposedSsl[s.service] = s; });
|
|
|
|
// Find unexposed services (suggestions)
|
|
var suggestions = exposableServices.filter(function(svc) {
|
|
var name = self.getServiceName(svc);
|
|
return !exposedTor[name] && !exposedSsl[name];
|
|
});
|
|
|
|
this.log('SUGGESTIONS', 'Unexposed services found', {
|
|
count: suggestions.length,
|
|
names: suggestions.slice(0, 5).map(function(s) { return s.name || s.process; })
|
|
});
|
|
|
|
// Build view content progressively with timing
|
|
var content = [];
|
|
|
|
this.log('DOM', '1/6 Page header');
|
|
content.push(this.renderPageHeader(status));
|
|
|
|
if (conflicts.length > 0) {
|
|
this.log('DOM', '2/6 Conflicts banner (active)');
|
|
content.push(this.renderConflictsBanner(conflicts));
|
|
} else {
|
|
this.log('DOM', '2/6 Conflicts banner (skipped - none)');
|
|
}
|
|
|
|
this.log('DOM', '3/6 Stats grid');
|
|
content.push(this.renderStatsGrid(status, torServices, sslBackends, exposableServices));
|
|
|
|
if (suggestions.length > 0) {
|
|
this.log('DOM', '4/6 Suggestions card (active)');
|
|
content.push(this.renderSuggestionsCard(suggestions));
|
|
} else {
|
|
this.log('DOM', '4/6 Suggestions card (skipped - none)');
|
|
}
|
|
|
|
this.log('DOM', '5/6 Service cards row');
|
|
content.push(E('div', { 'class': 'exp-row' }, [
|
|
E('div', { 'style': 'flex: 1' }, [
|
|
this.renderTorServicesCard(torServices)
|
|
]),
|
|
E('div', { 'style': 'flex: 1' }, [
|
|
this.renderSslBackendsCard(sslBackends)
|
|
])
|
|
]));
|
|
|
|
this.log('DOM', '6/6 Quick actions');
|
|
content.push(this.renderQuickActions());
|
|
|
|
// Filter nulls
|
|
content = content.filter(Boolean);
|
|
|
|
// Main wrapper with animation
|
|
var view = E('div', { 'class': 'exposure-dashboard exp-fade-in' }, content);
|
|
|
|
// Setup polling
|
|
if (!this.pollRegistered) {
|
|
this.pollRegistered = true;
|
|
this.log('POLL', 'Auto-refresh registered (30s interval)');
|
|
poll.add(function() {
|
|
self.log('POLL', 'Refreshing dashboard...');
|
|
return self.refreshDashboard();
|
|
}, 30);
|
|
}
|
|
|
|
var renderTime = Date.now() - renderStart;
|
|
var totalTime = Date.now() - this.loadStartTime;
|
|
this.log('COMPLETE', 'Dashboard ready', {
|
|
renderTime: renderTime + 'ms',
|
|
totalTime: totalTime + 'ms'
|
|
});
|
|
|
|
return view;
|
|
},
|
|
|
|
getServiceName: function(svc) {
|
|
var name = svc.name ? svc.name.toLowerCase().replace(/\s+/g, '') : svc.process;
|
|
return name.replace(/[^a-z0-9]/g, '');
|
|
},
|
|
|
|
renderPageHeader: function(status) {
|
|
var servicesData = status.services || {};
|
|
var torData = status.tor || {};
|
|
var sslData = status.ssl || {};
|
|
|
|
return E('div', { 'class': 'exp-page-header' }, [
|
|
E('div', {}, [
|
|
E('h1', { 'class': 'exp-page-title' }, [
|
|
E('span', { 'class': 'exp-page-title-icon' }, '\uD83D\uDD0C'),
|
|
'Service Exposure Manager'
|
|
]),
|
|
E('p', { 'class': 'exp-page-subtitle' },
|
|
'Expose local services via Tor Hidden Services (.onion) or HAProxy SSL reverse proxy')
|
|
]),
|
|
E('div', { 'class': 'exp-header-badges' }, [
|
|
E('div', { 'class': 'exp-header-badge' }, [
|
|
E('span', { 'style': 'color: #64ffda;' }, String(servicesData.external || 0)),
|
|
' Exposable'
|
|
]),
|
|
E('div', { 'class': 'exp-header-badge' }, [
|
|
E('span', { 'style': 'color: #9b59b6;' }, String(torData.count || 0)),
|
|
' Tor'
|
|
]),
|
|
E('div', { 'class': 'exp-header-badge' }, [
|
|
E('span', { 'style': 'color: #27ae60;' }, String(sslData.count || 0)),
|
|
' SSL'
|
|
])
|
|
])
|
|
]);
|
|
},
|
|
|
|
renderConflictsBanner: function(conflicts) {
|
|
return E('div', { 'class': 'exp-card exp-warning-card' }, [
|
|
E('div', { 'class': 'exp-card-body', 'style': 'display: flex; align-items: center; gap: 16px;' }, [
|
|
E('span', { 'style': 'font-size: 32px;' }, '\u26A0\uFE0F'),
|
|
E('div', { 'style': 'flex: 1;' }, [
|
|
E('div', { 'style': 'font-weight: 600; font-size: 16px; margin-bottom: 4px; color: #f39c12;' },
|
|
conflicts.length + ' Port Conflict(s) Detected'),
|
|
E('div', { 'style': 'color: #8892b0;' },
|
|
conflicts.map(function(c) {
|
|
return 'Port ' + c.port + ': ' + (c.services || []).join(', ');
|
|
}).join(' | '))
|
|
])
|
|
])
|
|
]);
|
|
},
|
|
|
|
renderStatsGrid: function(status, torServices, sslBackends, exposableServices) {
|
|
var servicesData = status.services || {};
|
|
|
|
return E('div', { 'class': 'exp-stats-grid' }, [
|
|
E('div', { 'class': 'exp-stat-card' }, [
|
|
E('div', { 'class': 'exp-stat-icon' }, '\uD83D\uDD0C'),
|
|
E('div', { 'class': 'exp-stat-value' }, String(servicesData.total || 0)),
|
|
E('div', { 'class': 'exp-stat-label' }, 'Total Services'),
|
|
E('div', { 'class': 'exp-stat-trend' }, (servicesData.external || 0) + ' external')
|
|
]),
|
|
E('div', { 'class': 'exp-stat-card exp-stat-tor' }, [
|
|
E('div', { 'class': 'exp-stat-icon' }, '\uD83E\uDDC5'),
|
|
E('div', { 'class': 'exp-stat-value' }, String(torServices.length)),
|
|
E('div', { 'class': 'exp-stat-label' }, 'Tor Hidden Services'),
|
|
E('div', { 'class': 'exp-stat-trend' }, torServices.length > 0 ? 'Active' : 'None configured')
|
|
]),
|
|
E('div', { 'class': 'exp-stat-card exp-stat-ssl' }, [
|
|
E('div', { 'class': 'exp-stat-icon' }, '\uD83D\uDD12'),
|
|
E('div', { 'class': 'exp-stat-value' }, String(sslBackends.length)),
|
|
E('div', { 'class': 'exp-stat-label' }, 'SSL Backends'),
|
|
E('div', { 'class': 'exp-stat-trend' }, sslBackends.length > 0 ? 'HAProxy' : 'None configured')
|
|
]),
|
|
E('div', { 'class': 'exp-stat-card' }, [
|
|
E('div', { 'class': 'exp-stat-icon' }, '\uD83D\uDCA1'),
|
|
E('div', { 'class': 'exp-stat-value' }, String(exposableServices.length)),
|
|
E('div', { 'class': 'exp-stat-label' }, 'Exposable'),
|
|
E('div', { 'class': 'exp-stat-trend' }, 'Ready for exposure')
|
|
])
|
|
]);
|
|
},
|
|
|
|
renderSuggestionsCard: function(suggestions) {
|
|
var self = this;
|
|
|
|
// Prioritize interesting services
|
|
var prioritized = suggestions.sort(function(a, b) {
|
|
var priority = {
|
|
'streamlit': 1, 'Streamlit': 1,
|
|
'gitea': 2, 'Gitea': 2,
|
|
'hexo': 3, 'HexoJS': 3,
|
|
'nextcloud': 4,
|
|
'jupyter': 5,
|
|
'flask': 6,
|
|
'django': 7
|
|
};
|
|
var pA = priority[a.name] || priority[a.process] || 100;
|
|
var pB = priority[b.name] || priority[b.process] || 100;
|
|
return pA - pB;
|
|
}).slice(0, 6);
|
|
|
|
return E('div', { 'class': 'exp-card exp-suggestions-card' }, [
|
|
E('div', { 'class': 'exp-card-header' }, [
|
|
E('div', { 'class': 'exp-card-title' }, [
|
|
E('span', { 'class': 'exp-card-title-icon' }, '\uD83D\uDCA1'),
|
|
'Suggested Services to Expose'
|
|
]),
|
|
E('a', { 'href': L.url('admin/secubox/network/exposure/services'), 'class': 'exp-btn exp-btn-secondary exp-btn-sm' },
|
|
'View All')
|
|
]),
|
|
E('div', { 'class': 'exp-card-body' }, [
|
|
E('div', { 'class': 'exp-suggestions-grid' }, prioritized.map(function(svc) {
|
|
var serviceName = self.getServiceName(svc);
|
|
var icon = self.getServiceIcon(svc);
|
|
|
|
return E('div', { 'class': 'exp-suggestion-item' }, [
|
|
E('div', { 'class': 'exp-suggestion-icon' }, icon),
|
|
E('div', { 'class': 'exp-suggestion-info' }, [
|
|
E('div', { 'class': 'exp-suggestion-name' }, svc.name || svc.process),
|
|
E('div', { 'class': 'exp-suggestion-port' }, 'Port ' + svc.port)
|
|
]),
|
|
E('div', { 'class': 'exp-suggestion-actions' }, [
|
|
E('button', {
|
|
'class': 'exp-btn exp-btn-tor exp-btn-xs',
|
|
'title': 'Add Tor Hidden Service',
|
|
'click': function() { self.handleAddTor(svc, serviceName); }
|
|
}, '\uD83E\uDDC5'),
|
|
E('button', {
|
|
'class': 'exp-btn exp-btn-ssl exp-btn-xs',
|
|
'title': 'Add SSL Backend',
|
|
'click': function() { self.handleAddSsl(svc, serviceName); }
|
|
}, '\uD83D\uDD12')
|
|
])
|
|
]);
|
|
}))
|
|
])
|
|
]);
|
|
},
|
|
|
|
getServiceIcon: function(svc) {
|
|
var iconMap = {
|
|
'streamlit': '\uD83D\uDCCA',
|
|
'Streamlit': '\uD83D\uDCCA',
|
|
'gitea': '\uD83D\uDC19',
|
|
'Gitea': '\uD83D\uDC19',
|
|
'hexo': '\uD83D\uDCDD',
|
|
'HexoJS': '\uD83D\uDCDD',
|
|
'jupyter': '\uD83D\uDCD3',
|
|
'flask': '\uD83C\uDF76',
|
|
'django': '\uD83E\uDD8E',
|
|
'nextcloud': '\u2601\uFE0F',
|
|
'SSH': '\uD83D\uDD11',
|
|
'HAProxy': '\u2696\uFE0F',
|
|
'DNS': '\uD83C\uDF10',
|
|
'LuCI': '\uD83D\uDDA5\uFE0F',
|
|
'python': '\uD83D\uDC0D'
|
|
};
|
|
return iconMap[svc.name] || iconMap[svc.process] || '\uD83D\uDD0C';
|
|
},
|
|
|
|
renderTorServicesCard: function(torServices) {
|
|
var self = this;
|
|
|
|
if (torServices.length === 0) {
|
|
return E('div', { 'class': 'exp-card' }, [
|
|
E('div', { 'class': 'exp-card-header' }, [
|
|
E('div', { 'class': 'exp-card-title' }, [
|
|
E('span', { 'class': 'exp-card-title-icon' }, '\uD83E\uDDC5'),
|
|
'Tor Hidden Services'
|
|
]),
|
|
E('a', { 'href': L.url('admin/secubox/network/exposure/tor'), 'class': 'exp-btn exp-btn-tor exp-btn-sm' },
|
|
'+ Add')
|
|
]),
|
|
E('div', { 'class': 'exp-card-body' }, [
|
|
E('div', { 'class': 'exp-empty' }, [
|
|
E('div', { 'class': 'exp-empty-icon' }, '\uD83E\uDDC5'),
|
|
E('div', { 'class': 'exp-empty-text' }, 'No Tor hidden services'),
|
|
E('div', { 'class': 'exp-empty-hint' }, 'Services are accessible via .onion addresses')
|
|
])
|
|
])
|
|
]);
|
|
}
|
|
|
|
var items = torServices.slice(0, 5).map(function(svc) {
|
|
var onion = svc.onion || '';
|
|
var shortOnion = onion.length > 30 ? onion.substring(0, 28) + '...' : onion;
|
|
|
|
return E('div', { 'class': 'exp-service-item' }, [
|
|
E('div', { 'class': 'exp-service-icon' }, '\uD83E\uDDC5'),
|
|
E('div', { 'class': 'exp-service-info' }, [
|
|
E('div', { 'class': 'exp-service-name' }, svc.service),
|
|
E('div', { 'class': 'exp-service-detail exp-onion' }, shortOnion)
|
|
]),
|
|
E('button', {
|
|
'class': 'exp-btn exp-btn-danger exp-btn-xs',
|
|
'title': 'Remove',
|
|
'click': function() { self.handleRemoveTor(svc.service); }
|
|
}, '\u2715')
|
|
]);
|
|
});
|
|
|
|
var cardBody = [E('div', { 'class': 'exp-services-list' }, items)];
|
|
|
|
if (torServices.length > 5) {
|
|
cardBody.push(E('div', { 'style': 'text-align: center; margin-top: 12px;' },
|
|
E('a', { 'href': L.url('admin/secubox/network/exposure/tor') },
|
|
'+' + (torServices.length - 5) + ' more')));
|
|
}
|
|
|
|
return E('div', { 'class': 'exp-card' }, [
|
|
E('div', { 'class': 'exp-card-header' }, [
|
|
E('div', { 'class': 'exp-card-title' }, [
|
|
E('span', { 'class': 'exp-card-title-icon' }, '\uD83E\uDDC5'),
|
|
'Tor Hidden Services (' + torServices.length + ')'
|
|
]),
|
|
E('a', { 'href': L.url('admin/secubox/network/exposure/tor'), 'class': 'exp-btn exp-btn-secondary exp-btn-sm' },
|
|
'Manage')
|
|
]),
|
|
E('div', { 'class': 'exp-card-body' }, cardBody)
|
|
]);
|
|
},
|
|
|
|
renderSslBackendsCard: function(sslBackends) {
|
|
var self = this;
|
|
|
|
if (sslBackends.length === 0) {
|
|
return E('div', { 'class': 'exp-card' }, [
|
|
E('div', { 'class': 'exp-card-header' }, [
|
|
E('div', { 'class': 'exp-card-title' }, [
|
|
E('span', { 'class': 'exp-card-title-icon' }, '\uD83D\uDD12'),
|
|
'SSL Backends'
|
|
]),
|
|
E('a', { 'href': L.url('admin/secubox/network/exposure/ssl'), 'class': 'exp-btn exp-btn-ssl exp-btn-sm' },
|
|
'+ Add')
|
|
]),
|
|
E('div', { 'class': 'exp-card-body' }, [
|
|
E('div', { 'class': 'exp-empty' }, [
|
|
E('div', { 'class': 'exp-empty-icon' }, '\uD83D\uDD12'),
|
|
E('div', { 'class': 'exp-empty-text' }, 'No SSL backends'),
|
|
E('div', { 'class': 'exp-empty-hint' }, 'Add HTTPS reverse proxy via HAProxy')
|
|
])
|
|
])
|
|
]);
|
|
}
|
|
|
|
var items = sslBackends.slice(0, 5).map(function(backend) {
|
|
return E('div', { 'class': 'exp-service-item' }, [
|
|
E('div', { 'class': 'exp-service-icon' }, '\uD83D\uDD12'),
|
|
E('div', { 'class': 'exp-service-info' }, [
|
|
E('div', { 'class': 'exp-service-name' }, backend.service),
|
|
E('div', { 'class': 'exp-service-detail exp-domain' }, backend.domain)
|
|
]),
|
|
E('button', {
|
|
'class': 'exp-btn exp-btn-danger exp-btn-xs',
|
|
'title': 'Remove',
|
|
'click': function() { self.handleRemoveSsl(backend.service); }
|
|
}, '\u2715')
|
|
]);
|
|
});
|
|
|
|
var cardBody = [E('div', { 'class': 'exp-services-list' }, items)];
|
|
|
|
if (sslBackends.length > 5) {
|
|
cardBody.push(E('div', { 'style': 'text-align: center; margin-top: 12px;' },
|
|
E('a', { 'href': L.url('admin/secubox/network/exposure/ssl') },
|
|
'+' + (sslBackends.length - 5) + ' more')));
|
|
}
|
|
|
|
return E('div', { 'class': 'exp-card' }, [
|
|
E('div', { 'class': 'exp-card-header' }, [
|
|
E('div', { 'class': 'exp-card-title' }, [
|
|
E('span', { 'class': 'exp-card-title-icon' }, '\uD83D\uDD12'),
|
|
'SSL Backends (' + sslBackends.length + ')'
|
|
]),
|
|
E('a', { 'href': L.url('admin/secubox/network/exposure/ssl'), 'class': 'exp-btn exp-btn-secondary exp-btn-sm' },
|
|
'Manage')
|
|
]),
|
|
E('div', { 'class': 'exp-card-body' }, cardBody)
|
|
]);
|
|
},
|
|
|
|
renderQuickActions: function() {
|
|
var self = this;
|
|
|
|
return E('div', { 'class': 'exp-card' }, [
|
|
E('div', { 'class': 'exp-card-header' }, [
|
|
E('div', { 'class': 'exp-card-title' }, [
|
|
E('span', { 'class': 'exp-card-title-icon' }, '\u26A1'),
|
|
'Quick Actions'
|
|
])
|
|
]),
|
|
E('div', { 'class': 'exp-card-body' }, [
|
|
E('div', { 'class': 'exp-quick-actions' }, [
|
|
E('a', {
|
|
'href': L.url('admin/secubox/network/exposure/services'),
|
|
'class': 'exp-action-btn'
|
|
}, [
|
|
E('span', { 'class': 'exp-action-icon' }, '\uD83D\uDD0C'),
|
|
E('span', { 'class': 'exp-action-label' }, 'All Services')
|
|
]),
|
|
E('a', {
|
|
'href': L.url('admin/secubox/network/exposure/tor'),
|
|
'class': 'exp-action-btn'
|
|
}, [
|
|
E('span', { 'class': 'exp-action-icon' }, '\uD83E\uDDC5'),
|
|
E('span', { 'class': 'exp-action-label' }, 'Tor Services')
|
|
]),
|
|
E('a', {
|
|
'href': L.url('admin/secubox/network/exposure/ssl'),
|
|
'class': 'exp-action-btn'
|
|
}, [
|
|
E('span', { 'class': 'exp-action-icon' }, '\uD83D\uDD12'),
|
|
E('span', { 'class': 'exp-action-label' }, 'SSL Backends')
|
|
]),
|
|
E('button', {
|
|
'class': 'exp-action-btn',
|
|
'click': function() { self.refreshDashboard(); }
|
|
}, [
|
|
E('span', { 'class': 'exp-action-icon' }, '\uD83D\uDD04'),
|
|
E('span', { 'class': 'exp-action-label' }, 'Refresh')
|
|
])
|
|
])
|
|
])
|
|
]);
|
|
},
|
|
|
|
// === Progress Modal Helpers ===
|
|
|
|
createProgressModal: function(title, steps) {
|
|
var stepsContainer = E('div', { 'class': 'exp-progress-steps', 'id': 'progress-steps' });
|
|
|
|
steps.forEach(function(step, index) {
|
|
stepsContainer.appendChild(E('div', {
|
|
'class': 'exp-progress-step',
|
|
'id': 'step-' + index,
|
|
'data-status': 'pending'
|
|
}, [
|
|
E('div', { 'class': 'exp-step-indicator' }, [
|
|
E('span', { 'class': 'exp-step-number' }, String(index + 1)),
|
|
E('span', { 'class': 'exp-step-icon' })
|
|
]),
|
|
E('div', { 'class': 'exp-step-content' }, [
|
|
E('div', { 'class': 'exp-step-label' }, step.label),
|
|
E('div', { 'class': 'exp-step-detail', 'id': 'step-detail-' + index }, step.detail || '')
|
|
])
|
|
]));
|
|
});
|
|
|
|
return E('div', { 'class': 'exp-progress-modal' }, [
|
|
E('div', { 'class': 'exp-progress-header' }, [
|
|
E('div', { 'class': 'exp-progress-title' }, title),
|
|
E('div', { 'class': 'exp-progress-subtitle', 'id': 'progress-subtitle' }, 'Initializing...')
|
|
]),
|
|
stepsContainer,
|
|
E('div', { 'class': 'exp-progress-result', 'id': 'progress-result', 'style': 'display: none;' })
|
|
]);
|
|
},
|
|
|
|
updateStep: function(index, status, detail) {
|
|
var stepEl = document.getElementById('step-' + index);
|
|
var detailEl = document.getElementById('step-detail-' + index);
|
|
var subtitleEl = document.getElementById('progress-subtitle');
|
|
|
|
if (stepEl) {
|
|
stepEl.setAttribute('data-status', status);
|
|
if (detail && detailEl) {
|
|
detailEl.textContent = detail;
|
|
}
|
|
}
|
|
|
|
if (subtitleEl && status === 'active') {
|
|
var labelEl = stepEl ? stepEl.querySelector('.exp-step-label') : null;
|
|
if (labelEl) {
|
|
subtitleEl.textContent = labelEl.textContent + '...';
|
|
}
|
|
}
|
|
},
|
|
|
|
showProgressResult: function(success, message, details) {
|
|
var resultEl = document.getElementById('progress-result');
|
|
var subtitleEl = document.getElementById('progress-subtitle');
|
|
|
|
if (resultEl) {
|
|
resultEl.style.display = 'block';
|
|
resultEl.className = 'exp-progress-result ' + (success ? 'success' : 'error');
|
|
resultEl.innerHTML = '';
|
|
resultEl.appendChild(E('div', { 'class': 'exp-result-icon' }, success ? '\u2705' : '\u274C'));
|
|
resultEl.appendChild(E('div', { 'class': 'exp-result-message' }, message));
|
|
if (details) {
|
|
resultEl.appendChild(E('div', { 'class': 'exp-result-details' }, details));
|
|
}
|
|
}
|
|
|
|
if (subtitleEl) {
|
|
subtitleEl.textContent = success ? 'Completed successfully' : 'Operation failed';
|
|
}
|
|
},
|
|
|
|
// === Action Handlers ===
|
|
|
|
handleAddTor: function(svc, serviceName) {
|
|
var self = this;
|
|
|
|
ui.showModal('Add Tor Hidden Service', [
|
|
E('p', {}, 'Create a .onion address for ' + (svc.name || svc.process)),
|
|
E('div', { 'style': 'margin: 1rem 0;' }, [
|
|
E('div', { 'style': 'margin-bottom: 0.75rem;' }, [
|
|
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Service Name'),
|
|
E('input', {
|
|
'type': 'text',
|
|
'id': 'tor-svc-name',
|
|
'value': serviceName,
|
|
'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;'
|
|
})
|
|
]),
|
|
E('div', { 'style': 'margin-bottom: 0.75rem;' }, [
|
|
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Local Port'),
|
|
E('input', {
|
|
'type': 'number',
|
|
'id': 'tor-local-port',
|
|
'value': svc.port,
|
|
'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;'
|
|
})
|
|
]),
|
|
E('div', {}, [
|
|
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Onion Port (public)'),
|
|
E('input', {
|
|
'type': 'number',
|
|
'id': 'tor-onion-port',
|
|
'value': '80',
|
|
'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;'
|
|
})
|
|
])
|
|
]),
|
|
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [
|
|
E('button', {
|
|
'class': 'btn',
|
|
'click': ui.hideModal
|
|
}, 'Cancel'),
|
|
E('button', {
|
|
'class': 'btn cbi-button-action',
|
|
'click': function() {
|
|
var name = document.getElementById('tor-svc-name').value;
|
|
var localPort = parseInt(document.getElementById('tor-local-port').value);
|
|
var onionPort = parseInt(document.getElementById('tor-onion-port').value);
|
|
|
|
if (!name) {
|
|
ui.addNotification(null, E('p', {}, 'Service name is required'), 'warning');
|
|
return;
|
|
}
|
|
|
|
ui.hideModal();
|
|
|
|
// Show progress modal
|
|
var progressContent = self.createProgressModal('Creating Tor Hidden Service', [
|
|
{ label: 'Validating configuration', detail: 'Checking service name and ports' },
|
|
{ label: 'Creating hidden service directory', detail: '/var/lib/tor/hidden_services/' + name },
|
|
{ label: 'Updating Tor configuration', detail: 'Adding HiddenServiceDir and HiddenServicePort' },
|
|
{ label: 'Restarting Tor daemon', detail: 'Applying configuration changes' },
|
|
{ label: 'Generating .onion address', detail: 'This may take up to 30 seconds' },
|
|
{ label: 'Finalizing', detail: 'Saving to UCI and syncing with Tor Shield' }
|
|
]);
|
|
|
|
ui.showModal('Creating Tor Hidden Service', [
|
|
progressContent,
|
|
E('div', { 'id': 'progress-actions', 'style': 'display: none; margin-top: 1rem; text-align: right;' }, [
|
|
E('button', {
|
|
'class': 'btn cbi-button-action',
|
|
'click': function() {
|
|
ui.hideModal();
|
|
self.refreshDashboard();
|
|
}
|
|
}, 'Close')
|
|
])
|
|
]);
|
|
|
|
// Simulate step progression (actual API call happens in background)
|
|
self.updateStep(0, 'active', 'Validating ' + name + ':' + localPort + ' -> :' + onionPort);
|
|
|
|
setTimeout(function() {
|
|
self.updateStep(0, 'complete', 'Configuration valid');
|
|
self.updateStep(1, 'active');
|
|
}, 500);
|
|
|
|
setTimeout(function() {
|
|
self.updateStep(1, 'complete', 'Directory created');
|
|
self.updateStep(2, 'active');
|
|
}, 1000);
|
|
|
|
setTimeout(function() {
|
|
self.updateStep(2, 'complete', 'torrc updated');
|
|
self.updateStep(3, 'active');
|
|
}, 1500);
|
|
|
|
// Make actual API call
|
|
api.torAdd(name, localPort, onionPort).then(function(res) {
|
|
if (res.success) {
|
|
self.updateStep(3, 'complete', 'Tor restarted');
|
|
self.updateStep(4, 'complete', 'Address generated');
|
|
self.updateStep(5, 'complete', 'UCI and Tor Shield synced');
|
|
|
|
self.showProgressResult(true,
|
|
'Hidden service created successfully!',
|
|
res.onion ? E('code', { 'style': 'color: #9b59b6; font-size: 12px; word-break: break-all;' }, res.onion) : null
|
|
);
|
|
} else {
|
|
// Mark current step as error
|
|
for (var i = 3; i <= 5; i++) {
|
|
var stepEl = document.getElementById('step-' + i);
|
|
if (stepEl && stepEl.getAttribute('data-status') === 'active') {
|
|
self.updateStep(i, 'error', res.error || 'Failed');
|
|
break;
|
|
}
|
|
}
|
|
self.showProgressResult(false, 'Failed to create hidden service', res.error || 'Unknown error');
|
|
}
|
|
|
|
document.getElementById('progress-actions').style.display = 'block';
|
|
}).catch(function(err) {
|
|
self.updateStep(3, 'error', 'Connection failed');
|
|
self.showProgressResult(false, 'API Error', err.message || 'Unknown error');
|
|
document.getElementById('progress-actions').style.display = 'block';
|
|
});
|
|
}
|
|
}, 'Create Hidden Service')
|
|
])
|
|
]);
|
|
},
|
|
|
|
handleAddSsl: function(svc, serviceName) {
|
|
var self = this;
|
|
|
|
ui.showModal('Add SSL Backend', [
|
|
E('p', {}, 'Configure HTTPS reverse proxy for ' + (svc.name || svc.process)),
|
|
E('div', { 'style': 'margin: 1rem 0;' }, [
|
|
E('div', { 'style': 'margin-bottom: 0.75rem;' }, [
|
|
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Service Name'),
|
|
E('input', {
|
|
'type': 'text',
|
|
'id': 'ssl-svc-name',
|
|
'value': serviceName,
|
|
'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;'
|
|
})
|
|
]),
|
|
E('div', { 'style': 'margin-bottom: 0.75rem;' }, [
|
|
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Domain (FQDN)'),
|
|
E('input', {
|
|
'type': 'text',
|
|
'id': 'ssl-domain',
|
|
'placeholder': serviceName + '.example.com',
|
|
'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;'
|
|
})
|
|
]),
|
|
E('div', {}, [
|
|
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Backend Port'),
|
|
E('input', {
|
|
'type': 'number',
|
|
'id': 'ssl-port',
|
|
'value': svc.port,
|
|
'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;'
|
|
})
|
|
])
|
|
]),
|
|
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [
|
|
E('button', {
|
|
'class': 'btn',
|
|
'click': ui.hideModal
|
|
}, 'Cancel'),
|
|
E('button', {
|
|
'class': 'btn cbi-button-action',
|
|
'click': function() {
|
|
var name = document.getElementById('ssl-svc-name').value;
|
|
var domain = document.getElementById('ssl-domain').value;
|
|
var port = parseInt(document.getElementById('ssl-port').value);
|
|
|
|
if (!domain) {
|
|
ui.addNotification(null, E('p', {}, 'Domain is required'), 'warning');
|
|
return;
|
|
}
|
|
|
|
ui.hideModal();
|
|
|
|
// Show progress modal
|
|
var progressContent = self.createProgressModal('Configuring SSL Backend', [
|
|
{ label: 'Validating configuration', detail: 'Checking domain and backend port' },
|
|
{ label: 'Creating HAProxy backend', detail: 'Adding server 127.0.0.1:' + port },
|
|
{ label: 'Creating virtual host', detail: 'Domain: ' + domain },
|
|
{ label: 'Committing UCI changes', detail: 'Saving to /etc/config/haproxy' },
|
|
{ label: 'Regenerating HAProxy config', detail: 'Running haproxyctl generate' },
|
|
{ label: 'Reloading HAProxy', detail: 'Applying changes without downtime' }
|
|
]);
|
|
|
|
ui.showModal('Configuring SSL Backend', [
|
|
progressContent,
|
|
E('div', { 'id': 'progress-actions', 'style': 'display: none; margin-top: 1rem; text-align: right;' }, [
|
|
E('button', {
|
|
'class': 'btn cbi-button-action',
|
|
'click': function() {
|
|
ui.hideModal();
|
|
self.refreshDashboard();
|
|
}
|
|
}, 'Close')
|
|
])
|
|
]);
|
|
|
|
// Simulate step progression
|
|
self.updateStep(0, 'active', 'Validating ' + name + ' -> ' + domain);
|
|
|
|
setTimeout(function() {
|
|
self.updateStep(0, 'complete', 'Configuration valid');
|
|
self.updateStep(1, 'active');
|
|
}, 400);
|
|
|
|
setTimeout(function() {
|
|
self.updateStep(1, 'complete', 'Backend ' + name + ' created');
|
|
self.updateStep(2, 'active');
|
|
}, 800);
|
|
|
|
setTimeout(function() {
|
|
self.updateStep(2, 'complete', 'VHost for ' + domain + ' created');
|
|
self.updateStep(3, 'active');
|
|
}, 1200);
|
|
|
|
// Make actual API call
|
|
api.sslAdd(name, domain, port).then(function(res) {
|
|
if (res.success) {
|
|
self.updateStep(3, 'complete', 'UCI committed');
|
|
self.updateStep(4, 'complete', 'Config regenerated');
|
|
self.updateStep(5, 'complete', 'HAProxy reloaded');
|
|
|
|
self.showProgressResult(true,
|
|
'SSL backend configured successfully!',
|
|
E('div', {}, [
|
|
E('div', { 'style': 'margin-bottom: 8px;' }, [
|
|
'Access via: ',
|
|
E('a', {
|
|
'href': 'https://' + domain,
|
|
'target': '_blank',
|
|
'style': 'color: #27ae60;'
|
|
}, 'https://' + domain)
|
|
]),
|
|
E('div', { 'style': 'font-size: 12px; color: #888;' },
|
|
'Note: Ensure SSL certificate is configured for ' + domain)
|
|
])
|
|
);
|
|
} else {
|
|
for (var i = 3; i <= 5; i++) {
|
|
var stepEl = document.getElementById('step-' + i);
|
|
if (stepEl && stepEl.getAttribute('data-status') !== 'complete') {
|
|
self.updateStep(i, 'error', res.error || 'Failed');
|
|
break;
|
|
}
|
|
}
|
|
self.showProgressResult(false, 'Failed to configure SSL backend', res.error || 'Unknown error');
|
|
}
|
|
|
|
document.getElementById('progress-actions').style.display = 'block';
|
|
}).catch(function(err) {
|
|
self.updateStep(3, 'error', 'Connection failed');
|
|
self.showProgressResult(false, 'API Error', err.message || 'Unknown error');
|
|
document.getElementById('progress-actions').style.display = 'block';
|
|
});
|
|
}
|
|
}, 'Create SSL Backend')
|
|
])
|
|
]);
|
|
},
|
|
|
|
handleRemoveTor: function(serviceName) {
|
|
var self = this;
|
|
|
|
ui.showModal('Remove Tor Hidden Service', [
|
|
E('p', {}, 'Remove the .onion address for ' + serviceName + '?'),
|
|
E('p', { 'style': 'color: #e74c3c;' }, 'Warning: The onion address will be permanently deleted.'),
|
|
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [
|
|
E('button', { 'class': 'btn', 'click': ui.hideModal }, 'Cancel'),
|
|
E('button', {
|
|
'class': 'btn cbi-button-negative',
|
|
'click': function() {
|
|
ui.hideModal();
|
|
|
|
// Show progress modal
|
|
var progressContent = self.createProgressModal('Removing Tor Hidden Service', [
|
|
{ label: 'Removing from torrc', detail: 'Deleting HiddenServiceDir entry' },
|
|
{ label: 'Deleting hidden service directory', detail: '/var/lib/tor/hidden_services/' + serviceName },
|
|
{ label: 'Restarting Tor daemon', detail: 'Applying configuration changes' },
|
|
{ label: 'Updating UCI', detail: 'Removing from secubox-exposure and tor-shield' }
|
|
]);
|
|
|
|
ui.showModal('Removing Tor Hidden Service', [
|
|
progressContent,
|
|
E('div', { 'id': 'progress-actions', 'style': 'display: none; margin-top: 1rem; text-align: right;' }, [
|
|
E('button', {
|
|
'class': 'btn cbi-button-action',
|
|
'click': function() {
|
|
ui.hideModal();
|
|
self.refreshDashboard();
|
|
}
|
|
}, 'Close')
|
|
])
|
|
]);
|
|
|
|
self.updateStep(0, 'active');
|
|
|
|
api.torRemove(serviceName).then(function(res) {
|
|
if (res.success) {
|
|
self.updateStep(0, 'complete');
|
|
self.updateStep(1, 'complete');
|
|
self.updateStep(2, 'complete');
|
|
self.updateStep(3, 'complete');
|
|
self.showProgressResult(true, 'Hidden service removed successfully');
|
|
} else {
|
|
self.updateStep(0, 'error', res.error);
|
|
self.showProgressResult(false, 'Failed to remove hidden service', res.error || 'Unknown error');
|
|
}
|
|
document.getElementById('progress-actions').style.display = 'block';
|
|
}).catch(function(err) {
|
|
self.updateStep(0, 'error', 'Connection failed');
|
|
self.showProgressResult(false, 'API Error', err.message);
|
|
document.getElementById('progress-actions').style.display = 'block';
|
|
});
|
|
}
|
|
}, 'Remove')
|
|
])
|
|
]);
|
|
},
|
|
|
|
handleRemoveSsl: function(serviceName) {
|
|
var self = this;
|
|
|
|
ui.showModal('Remove SSL Backend', [
|
|
E('p', {}, 'Remove HAProxy backend for ' + serviceName + '?'),
|
|
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [
|
|
E('button', { 'class': 'btn', 'click': ui.hideModal }, 'Cancel'),
|
|
E('button', {
|
|
'class': 'btn cbi-button-negative',
|
|
'click': function() {
|
|
ui.hideModal();
|
|
|
|
// Show progress modal
|
|
var progressContent = self.createProgressModal('Removing SSL Backend', [
|
|
{ label: 'Removing virtual host', detail: 'Deleting vhost configuration' },
|
|
{ label: 'Removing backend', detail: 'Deleting backend ' + serviceName },
|
|
{ label: 'Committing UCI changes', detail: 'Saving to /etc/config/haproxy' },
|
|
{ label: 'Regenerating HAProxy config', detail: 'Running haproxyctl generate' },
|
|
{ label: 'Reloading HAProxy', detail: 'Applying changes' }
|
|
]);
|
|
|
|
ui.showModal('Removing SSL Backend', [
|
|
progressContent,
|
|
E('div', { 'id': 'progress-actions', 'style': 'display: none; margin-top: 1rem; text-align: right;' }, [
|
|
E('button', {
|
|
'class': 'btn cbi-button-action',
|
|
'click': function() {
|
|
ui.hideModal();
|
|
self.refreshDashboard();
|
|
}
|
|
}, 'Close')
|
|
])
|
|
]);
|
|
|
|
self.updateStep(0, 'active');
|
|
|
|
api.sslRemove(serviceName).then(function(res) {
|
|
if (res.success) {
|
|
self.updateStep(0, 'complete');
|
|
self.updateStep(1, 'complete');
|
|
self.updateStep(2, 'complete');
|
|
self.updateStep(3, 'complete');
|
|
self.updateStep(4, 'complete');
|
|
self.showProgressResult(true, 'SSL backend removed successfully');
|
|
} else {
|
|
self.updateStep(0, 'error', res.error);
|
|
self.showProgressResult(false, 'Failed to remove SSL backend', res.error || 'Unknown error');
|
|
}
|
|
document.getElementById('progress-actions').style.display = 'block';
|
|
}).catch(function(err) {
|
|
self.updateStep(0, 'error', 'Connection failed');
|
|
self.showProgressResult(false, 'API Error', err.message);
|
|
document.getElementById('progress-actions').style.display = 'block';
|
|
});
|
|
}
|
|
}, 'Remove')
|
|
])
|
|
]);
|
|
},
|
|
|
|
refreshDashboard: function() {
|
|
var self = this;
|
|
return Promise.all([
|
|
api.status(),
|
|
api.scan(),
|
|
api.conflicts(),
|
|
api.torList(),
|
|
api.sslList()
|
|
]).then(function(data) {
|
|
var container = document.querySelector('.exposure-dashboard');
|
|
if (container) {
|
|
var newView = self.render(data);
|
|
container.parentNode.replaceChild(newView, container);
|
|
}
|
|
});
|
|
},
|
|
|
|
showToast: function(message, type) {
|
|
var existing = document.querySelector('.exp-toast');
|
|
if (existing) existing.remove();
|
|
|
|
var iconMap = {
|
|
'success': '\u2705',
|
|
'error': '\u274C',
|
|
'warning': '\u26A0\uFE0F'
|
|
};
|
|
|
|
var colorMap = {
|
|
'success': '#22c55e',
|
|
'error': '#ef4444',
|
|
'warning': '#f97316'
|
|
};
|
|
|
|
var toast = E('div', {
|
|
'class': 'exp-toast',
|
|
'style': 'position: fixed; bottom: 24px; right: 24px; background: #1a1a2e; border: 1px solid ' + (colorMap[type] || '#333') + '; padding: 12px 20px; border-radius: 8px; color: #fff; z-index: 10000; display: flex; align-items: center; gap: 8px;'
|
|
}, [
|
|
E('span', {}, iconMap[type] || '\u2139\uFE0F'),
|
|
message
|
|
]);
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(function() {
|
|
toast.remove();
|
|
}, 4000);
|
|
},
|
|
|
|
handleSaveApply: null,
|
|
handleSave: null,
|
|
handleReset: null
|
|
});
|