develstats
This commit is contained in:
parent
b20f9cbb8c
commit
bd96ab1d31
@ -155,7 +155,8 @@
|
||||
"Bash(luci-app-system-hub/htdocs/luci-static/resources/view/system-hub/services.js )",
|
||||
"Bash(deploy-modules-with-theme.sh)",
|
||||
"Bash(timeout 120 ./local-build.sh:*)",
|
||||
"Bash(do echo \"=== admin/secubox/$category ===\")"
|
||||
"Bash(do echo \"=== admin/secubox/$category ===\")",
|
||||
"Bash(./secubox-tools/sync_module_versions.sh:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -167,6 +167,8 @@ graph TB
|
||||
|
||||
#### 1. Page Header (Standard)
|
||||
|
||||
**REQUIREMENT:** Every module view MUST begin with this compact `.sh-page-header`. Do not introduce bespoke hero sections or oversized banners; the header keeps height predictable (title + subtitle on the left, stats on the right) and guarantees consistency across SecuBox dashboards. If no stats are needed, keep the container but supply an empty `.sh-stats-grid` for future metrics.
|
||||
|
||||
**HTML Structure:**
|
||||
```javascript
|
||||
E('div', { 'class': 'sh-page-header' }, [
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
var tabs = [
|
||||
{ id: 'overview', icon: '📦', label: _('Overview'), path: ['admin', 'secubox', 'network', 'cdn-cache', 'overview'] },
|
||||
{ id: 'cache', icon: '💾', label: _('Cache'), path: ['admin', 'secubox', 'network', 'cdn-cache', 'cache'] },
|
||||
{ id: 'policies', icon: '🧭', label: _('Policies'), path: ['admin', 'secubox', 'network', 'cdn-cache', 'policies'] },
|
||||
{ id: 'statistics', icon: '📊', label: _('Statistics'), path: ['admin', 'secubox', 'network', 'cdn-cache', 'statistics'] },
|
||||
{ id: 'maintenance', icon: '🧹', label: _('Maintenance'), path: ['admin', 'secubox', 'network', 'cdn-cache', 'maintenance'] },
|
||||
{ id: 'settings', icon: '⚙️', label: _('Settings'), path: ['admin', 'secubox', 'network', 'cdn-cache', 'settings'] }
|
||||
];
|
||||
|
||||
return baseclass.extend({
|
||||
getTabs: function() {
|
||||
return tabs.slice();
|
||||
},
|
||||
|
||||
renderTabs: function(active) {
|
||||
return E('div', { 'class': 'sh-nav-tabs cdn-nav-tabs' },
|
||||
this.getTabs().map(function(tab) {
|
||||
return E('a', {
|
||||
'class': 'sh-nav-tab' + (tab.id === active ? ' active' : ''),
|
||||
'href': L.url.apply(L, tab.path)
|
||||
}, [
|
||||
E('span', { 'class': 'sh-tab-icon' }, tab.icon),
|
||||
E('span', { 'class': 'sh-tab-label' }, tab.label)
|
||||
]);
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -3,6 +3,7 @@
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require cdn-cache/nav as CdnNav';
|
||||
|
||||
var callCacheList = rpc.declare({
|
||||
object: 'luci.cdn-cache',
|
||||
@ -59,6 +60,7 @@ return view.extend({
|
||||
return E('div', { 'class': 'cdn-dashboard' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('cdn-cache/dashboard.css') }),
|
||||
CdnNav.renderTabs('cache'),
|
||||
this.renderHero(items, domains),
|
||||
this.renderDomains(domains),
|
||||
this.renderCacheTable(items)
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
'require view';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require cdn-cache/nav as CdnNav';
|
||||
|
||||
var callPurgeCache = rpc.declare({
|
||||
object: 'luci.cdn-cache',
|
||||
@ -74,6 +75,8 @@ return view.extend({
|
||||
.cdn-log-line:hover { background: rgba(6,182,212,0.1); }
|
||||
`),
|
||||
|
||||
CdnNav.renderTabs('maintenance'),
|
||||
|
||||
E('div', { 'class': 'cdn-page-header' }, [
|
||||
E('h2', { 'class': 'cdn-page-title' }, '🔧 Maintenance')
|
||||
]),
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
'require view';
|
||||
'require rpc';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require cdn-cache/nav as CdnNav';
|
||||
|
||||
var callStatus = rpc.declare({
|
||||
object: 'luci.cdn-cache',
|
||||
@ -72,26 +73,37 @@ return view.extend({
|
||||
return E('div', { 'class': 'cdn-dashboard' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('cdn-cache/dashboard.css') }),
|
||||
this.renderHero(status),
|
||||
CdnNav.renderTabs('overview'),
|
||||
this.renderHeader(status),
|
||||
this.renderMetricGrid(stats, cacheSize),
|
||||
this.renderSections(stats, cacheSize, topDomains)
|
||||
]);
|
||||
},
|
||||
|
||||
renderHero: function(status) {
|
||||
return E('section', { 'class': 'cdn-hero' }, [
|
||||
renderHeader: function(status) {
|
||||
var stats = [
|
||||
{ label: _('Service'), value: status.running ? _('Running') : _('Stopped') },
|
||||
{ label: _('Uptime'), value: formatUptime(status.uptime || 0) },
|
||||
{ label: _('Cache files'), value: (status.cache_files || 0).toLocaleString() }
|
||||
];
|
||||
|
||||
return E('div', { 'class': 'sh-page-header' }, [
|
||||
E('div', {}, [
|
||||
E('h2', {}, '📦 CDN Cache Control'),
|
||||
E('p', {}, _('Edge caching for media, firmware and downloads')),
|
||||
E('span', { 'class': 'cdn-status-badge ' + (status.running ? 'cdn-status-running' : 'cdn-status-stopped') }, [
|
||||
status.running ? _('● Running on port ') + (status.listen_port || '3128') : _('○ Service stopped')
|
||||
])
|
||||
E('h2', { 'class': 'sh-page-title' }, [
|
||||
E('span', { 'class': 'sh-page-title-icon' }, '📦'),
|
||||
_('CDN Cache Control')
|
||||
]),
|
||||
E('p', { 'class': 'sh-page-subtitle' },
|
||||
_('Edge caching for media, firmware, and downloads'))
|
||||
]),
|
||||
E('div', { 'class': 'cdn-hero-meta' }, [
|
||||
E('span', {}, _('PID: ') + (status.pid || 'N/A')),
|
||||
E('span', {}, _('Uptime: ') + formatUptime(status.uptime || 0)),
|
||||
E('span', {}, _('Cache files: ') + (status.cache_files || 0).toLocaleString())
|
||||
])
|
||||
E('div', { 'class': 'sh-stats-grid' }, stats.map(this.renderHeaderStat, this))
|
||||
]);
|
||||
},
|
||||
|
||||
renderHeaderStat: function(stat) {
|
||||
return E('div', { 'class': 'sh-stat-badge' }, [
|
||||
E('div', { 'class': 'sh-stat-value' }, stat.value),
|
||||
E('div', { 'class': 'sh-stat-label' }, stat.label)
|
||||
]);
|
||||
},
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require form';
|
||||
'require cdn-cache/nav as CdnNav';
|
||||
|
||||
var callPolicies = rpc.declare({
|
||||
object: 'luci.cdn-cache',
|
||||
@ -68,6 +69,8 @@ return view.extend({
|
||||
.cdn-empty { text-align: center; padding: 40px; color: #64748b; }
|
||||
`),
|
||||
|
||||
CdnNav.renderTabs('policies'),
|
||||
|
||||
E('div', { 'class': 'cdn-page-header' }, [
|
||||
E('h2', { 'class': 'cdn-page-title' }, '📋 Policies de Cache'),
|
||||
E('p', { 'style': 'margin: 0; opacity: 0.9;' }, 'Règles de mise en cache par domaine et type de fichier')
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
'require form';
|
||||
'require uci';
|
||||
'require rpc';
|
||||
'require cdn-cache/nav as CdnNav';
|
||||
|
||||
var callSetEnabled = rpc.declare({
|
||||
object: 'luci.cdn-cache',
|
||||
@ -137,6 +138,9 @@ return view.extend({
|
||||
o.datatype = 'uinteger';
|
||||
o.default = '60';
|
||||
|
||||
return m.render();
|
||||
return E('div', { 'class': 'cdn-settings-page' }, [
|
||||
CdnNav.renderTabs('settings'),
|
||||
m.render()
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
'require view';
|
||||
'require rpc';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require cdn-cache/nav as CdnNav';
|
||||
|
||||
var callStats = rpc.declare({
|
||||
object: 'luci.cdn-cache',
|
||||
@ -60,6 +61,7 @@ return view.extend({
|
||||
var view = E('div', { 'class': 'cdn-dashboard' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('cdn-cache/dashboard.css') }),
|
||||
CdnNav.renderTabs('statistics'),
|
||||
this.renderHero(stats),
|
||||
this.renderMetrics(stats),
|
||||
this.renderTrendSection(_('Bandwidth Savings'), bandwidthTrend, '#06b6d4', function(d) {
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
@ -136,25 +136,17 @@ function createStepper(steps, active) {
|
||||
}
|
||||
|
||||
function createNavigationTabs(activeId) {
|
||||
var base = 'admin/secubox/network/modes/';
|
||||
return E('nav', { 'class': 'nm-nav-tabs' }, [
|
||||
E('div', { 'class': 'cyber-tablist' },
|
||||
NAV_BLUEPRINT.map(function(item) {
|
||||
var cls = 'cyber-tab';
|
||||
if (activeId === item.id)
|
||||
cls += ' is-active';
|
||||
|
||||
return E('a', {
|
||||
'class': cls,
|
||||
'href': L.url(base + item.id),
|
||||
'aria-current': activeId === item.id ? 'page' : null
|
||||
}, [
|
||||
E('span', { 'class': 'cyber-tab-icon' }, item.icon),
|
||||
E('span', { 'class': 'cyber-tab-label' }, _(item.labelKey))
|
||||
]);
|
||||
})
|
||||
)
|
||||
]);
|
||||
return E('div', { 'class': 'sh-nav-tabs network-modes-nav-tabs' },
|
||||
NAV_BLUEPRINT.map(function(item) {
|
||||
return E('a', {
|
||||
'class': 'sh-nav-tab' + (activeId === item.id ? ' active' : ''),
|
||||
'href': L.url('admin', 'secubox', 'network', 'modes', item.id)
|
||||
}, [
|
||||
E('span', { 'class': 'sh-tab-icon' }, item.icon),
|
||||
E('span', { 'class': 'sh-tab-label' }, _(item.labelKey))
|
||||
]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
|
||||
@ -104,21 +104,7 @@ return view.extend({
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('network-modes/dashboard.css') }),
|
||||
helpers.createNavigationTabs('overview'),
|
||||
|
||||
// Header
|
||||
E('div', { 'class': 'nm-header' }, [
|
||||
E('div', { 'class': 'nm-logo' }, [
|
||||
E('div', { 'class': 'nm-logo-icon' }, '🌐'),
|
||||
E('div', { 'class': 'nm-logo-text' }, ['Network ', E('span', {}, 'Configuration')])
|
||||
]),
|
||||
E('div', { 'class': 'nm-mode-badge ' + currentMode }, [
|
||||
E('span', { 'class': 'nm-mode-dot' }),
|
||||
currentModeInfo ? currentModeInfo.name : currentMode
|
||||
]),
|
||||
Help.createHelpButton('network-modes', 'header', {
|
||||
icon: '📖',
|
||||
label: _('Help')
|
||||
})
|
||||
]),
|
||||
this.renderHeader(status, currentModeInfo),
|
||||
|
||||
// Current Mode Display Card
|
||||
E('div', { 'class': 'nm-current-mode-card' }, [
|
||||
@ -377,6 +363,34 @@ return view.extend({
|
||||
|
||||
return view;
|
||||
},
|
||||
|
||||
renderHeader: function(status, currentModeInfo) {
|
||||
var modeName = currentModeInfo ? currentModeInfo.name : (status.current_mode || 'router');
|
||||
var stats = [
|
||||
{ label: _('Mode'), value: modeName },
|
||||
{ label: _('WAN IP'), value: status.wan_ip || _('Unknown') },
|
||||
{ label: _('LAN IP'), value: status.lan_ip || _('Unknown') }
|
||||
];
|
||||
|
||||
return E('div', { 'class': 'sh-page-header' }, [
|
||||
E('div', {}, [
|
||||
E('h2', { 'class': 'sh-page-title' }, [
|
||||
E('span', { 'class': 'sh-page-title-icon' }, '🌐'),
|
||||
_('Network Configuration')
|
||||
]),
|
||||
E('p', { 'class': 'sh-page-subtitle' },
|
||||
_('Switch between curated router, bridge, relay, and travel modes.'))
|
||||
]),
|
||||
E('div', { 'class': 'sh-stats-grid' }, stats.map(this.renderHeaderStat, this))
|
||||
]);
|
||||
},
|
||||
|
||||
renderHeaderStat: function(stat) {
|
||||
return E('div', { 'class': 'sh-stat-badge' }, [
|
||||
E('div', { 'class': 'sh-stat-value' }, stat.value || '-'),
|
||||
E('div', { 'class': 'sh-stat-label' }, stat.label)
|
||||
]);
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
30
luci-app-secubox/htdocs/luci-static/resources/secubox/nav.js
Normal file
30
luci-app-secubox/htdocs/luci-static/resources/secubox/nav.js
Normal file
@ -0,0 +1,30 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
var tabs = [
|
||||
{ id: 'dashboard', icon: '🚀', label: _('Dashboard'), path: ['admin', 'secubox', 'dashboard'] },
|
||||
{ id: 'modules', icon: '🧩', label: _('Modules'), path: ['admin', 'secubox', 'modules'] },
|
||||
{ id: 'monitoring', icon: '📡', label: _('Monitoring'), path: ['admin', 'secubox', 'monitoring', 'overview'] },
|
||||
{ id: 'alerts', icon: '⚠️', label: _('Alerts'), path: ['admin', 'secubox', 'alerts'] },
|
||||
{ id: 'settings', icon: '⚙️', label: _('Settings'), path: ['admin', 'secubox', 'settings'] }
|
||||
];
|
||||
|
||||
return baseclass.extend({
|
||||
getTabs: function() {
|
||||
return tabs.slice();
|
||||
},
|
||||
|
||||
renderTabs: function(active) {
|
||||
return E('div', { 'class': 'sh-nav-tabs secubox-nav-tabs' },
|
||||
this.getTabs().map(function(tab) {
|
||||
return E('a', {
|
||||
'class': 'sh-nav-tab' + (tab.id === active ? ' active' : ''),
|
||||
'href': L.url.apply(L, tab.path)
|
||||
}, [
|
||||
E('span', { 'class': 'sh-tab-icon' }, tab.icon),
|
||||
E('span', { 'class': 'sh-tab-label' }, tab.label)
|
||||
]);
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -4,6 +4,7 @@
|
||||
'require dom';
|
||||
'require secubox/api as API';
|
||||
'require secubox/theme as Theme';
|
||||
'require secubox/nav as SecuNav';
|
||||
'require poll';
|
||||
|
||||
// Load CSS (base theme variables first)
|
||||
@ -41,19 +42,13 @@ return view.extend({
|
||||
|
||||
render: function(data) {
|
||||
var self = this;
|
||||
var container = E('div', { 'class': 'secubox-alerts-page' });
|
||||
|
||||
// Header
|
||||
container.appendChild(this.renderHeader());
|
||||
|
||||
// Filters and controls
|
||||
container.appendChild(this.renderControls());
|
||||
|
||||
// Stats overview
|
||||
container.appendChild(this.renderStats());
|
||||
|
||||
// Alerts list
|
||||
container.appendChild(this.renderAlertsList());
|
||||
var container = E('div', { 'class': 'secubox-alerts-page' }, [
|
||||
SecuNav.renderTabs('alerts'),
|
||||
this.renderHeader(),
|
||||
this.renderControls(),
|
||||
this.renderStats(),
|
||||
this.renderAlertsList()
|
||||
]);
|
||||
|
||||
// Auto-refresh
|
||||
poll.add(function() {
|
||||
@ -66,6 +61,7 @@ return view.extend({
|
||||
},
|
||||
|
||||
renderHeader: function() {
|
||||
var self = this;
|
||||
return E('div', { 'class': 'secubox-page-header' }, [
|
||||
E('div', {}, [
|
||||
E('h2', {}, '⚠️ System Alerts'),
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
'require poll';
|
||||
'require secubox/api as API';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require secubox/nav as SecuNav';
|
||||
|
||||
// Load theme resources once
|
||||
document.head.appendChild(E('link', {
|
||||
@ -48,6 +49,7 @@ return view.extend({
|
||||
render: function() {
|
||||
var container = E('div', { 'class': 'secubox-dashboard' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox/dashboard.css') }),
|
||||
SecuNav.renderTabs('dashboard'),
|
||||
this.renderHeader(),
|
||||
this.renderStatsGrid(),
|
||||
this.renderMainLayout()
|
||||
@ -64,25 +66,37 @@ return view.extend({
|
||||
},
|
||||
|
||||
renderHeader: function() {
|
||||
var status = this.dashboardData.status || {};
|
||||
return E('header', { 'class': 'sb-header' }, [
|
||||
E('div', { 'class': 'sb-header-info' }, [
|
||||
E('div', { 'class': 'sb-header-icon' }, '🚀'),
|
||||
E('div', {}, [
|
||||
E('h1', { 'class': 'sb-title' }, 'SecuBox Control Center'),
|
||||
E('p', { 'class': 'sb-subtitle' }, 'Security · Network · System Automation')
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'sb-header-meta' }, [
|
||||
this.renderBadge('v' + (status.version || this.getSystemVersion())),
|
||||
this.renderBadge('⏱ ' + API.formatUptime(status.uptime || this.getSystemUptime())),
|
||||
this.renderBadge('🖥 ' + (status.hostname || 'SecuBox'), 'sb-badge-ghost')
|
||||
])
|
||||
]);
|
||||
},
|
||||
var status = this.dashboardData.status || {};
|
||||
var counts = this.dashboardData.counts || {};
|
||||
var moduleStats = this.getModuleStats();
|
||||
var alertsCount = (this.alertsData.alerts || []).length;
|
||||
var healthScore = (this.healthData.overall && this.healthData.overall.score) || 0;
|
||||
|
||||
renderBadge: function(text, extraClass) {
|
||||
return E('span', { 'class': 'sb-badge ' + (extraClass || '') }, text);
|
||||
var stats = [
|
||||
{ label: _('Modules'), value: counts.total || moduleStats.total || 0 },
|
||||
{ label: _('Running'), value: counts.running || moduleStats.running || 0 },
|
||||
{ label: _('Alerts'), value: alertsCount },
|
||||
{ label: _('Health'), value: healthScore + '/100' }
|
||||
];
|
||||
|
||||
return E('div', { 'class': 'sh-page-header' }, [
|
||||
E('div', {}, [
|
||||
E('h2', { 'class': 'sh-page-title' }, [
|
||||
E('span', { 'class': 'sh-page-title-icon' }, '🚀'),
|
||||
_('SecuBox Control Center')
|
||||
]),
|
||||
E('p', { 'class': 'sh-page-subtitle' },
|
||||
_('Security · Network · System automation'))
|
||||
]),
|
||||
E('div', { 'class': 'sh-stats-grid' }, stats.map(this.renderHeaderStat, this))
|
||||
]);
|
||||
},
|
||||
|
||||
renderHeaderStat: function(stat) {
|
||||
return E('div', { 'class': 'sh-stat-badge' }, [
|
||||
E('div', { 'class': 'sh-stat-value' }, stat.value.toString()),
|
||||
E('div', { 'class': 'sh-stat-label' }, stat.label)
|
||||
]);
|
||||
},
|
||||
|
||||
renderStatsGrid: function() {
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
'require dom';
|
||||
'require secubox/api as API';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require secubox/nav as SecuNav';
|
||||
'require poll';
|
||||
|
||||
// Load global theme CSS
|
||||
@ -43,18 +44,15 @@ return view.extend({
|
||||
var self = this;
|
||||
var modules = this.modulesData;
|
||||
|
||||
var container = E('div', { 'class': 'secubox-modules-page' });
|
||||
|
||||
// Header with stats
|
||||
container.appendChild(this.renderHeader(modules));
|
||||
|
||||
// Filter tabs
|
||||
container.appendChild(this.renderFilterTabs());
|
||||
|
||||
// Modules grid
|
||||
container.appendChild(E('div', { 'id': 'modules-grid', 'class': 'secubox-modules-grid' },
|
||||
this.renderModuleCards(modules, 'all')
|
||||
));
|
||||
var container = E('div', { 'class': 'secubox-modules-page' }, [
|
||||
SecuNav.renderTabs('modules'),
|
||||
this.renderHeader(modules),
|
||||
this.renderFilterTabs(),
|
||||
E('div', {
|
||||
'id': 'modules-grid',
|
||||
'class': 'secubox-modules-grid'
|
||||
}, this.renderModuleCards(modules, 'all'))
|
||||
]);
|
||||
|
||||
// Auto-refresh
|
||||
poll.add(function() {
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
'require poll';
|
||||
'require secubox/api as API';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require secubox/nav as SecuNav';
|
||||
|
||||
// Respect LuCI language/theme preferences
|
||||
var secuLang = (typeof L !== 'undefined' && L.env && L.env.lang) ||
|
||||
@ -56,6 +57,7 @@ return view.extend({
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox/secubox.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox/monitoring.css') }),
|
||||
SecuNav.renderTabs('monitoring'),
|
||||
this.renderHero(),
|
||||
this.renderChartsGrid(),
|
||||
this.renderCurrentStatsCard()
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
'require ui';
|
||||
'require secubox/api as API';
|
||||
'require secubox/theme as Theme';
|
||||
'require secubox/nav as SecuNav';
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
@ -25,6 +26,8 @@ return view.extend({
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox/common.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox/secubox.css') }),
|
||||
|
||||
SecuNav.renderTabs('settings'),
|
||||
|
||||
// Modern header
|
||||
E('div', { 'class': 'sh-page-header' }, [
|
||||
E('div', {}, [
|
||||
|
||||
@ -76,7 +76,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -89,7 +89,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
var tabs = [
|
||||
{ id: 'overview', icon: '📊', label: _('Overview'), path: ['admin', 'secubox', 'system', 'system-hub', 'overview'] },
|
||||
{ id: 'services', icon: '🧩', label: _('Services'), path: ['admin', 'secubox', 'system', 'system-hub', 'services'] },
|
||||
{ id: 'logs', icon: '📜', label: _('Logs'), path: ['admin', 'secubox', 'system', 'system-hub', 'logs'] },
|
||||
{ id: 'backup', icon: '💾', label: _('Backup'), path: ['admin', 'secubox', 'system', 'system-hub', 'backup'] },
|
||||
{ id: 'components', icon: '🧱', label: _('Components'), path: ['admin', 'secubox', 'system', 'system-hub', 'components'] },
|
||||
{ id: 'diagnostics', icon: '🧪', label: _('Diagnostics'), path: ['admin', 'secubox', 'system', 'system-hub', 'diagnostics'] },
|
||||
{ id: 'health', icon: '❤️', label: _('Health'), path: ['admin', 'secubox', 'system', 'system-hub', 'health'] },
|
||||
{ id: 'remote', icon: '📡', label: _('Remote'), path: ['admin', 'secubox', 'system', 'system-hub', 'remote'] },
|
||||
{ id: 'dev-status', icon: '🚀', label: _('Dev Status'), path: ['admin', 'secubox', 'system', 'system-hub', 'dev-status'] },
|
||||
{ id: 'settings', icon: '⚙️', label: _('Settings'), path: ['admin', 'secubox', 'system', 'system-hub', 'settings'] }
|
||||
];
|
||||
|
||||
return baseclass.extend({
|
||||
getTabs: function() {
|
||||
return tabs.slice();
|
||||
},
|
||||
|
||||
renderTabs: function(active) {
|
||||
return E('div', { 'class': 'sh-nav-tabs system-hub-nav-tabs' },
|
||||
this.getTabs().map(function(tab) {
|
||||
return E('a', {
|
||||
'class': 'sh-nav-tab' + (tab.id === active ? ' active' : ''),
|
||||
'href': L.url.apply(L, tab.path)
|
||||
}, [
|
||||
E('span', { 'class': 'sh-tab-icon' }, tab.icon),
|
||||
E('span', { 'class': 'sh-tab-label' }, tab.label)
|
||||
]);
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -3,6 +3,7 @@
|
||||
'require ui';
|
||||
'require system-hub/api as API';
|
||||
'require system-hub/theme as Theme';
|
||||
'require system-hub/nav as HubNav';
|
||||
|
||||
Theme.init();
|
||||
|
||||
@ -15,6 +16,7 @@ return view.extend({
|
||||
var container = E('div', { 'class': 'system-hub-dashboard sh-backup-view' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/dashboard.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/backup.css') }),
|
||||
HubNav.renderTabs('backup'),
|
||||
this.renderHero(),
|
||||
E('div', { 'class': 'sh-backup-grid' }, [
|
||||
this.renderBackupCard(),
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
'require poll';
|
||||
'require system-hub.api as API';
|
||||
'require system-hub.theme as Theme';
|
||||
'require system-hub/nav as HubNav';
|
||||
|
||||
return view.extend({
|
||||
componentsData: [],
|
||||
@ -26,7 +27,8 @@ return view.extend({
|
||||
var view = E('div', { 'class': 'system-hub-dashboard' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/components.css') }),
|
||||
|
||||
// Header with filter tabs
|
||||
HubNav.renderTabs('components'),
|
||||
|
||||
E('div', { 'class': 'sh-components-header' }, [
|
||||
E('h2', { 'class': 'sh-page-title' }, [
|
||||
E('span', { 'class': 'sh-title-icon' }, '🧩'),
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
'require view';
|
||||
'require system-hub/theme as Theme';
|
||||
'require system-hub/dev-status-widget as DevStatusWidget';
|
||||
'require system-hub/nav as HubNav';
|
||||
|
||||
return view.extend({
|
||||
widget: null,
|
||||
@ -22,6 +23,7 @@ return view.extend({
|
||||
var widget = this.getWidget();
|
||||
var container = E('div', { 'class': 'system-hub-dev-status' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/common.css') }),
|
||||
HubNav.renderTabs('dev-status'),
|
||||
this.renderHeader(),
|
||||
this.renderSummaryGrid(),
|
||||
E('div', { 'class': 'sh-dev-status-widget-shell' }, [
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
'require dom';
|
||||
'require ui';
|
||||
'require fs';
|
||||
'require system-hub/nav as HubNav';
|
||||
|
||||
var api = L.require('system-hub.api');
|
||||
|
||||
@ -17,6 +18,7 @@ return view.extend({
|
||||
|
||||
var view = E('div', { 'class': 'system-hub-dashboard' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/dashboard.css') }),
|
||||
HubNav.renderTabs('diagnostics'),
|
||||
|
||||
// Collect Diagnostics
|
||||
E('div', { 'class': 'sh-card' }, [
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
'require poll';
|
||||
'require system-hub/api as API';
|
||||
'require system-hub/theme as Theme';
|
||||
'require system-hub/nav as HubNav';
|
||||
|
||||
Theme.init();
|
||||
|
||||
@ -21,6 +22,7 @@ return view.extend({
|
||||
var container = E('div', { 'class': 'system-hub-dashboard sh-health-view' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/dashboard.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/health.css') }),
|
||||
HubNav.renderTabs('health'),
|
||||
this.renderHero(),
|
||||
this.renderMetricGrid(),
|
||||
this.renderSummaryPanels(),
|
||||
@ -176,4 +178,3 @@ return view.extend({
|
||||
ui.addNotification(null, E('p', {}, _('Full health check started (see alerts).')), 'info');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
'require poll';
|
||||
'require system-hub/api as API';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require system-hub/nav as HubNav';
|
||||
|
||||
var shLang = (typeof L !== 'undefined' && L.env && L.env.lang) ||
|
||||
(document.documentElement && document.documentElement.getAttribute('lang')) ||
|
||||
@ -31,6 +32,7 @@ return view.extend({
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/common.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/dashboard.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/logs.css') }),
|
||||
HubNav.renderTabs('logs'),
|
||||
this.renderHero(),
|
||||
this.renderControls(),
|
||||
this.renderBody()
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
'require poll';
|
||||
'require system-hub/api as API';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require system-hub/nav as HubNav';
|
||||
|
||||
var shLang = (typeof L !== 'undefined' && L.env && L.env.lang) ||
|
||||
(document.documentElement && document.documentElement.getAttribute('lang')) ||
|
||||
@ -31,7 +32,8 @@ return view.extend({
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/common.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/dashboard.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/overview.css') }),
|
||||
this.renderHeroHeader(),
|
||||
HubNav.renderTabs('overview'),
|
||||
this.renderPageHeader(),
|
||||
this.renderInfoGrid(),
|
||||
this.renderResourceMonitors(),
|
||||
this.renderQuickStatus()
|
||||
@ -52,46 +54,47 @@ return view.extend({
|
||||
return container;
|
||||
},
|
||||
|
||||
renderHeroHeader: function() {
|
||||
renderPageHeader: function() {
|
||||
var uptime = this.sysInfo.uptime_formatted || '0d 0h 0m';
|
||||
var hostname = this.sysInfo.hostname || 'OpenWrt';
|
||||
var kernel = this.sysInfo.kernel || '';
|
||||
var kernel = this.sysInfo.kernel || '-';
|
||||
var score = (this.healthData.score || 0);
|
||||
|
||||
return E('section', { 'class': 'sh-hero' }, [
|
||||
E('div', { 'class': 'sh-hero-title' }, [
|
||||
E('div', { 'class': 'sh-hero-icon' }, '⚙️'),
|
||||
E('div', {}, [
|
||||
E('h1', {}, _('System Control Center')),
|
||||
E('p', {}, _('Unified telemetry & orchestration'))
|
||||
])
|
||||
var stats = [
|
||||
{ label: _('Uptime'), value: uptime },
|
||||
{ label: _('Hostname'), value: hostname },
|
||||
{ label: _('Kernel'), value: kernel, copy: kernel },
|
||||
{ label: _('Health'), value: score + '/100' }
|
||||
];
|
||||
|
||||
return E('div', { 'class': 'sh-page-header' }, [
|
||||
E('div', {}, [
|
||||
E('h2', { 'class': 'sh-page-title' }, [
|
||||
E('span', { 'class': 'sh-page-title-icon' }, '⚙️'),
|
||||
_('System Control Center')
|
||||
]),
|
||||
E('p', { 'class': 'sh-page-subtitle' }, _('Unified telemetry & orchestration'))
|
||||
]),
|
||||
E('div', { 'class': 'sh-hero-meta' }, [
|
||||
this.renderBadge('⏱ ' + uptime, 'sh-badge'),
|
||||
this.renderBadge('🖥 ' + hostname, 'sh-badge'),
|
||||
this.renderBadge(kernel, 'sh-badge ghost', { copy: kernel })
|
||||
]),
|
||||
E('div', { 'class': 'sh-hero-score' }, [
|
||||
E('div', { 'class': 'sh-score-value', 'id': 'sh-score-value' }, score),
|
||||
E('span', {}, '/100'),
|
||||
E('div', { 'class': 'sh-score-label', 'id': 'sh-score-label' }, this.getScoreLabel(score))
|
||||
])
|
||||
E('div', { 'class': 'sh-stats-grid' }, stats.map(this.renderHeaderStat, this))
|
||||
]);
|
||||
},
|
||||
|
||||
renderBadge: function(text, cls, opts) {
|
||||
var node = E('span', { 'class': cls }, text);
|
||||
if (opts && opts.copy) {
|
||||
node.classList.add('sh-badge-copy');
|
||||
node.addEventListener('click', function() {
|
||||
if (navigator.clipboard && opts.copy) {
|
||||
navigator.clipboard.writeText(opts.copy).then(function() {
|
||||
ui.addNotification(null, E('p', {}, _('Copied to clipboard')), 'info');
|
||||
});
|
||||
}
|
||||
renderHeaderStat: function(stat) {
|
||||
var badge = E('div', { 'class': 'sh-stat-badge' }, [
|
||||
E('div', { 'class': 'sh-stat-value' }, stat.value || '-'),
|
||||
E('div', { 'class': 'sh-stat-label' }, stat.label)
|
||||
]);
|
||||
|
||||
if (stat.copy && navigator.clipboard) {
|
||||
badge.style.cursor = 'pointer';
|
||||
badge.addEventListener('click', function() {
|
||||
navigator.clipboard.writeText(stat.copy).then(function() {
|
||||
ui.addNotification(null, E('p', {}, _('Copied to clipboard')), 'info');
|
||||
});
|
||||
});
|
||||
}
|
||||
return node;
|
||||
|
||||
return badge;
|
||||
},
|
||||
|
||||
renderInfoGrid: function() {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
'require view';
|
||||
'require dom';
|
||||
'require ui';
|
||||
'require system-hub/nav as HubNav';
|
||||
|
||||
var api = L.require('system-hub.api');
|
||||
|
||||
@ -15,6 +16,7 @@ return view.extend({
|
||||
|
||||
var view = E('div', { 'class': 'system-hub-dashboard' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/dashboard.css') }),
|
||||
HubNav.renderTabs('remote'),
|
||||
|
||||
// RustDesk Section
|
||||
E('div', { 'class': 'sh-card sh-remote-card' }, [
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
'require poll';
|
||||
'require system-hub/api as API';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require system-hub/nav as HubNav';
|
||||
|
||||
var shLang = (typeof L !== 'undefined' && L.env && L.env.lang) ||
|
||||
(document.documentElement && document.documentElement.getAttribute('lang')) ||
|
||||
@ -28,6 +29,7 @@ return view.extend({
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/common.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/dashboard.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('system-hub/services.css') }),
|
||||
HubNav.renderTabs('services'),
|
||||
this.renderHeader(),
|
||||
this.renderControls(),
|
||||
E('div', { 'class': 'sh-services-grid', 'id': 'sh-services-grid' },
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
@ -1,667 +1,179 @@
|
||||
/* VHost Manager Dashboard Styles * Version: 0.3.0
|
||||
*/
|
||||
|
||||
:root {
|
||||
--vh-primary: #06b6d4;
|
||||
--vh-secondary: #0891b2;
|
||||
--vh-dark: #0a0f14;
|
||||
--vh-darker: #050810;
|
||||
--vh-light: #15181f;
|
||||
--vh-border: #202530;
|
||||
--vh-success: #10b981;
|
||||
--vh-warning: #f59e0b;
|
||||
--vh-danger: #ef4444;
|
||||
--vh-info: #3b82f6;
|
||||
--vh-gradient: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
|
||||
.vhost-page {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
background: var(--sh-bg-primary, #0a0f1f);
|
||||
color: var(--sh-text-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
/* Main Container * Version: 0.3.0
|
||||
*/
|
||||
.vhost-manager-container {
|
||||
background: linear-gradient(135deg, var(--vh-dark) 0%, var(--vh-darker) 100%);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin: 16px 0;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
.vhost-nav-tabs {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.vhost-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid var(--vh-border);
|
||||
.vhost-card-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.vhost-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
background: var(--vh-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.vhost-card {
|
||||
background: var(--sh-bg-card, rgba(17, 24, 39, 0.92));
|
||||
border: 1px solid var(--sh-border, rgba(148, 163, 184, 0.2));
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
box-shadow: 0 12px 30px rgba(2, 6, 23, 0.35);
|
||||
}
|
||||
|
||||
.vhost-title::before {
|
||||
content: "🌐";
|
||||
font-size: 28px;
|
||||
-webkit-text-fill-color: initial;
|
||||
.vhost-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.vhost-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
.vhost-card-meta {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--sh-text-secondary, #94a3b8);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.vh-stat-card {
|
||||
background: var(--vh-light);
|
||||
border: 1px solid var(--vh-border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
.vhost-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.vh-stat-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: var(--vh-gradient);
|
||||
.vhost-pill.success {
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.vh-stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.2);
|
||||
.vhost-pill.warn {
|
||||
border-color: rgba(245, 158, 11, 0.35);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.vh-stat-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #999;
|
||||
margin-bottom: 8px;
|
||||
.vhost-pill.danger {
|
||||
border-color: rgba(248, 113, 113, 0.35);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.vh-stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
background: var(--vh-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
.vhost-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.vh-stat-icon {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
font-size: 32px;
|
||||
opacity: 0.3;
|
||||
.vhost-table th,
|
||||
.vhost-table td {
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Virtual Hosts List */
|
||||
.vhost-list {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
.vhost-table th {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--sh-text-secondary, #94a3b8);
|
||||
}
|
||||
|
||||
.vhost-item {
|
||||
background: var(--vh-light);
|
||||
border: 1px solid var(--vh-border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
transition: all 0.2s;
|
||||
.vhost-table tbody tr:hover {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.vhost-item:hover {
|
||||
border-color: var(--vh-primary);
|
||||
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.2);
|
||||
}
|
||||
|
||||
.vhost-item.active {
|
||||
border-left: 4px solid var(--vh-primary);
|
||||
}
|
||||
|
||||
.vhost-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vhost-domain {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--vh-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vhost-domain::before {
|
||||
content: "🔗";
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.vhost-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.vhost-status.online {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--vh-success);
|
||||
}
|
||||
|
||||
.vhost-status.offline {
|
||||
background: rgba(156, 163, 175, 0.2);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.vhost-status.error {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--vh-danger);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
animation: pulse-status 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-status {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.vhost-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vhost-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: #666;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
.vhost-empty {
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.4);
|
||||
border-radius: 12px;
|
||||
color: var(--sh-text-secondary, #94a3b8);
|
||||
}
|
||||
|
||||
.vhost-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--vh-border);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* SSL Certificate Badge */
|
||||
.ssl-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
.vhost-form-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.ssl-badge.valid {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--vh-success);
|
||||
.vhost-form-grid label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--sh-text-secondary, #94a3b8);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ssl-badge.expired {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: var(--vh-warning);
|
||||
.vhost-form-grid input,
|
||||
.vhost-form-grid select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
color: var(--sh-text-primary, #f8fafc);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ssl-badge.none {
|
||||
background: rgba(156, 163, 175, 0.2);
|
||||
color: #9ca3af;
|
||||
.vhost-log-terminal {
|
||||
background: #050816;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(15, 118, 230, 0.4);
|
||||
box-shadow: inset 0 0 24px rgba(14, 165, 233, 0.12);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #12f7d6;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ssl-badge::before {
|
||||
content: "🔒";
|
||||
font-size: 12px;
|
||||
.vhost-status-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Service Redirects */
|
||||
.redirect-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
.vhost-status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: var(--sh-text-secondary, #a0aec0);
|
||||
}
|
||||
|
||||
.redirect-item {
|
||||
background: var(--vh-light);
|
||||
border: 1px solid var(--vh-border);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.vhost-status-item strong {
|
||||
color: var(--sh-text-primary, #f1f5f9);
|
||||
}
|
||||
|
||||
.redirect-route {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
.vhost-filter-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.redirect-from {
|
||||
color: var(--vh-warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.redirect-arrow {
|
||||
color: #666;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.redirect-to {
|
||||
color: var(--vh-success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.redirect-type {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.redirect-type.proxy {
|
||||
background: rgba(6, 182, 212, 0.2);
|
||||
color: var(--vh-primary);
|
||||
}
|
||||
|
||||
.redirect-type.dns {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
/* Service Templates */
|
||||
.service-templates {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.service-template {
|
||||
background: var(--vh-light);
|
||||
border: 1px solid var(--vh-border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.service-template:hover {
|
||||
border-color: var(--vh-primary);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.3);
|
||||
}
|
||||
|
||||
.service-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.service-desc {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Nginx/HAProxy Config Preview */
|
||||
.config-preview {
|
||||
background: var(--vh-dark);
|
||||
border: 1px solid var(--vh-border);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.config-line {
|
||||
color: #ccc;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.config-keyword {
|
||||
color: var(--vh-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.config-value {
|
||||
color: var(--vh-success);
|
||||
}
|
||||
|
||||
.config-comment {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Let's Encrypt Setup */
|
||||
.acme-setup {
|
||||
background: var(--vh-light);
|
||||
border: 1px solid var(--vh-border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.acme-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.acme-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.acme-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.acme-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.acme-subtitle {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.acme-domains {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.domain-tag {
|
||||
padding: 6px 12px;
|
||||
background: var(--vh-dark);
|
||||
border: 1px solid var(--vh-border);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.domain-tag.verified {
|
||||
border-color: var(--vh-success);
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.vh-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.vh-btn-primary {
|
||||
background: var(--vh-gradient);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.vh-btn-primary:hover {
|
||||
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.vh-btn-secondary {
|
||||
background: var(--vh-light);
|
||||
color: #ccc;
|
||||
border: 1px solid var(--vh-border);
|
||||
}
|
||||
|
||||
.vh-btn-secondary:hover {
|
||||
background: var(--vh-border);
|
||||
}
|
||||
|
||||
.vh-btn-danger {
|
||||
background: var(--vh-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.vh-btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.vh-btn-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.vh-form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.vh-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vh-input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--vh-light);
|
||||
border: 1px solid var(--vh-border);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.vh-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--vh-primary);
|
||||
box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.1);
|
||||
}
|
||||
|
||||
.vh-select {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--vh-light);
|
||||
border: 1px solid var(--vh-border);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Info Boxes */
|
||||
.vh-info-box {
|
||||
background: var(--vh-light);
|
||||
border-left: 4px solid var(--vh-info);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vh-info-box.warning {
|
||||
border-left-color: var(--vh-warning);
|
||||
}
|
||||
|
||||
.vh-info-box.danger {
|
||||
border-left-color: var(--vh-danger);
|
||||
}
|
||||
|
||||
.vh-info-box.success {
|
||||
border-left-color: var(--vh-success);
|
||||
}
|
||||
|
||||
.vh-info-icon {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vh-info-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vh-info-title {
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vh-info-text {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.vhost-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.service-templates {
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.vhost-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.redirect-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.redirect-route {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.redirect-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.vh-loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.vh-loading::before {
|
||||
content: "⚙️";
|
||||
font-size: 48px;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.vh-empty {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.vh-empty::before {
|
||||
content: "🏠";
|
||||
font-size: 64px;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.vh-empty-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vh-empty-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
.vhost-filter-tags .vhost-pill {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
return baseclass.extend({
|
||||
tabs: [
|
||||
{ id: 'overview', icon: '📊', label: _('Overview'), path: ['admin', 'secubox', 'services', 'vhosts', 'overview'] },
|
||||
{ id: 'vhosts', icon: '🗂️', label: _('Virtual Hosts'), path: ['admin', 'secubox', 'services', 'vhosts', 'vhosts'] },
|
||||
{ id: 'internal', icon: '🏠', label: _('Internal Services'), path: ['admin', 'secubox', 'services', 'vhosts', 'internal'] },
|
||||
{ id: 'certificates', icon: '🔐', label: _('Certificates'), path: ['admin', 'secubox', 'services', 'vhosts', 'certificates'] },
|
||||
{ id: 'ssl', icon: '⚙️', label: _('SSL/TLS'), path: ['admin', 'secubox', 'services', 'vhosts', 'ssl'] },
|
||||
{ id: 'redirects', icon: '↪️', label: _('Redirects'), path: ['admin', 'secubox', 'services', 'vhosts', 'redirects'] },
|
||||
{ id: 'logs', icon: '📜', label: _('Logs'), path: ['admin', 'secubox', 'services', 'vhosts', 'logs'] }
|
||||
],
|
||||
|
||||
renderTabs: function(active) {
|
||||
return E('div', { 'class': 'sh-nav-tabs vhost-nav-tabs' },
|
||||
this.getTabs().map(function(tab) {
|
||||
return E('a', {
|
||||
'class': 'sh-nav-tab' + (tab.id === active ? ' active' : ''),
|
||||
'href': L.url.apply(L, tab.path)
|
||||
}, [
|
||||
E('span', { 'class': 'sh-tab-icon' }, tab.icon),
|
||||
E('span', { 'class': 'sh-tab-label' }, tab.label)
|
||||
]);
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
getTabs: function() {
|
||||
return this.tabs.slice();
|
||||
}
|
||||
});
|
||||
@ -2,163 +2,213 @@
|
||||
'require view';
|
||||
'require ui';
|
||||
'require vhost-manager/api as API';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require vhost-manager/ui as VHostUI';
|
||||
|
||||
var lang = (typeof L !== 'undefined' && L.env && L.env.lang) ||
|
||||
(document.documentElement && document.documentElement.getAttribute('lang')) ||
|
||||
(navigator.language ? navigator.language.split('-')[0] : 'en');
|
||||
Theme.init({ language: lang });
|
||||
|
||||
function normalizeCerts(payload) {
|
||||
if (Array.isArray(payload))
|
||||
return payload;
|
||||
if (payload && Array.isArray(payload.certificates))
|
||||
return payload.certificates;
|
||||
return [];
|
||||
}
|
||||
|
||||
function daysUntil(dateStr) {
|
||||
if (!dateStr)
|
||||
return null;
|
||||
var ts = Date.parse(dateStr);
|
||||
if (isNaN(ts))
|
||||
return null;
|
||||
return Math.round((ts - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr)
|
||||
return _('N/A');
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
} catch (err) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
return L.view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
API.listCerts()
|
||||
API.listCerts(),
|
||||
API.getStatus()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var certs = data[0] || [];
|
||||
var certs = normalizeCerts(data[0]);
|
||||
var status = data[1] || {};
|
||||
|
||||
var v = E('div', { 'class': 'cbi-map' }, [
|
||||
E('h2', {}, _('SSL Certificates')),
|
||||
E('div', { 'class': 'cbi-map-descr' }, _('Manage Let\'s Encrypt SSL certificates'))
|
||||
return E('div', { 'class': 'vhost-page' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('vhost-manager/common.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('vhost-manager/dashboard.css') }),
|
||||
VHostUI.renderTabs('certificates'),
|
||||
this.renderHeader(certs, status),
|
||||
this.renderRequestCard(),
|
||||
this.renderCertTable(certs)
|
||||
]);
|
||||
|
||||
// Request new certificate section
|
||||
var requestSection = E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Request New Certificate')),
|
||||
E('div', { 'class': 'cbi-section-node' }, [
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Domain')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'id': 'cert-domain',
|
||||
'placeholder': 'example.com'
|
||||
})
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Email')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('input', {
|
||||
'type': 'email',
|
||||
'class': 'cbi-input-text',
|
||||
'id': 'cert-email',
|
||||
'placeholder': 'admin@example.com'
|
||||
})
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, ''),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': function(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
var domain = document.getElementById('cert-domain').value;
|
||||
var email = document.getElementById('cert-email').value;
|
||||
|
||||
if (!domain || !email) {
|
||||
ui.addNotification(null, E('p', _('Domain and email are required')), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
ui.addNotification(null, E('p', _('Requesting certificate... This may take a few minutes.')), 'info');
|
||||
|
||||
API.requestCert(domain, email).then(function(result) {
|
||||
if (result.success) {
|
||||
ui.addNotification(null, E('p', '✓ ' + _('Certificate obtained successfully')), 'info');
|
||||
window.location.reload();
|
||||
} else {
|
||||
ui.addNotification(null, E('p', '✗ ' + result.message), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, _('Request Certificate'))
|
||||
])
|
||||
])
|
||||
])
|
||||
]);
|
||||
v.appendChild(requestSection);
|
||||
|
||||
// Certificates list
|
||||
if (certs.length > 0) {
|
||||
var certsSection = E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Installed Certificates'))
|
||||
]);
|
||||
|
||||
var table = E('table', { 'class': 'table' }, [
|
||||
E('tr', { 'class': 'tr table-titles' }, [
|
||||
E('th', { 'class': 'th' }, _('Domain')),
|
||||
E('th', { 'class': 'th' }, _('Issuer')),
|
||||
E('th', { 'class': 'th' }, _('Expires')),
|
||||
E('th', { 'class': 'th' }, _('Actions'))
|
||||
])
|
||||
]);
|
||||
|
||||
certs.forEach(function(cert) {
|
||||
var expiresDate = new Date(cert.expires);
|
||||
var daysLeft = Math.floor((expiresDate - new Date()) / (1000 * 60 * 60 * 24));
|
||||
var expiresColor = daysLeft < 7 ? 'red' : (daysLeft < 30 ? 'orange' : 'green');
|
||||
|
||||
table.appendChild(E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td' }, cert.domain),
|
||||
E('td', { 'class': 'td' }, cert.issuer || 'N/A'),
|
||||
E('td', { 'class': 'td' }, [
|
||||
E('span', { 'style': 'color: ' + expiresColor }, cert.expires),
|
||||
E('br'),
|
||||
E('small', {}, daysLeft + ' ' + _('days remaining'))
|
||||
]),
|
||||
E('td', { 'class': 'td' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': function(ev) {
|
||||
ui.showModal(_('Certificate Details'), [
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('p', {}, [
|
||||
E('strong', {}, _('Domain: ')),
|
||||
E('span', {}, cert.domain)
|
||||
]),
|
||||
E('p', {}, [
|
||||
E('strong', {}, _('Subject: ')),
|
||||
E('span', {}, cert.subject || 'N/A')
|
||||
]),
|
||||
E('p', {}, [
|
||||
E('strong', {}, _('Issuer: ')),
|
||||
E('span', {}, cert.issuer || 'N/A')
|
||||
]),
|
||||
E('p', {}, [
|
||||
E('strong', {}, _('Expires: ')),
|
||||
E('span', {}, cert.expires)
|
||||
]),
|
||||
E('p', {}, [
|
||||
E('strong', {}, _('File: ')),
|
||||
E('code', {}, cert.cert_file)
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-neutral',
|
||||
'click': ui.hideModal
|
||||
}, _('Close'))
|
||||
])
|
||||
]);
|
||||
}
|
||||
}, _('Details'))
|
||||
])
|
||||
]));
|
||||
});
|
||||
|
||||
certsSection.appendChild(table);
|
||||
v.appendChild(certsSection);
|
||||
} else {
|
||||
v.appendChild(E('div', { 'class': 'cbi-section' }, [
|
||||
E('p', { 'style': 'font-style: italic; text-align: center; padding: 20px' },
|
||||
_('No SSL certificates installed'))
|
||||
]));
|
||||
}
|
||||
|
||||
return v;
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
renderHeader: function(certs, status) {
|
||||
var expiringSoon = certs.filter(function(cert) {
|
||||
var days = daysUntil(cert.expires);
|
||||
return days !== null && days <= 30;
|
||||
}).length;
|
||||
|
||||
return E('div', { 'class': 'sh-page-header' }, [
|
||||
E('div', {}, [
|
||||
E('h2', { 'class': 'sh-page-title' }, [
|
||||
E('span', { 'class': 'sh-page-title-icon' }, '🔐'),
|
||||
_('SSL Certificates')
|
||||
]),
|
||||
E('p', { 'class': 'sh-page-subtitle' },
|
||||
_('Request Let\'s Encrypt certificates and monitor expiry across all proxies.'))
|
||||
]),
|
||||
E('div', { 'class': 'sh-stats-grid' }, [
|
||||
this.renderStatBadge(certs.length, _('Installed')),
|
||||
this.renderStatBadge(expiringSoon, _('Expiring < 30d')),
|
||||
this.renderStatBadge(status.acme_available ? _('ACME Ready') : _('ACME Missing'), _('Automation')),
|
||||
this.renderStatBadge(status.nginx_running ? _('Nginx OK') : _('Nginx down'), _('Web server'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderStatBadge: function(value, label) {
|
||||
return E('div', { 'class': 'sh-stat-badge' }, [
|
||||
E('div', { 'class': 'sh-stat-value' }, value.toString()),
|
||||
E('div', { 'class': 'sh-stat-label' }, label)
|
||||
]);
|
||||
},
|
||||
|
||||
renderRequestCard: function() {
|
||||
var domainInput = E('input', { 'type': 'text', 'placeholder': 'cloud.example.com' });
|
||||
var emailInput = E('input', { 'type': 'email', 'placeholder': 'admin@example.com' });
|
||||
|
||||
return E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, ['🪄', _('Request Certificate')]),
|
||||
E('p', { 'class': 'vhost-card-meta' }, _('Issue a Let\'s Encrypt certificate using HTTP-01 validation.')),
|
||||
E('div', { 'class': 'vhost-form-grid' }, [
|
||||
E('div', {}, [
|
||||
E('label', {}, _('Domain')),
|
||||
domainInput
|
||||
]),
|
||||
E('div', {}, [
|
||||
E('label', {}, _('Contact Email')),
|
||||
emailInput
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'vhost-actions' }, [
|
||||
E('button', {
|
||||
'class': 'sh-btn-primary',
|
||||
'click': this.requestCert.bind(this, domainInput, emailInput)
|
||||
}, _('Request certificate'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderCertTable: function(certs) {
|
||||
return E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, ['📋', _('Installed Certificates')]),
|
||||
certs.length ? E('table', { 'class': 'vhost-table' }, [
|
||||
E('thead', {}, E('tr', {}, [
|
||||
E('th', {}, _('Domain')),
|
||||
E('th', {}, _('Issuer')),
|
||||
E('th', {}, _('Expires')),
|
||||
E('th', {}, _('Status')),
|
||||
E('th', {}, _('Actions'))
|
||||
])),
|
||||
E('tbody', {},
|
||||
certs.map(this.renderCertRow, this))
|
||||
]) : E('div', { 'class': 'vhost-empty' }, _('No certificates issued yet.'))
|
||||
]);
|
||||
},
|
||||
|
||||
renderCertRow: function(cert) {
|
||||
var days = daysUntil(cert.expires);
|
||||
var pill = 'success';
|
||||
var label = _('Valid');
|
||||
|
||||
if (days === null) {
|
||||
pill = 'danger';
|
||||
label = _('Unknown');
|
||||
} else if (days <= 7) {
|
||||
pill = 'danger';
|
||||
label = _('Expiring in %d days').format(days);
|
||||
} else if (days <= 30) {
|
||||
pill = 'warn';
|
||||
label = _('Renew soon (%d days)').format(days);
|
||||
}
|
||||
|
||||
return E('tr', {}, [
|
||||
E('td', {}, cert.domain),
|
||||
E('td', {}, cert.issuer || _('Unknown')),
|
||||
E('td', {}, formatDate(cert.expires)),
|
||||
E('td', {}, E('span', { 'class': 'vhost-pill ' + pill }, label)),
|
||||
E('td', {}, E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': function(ev) {
|
||||
ev.preventDefault();
|
||||
ui.showModal(_('Certificate Details'), [
|
||||
E('p', {}, [
|
||||
E('strong', {}, _('Domain: ')),
|
||||
E('span', {}, cert.domain)
|
||||
]),
|
||||
E('p', {}, [
|
||||
E('strong', {}, _('Subject: ')),
|
||||
E('span', {}, cert.subject || _('Unknown'))
|
||||
]),
|
||||
E('p', {}, [
|
||||
E('strong', {}, _('Issuer: ')),
|
||||
E('span', {}, cert.issuer || _('Unknown'))
|
||||
]),
|
||||
E('p', {}, [
|
||||
E('strong', {}, _('Expires: ')),
|
||||
E('span', {}, formatDate(cert.expires))
|
||||
]),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-neutral',
|
||||
'click': ui.hideModal
|
||||
}, _('Close'))
|
||||
])
|
||||
]);
|
||||
}
|
||||
}, _('Details')))
|
||||
]);
|
||||
},
|
||||
|
||||
requestCert: function(domainInput, emailInput, ev) {
|
||||
if (ev)
|
||||
ev.preventDefault();
|
||||
|
||||
var domain = domainInput.value.trim();
|
||||
var email = emailInput.value.trim();
|
||||
|
||||
if (!domain || !email) {
|
||||
ui.addNotification(null, E('p', _('Domain and email are required')), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
ui.addNotification(null, E('p', _('Requesting certificate... This may take a few minutes.')), 'info');
|
||||
|
||||
API.requestCert(domain, email).then(function(result) {
|
||||
if (result.success) {
|
||||
ui.addNotification(null, E('p', '✓ ' + _('Certificate obtained successfully')), 'info');
|
||||
window.location.reload();
|
||||
} else {
|
||||
ui.addNotification(null, E('p', '✗ ' + (result.message || _('Request failed'))), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,34 +1,95 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require vhost-manager.api as api';
|
||||
'require vhost-manager/api as API';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require vhost-manager/ui as VHostUI';
|
||||
|
||||
var lang = (typeof L !== 'undefined' && L.env && L.env.lang) ||
|
||||
(document.documentElement && document.documentElement.getAttribute('lang')) ||
|
||||
(navigator.language ? navigator.language.split('-')[0] : 'en');
|
||||
Theme.init({ language: lang });
|
||||
|
||||
var SERVICES = [
|
||||
{ icon: '🖥️', name: _('LuCI UI'), domain: 'router.local', backend: 'http://127.0.0.1:80', category: _('Core'), description: _('Expose the management UI behind nginx with optional SSL and auth.') },
|
||||
{ icon: '📈', name: _('Netdata'), domain: 'metrics.local', backend: 'http://127.0.0.1:19999', category: _('Monitoring'), description: _('High-resolution telemetry for CPU, memory, and interfaces.') },
|
||||
{ icon: '🛡️', name: _('CrowdSec'), domain: 'crowdsec.local', backend: 'http://127.0.0.1:8080', category: _('Security'), description: _('Review bouncer decisions and live intrusion alerts.') },
|
||||
{ icon: '🏠', name: _('Home Assistant'), domain: 'home.local', backend: 'http://192.168.1.13:8123', category: _('Automation'), description: _('Publish your smart-home UI securely with SSL and auth.') },
|
||||
{ icon: '🎬', name: _('Media Server'), domain: 'media.local', backend: 'http://192.168.1.12:8096', category: _('Entertainment'), description: _('Jellyfin or Plex front-end available via a friendly hostname.') },
|
||||
{ icon: '🗄️', name: _('Nextcloud'), domain: 'cloud.local', backend: 'http://192.168.1.20:80', category: _('Productivity'), description: _('Bring private SaaS back on-prem with HTTPS and caching headers.') }
|
||||
];
|
||||
|
||||
return view.extend({
|
||||
load: function() { return api.getInternalHosts(); },
|
||||
render: function(data) {
|
||||
var hosts = data.hosts || [];
|
||||
return E('div', {class:'cbi-map'}, [
|
||||
E('h2', {}, '🏠 Internal Virtual Hosts'),
|
||||
E('p', {style:'color:#94a3b8;margin-bottom:20px'}, 'Self-hosted services accessible from your local network.'),
|
||||
E('div', {style:'background:#1e293b;padding:20px;border-radius:12px'}, [
|
||||
E('table', {style:'width:100%;color:#f1f5f9'}, [
|
||||
E('tr', {style:'border-bottom:1px solid #334155'}, [
|
||||
E('th', {style:'padding:12px;text-align:left'}, 'Service'),
|
||||
E('th', {style:'padding:12px'}, 'Domain'),
|
||||
E('th', {style:'padding:12px'}, 'Backend'),
|
||||
E('th', {style:'padding:12px'}, 'SSL'),
|
||||
E('th', {style:'padding:12px'}, 'Status')
|
||||
])
|
||||
].concat(hosts.map(function(h) {
|
||||
return E('tr', {}, [
|
||||
E('td', {style:'padding:12px;font-weight:600'}, h.name),
|
||||
E('td', {style:'padding:12px;font-family:monospace;color:#10b981'}, h.domain),
|
||||
E('td', {style:'padding:12px;font-family:monospace;color:#64748b'}, h.backend),
|
||||
E('td', {style:'padding:12px;text-align:center'}, h.ssl ? '🔒' : '🔓'),
|
||||
E('td', {style:'padding:12px'}, E('span', {style:'padding:4px 8px;border-radius:4px;background:'+(h.enabled?'#22c55e20;color:#22c55e':'#64748b20;color:#64748b')}, h.enabled ? 'Active' : 'Disabled'))
|
||||
]);
|
||||
})))
|
||||
])
|
||||
]);
|
||||
},
|
||||
handleSaveApply:null,handleSave:null,handleReset:null
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
API.listVHosts()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var vhosts = data[0] || [];
|
||||
var active = {};
|
||||
vhosts.forEach(function(v) {
|
||||
active[v.domain] = true;
|
||||
});
|
||||
|
||||
return E('div', { 'class': 'vhost-page' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('vhost-manager/common.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('vhost-manager/dashboard.css') }),
|
||||
VHostUI.renderTabs('internal'),
|
||||
this.renderHeader(vhosts),
|
||||
this.renderServices(active)
|
||||
]);
|
||||
},
|
||||
|
||||
renderHeader: function(vhosts) {
|
||||
var configured = vhosts.filter(function(vhost) {
|
||||
return SERVICES.some(function(s) { return s.domain === vhost.domain; });
|
||||
}).length;
|
||||
|
||||
return E('div', { 'class': 'sh-page-header' }, [
|
||||
E('div', {}, [
|
||||
E('h2', { 'class': 'sh-page-title' }, [
|
||||
E('span', { 'class': 'sh-page-title-icon' }, '🏠'),
|
||||
_('Internal Service Catalog')
|
||||
]),
|
||||
E('p', { 'class': 'sh-page-subtitle' },
|
||||
_('Pre-built recipes for publishing popular LAN services with SSL, auth, and redirects.'))
|
||||
]),
|
||||
E('div', { 'class': 'sh-stats-grid' }, [
|
||||
this.renderStat(SERVICES.length, _('Templates')),
|
||||
this.renderStat(configured, _('Configured'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderStat: function(value, label) {
|
||||
return E('div', { 'class': 'sh-stat-badge' }, [
|
||||
E('div', { 'class': 'sh-stat-value' }, value.toString()),
|
||||
E('div', { 'class': 'sh-stat-label' }, label)
|
||||
]);
|
||||
},
|
||||
|
||||
renderServices: function(active) {
|
||||
return E('div', { 'class': 'vhost-card-grid' },
|
||||
SERVICES.map(function(service) {
|
||||
var isActive = !!active[service.domain];
|
||||
return E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, [service.icon, service.name]),
|
||||
E('div', { 'class': 'vhost-card-meta' }, service.category),
|
||||
E('p', { 'class': 'vhost-card-meta' }, service.description),
|
||||
E('div', { 'class': 'vhost-card-meta' }, _('Domain: %s').format(service.domain)),
|
||||
E('div', { 'class': 'vhost-card-meta' }, _('Backend: %s').format(service.backend)),
|
||||
E('div', { 'class': 'vhost-actions' }, [
|
||||
E('span', { 'class': 'vhost-pill ' + (isActive ? 'success' : '') },
|
||||
isActive ? _('Published') : _('Not configured')),
|
||||
E('a', {
|
||||
'class': 'sh-btn-secondary',
|
||||
'href': L.url('admin', 'secubox', 'services', 'vhosts', 'vhosts')
|
||||
}, isActive ? _('Manage') : _('Create'))
|
||||
])
|
||||
]);
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require form';
|
||||
'require ui';
|
||||
'require vhost-manager/api as API';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require vhost-manager/ui as VHostUI';
|
||||
|
||||
var lang = (typeof L !== 'undefined' && L.env && L.env.lang) ||
|
||||
(document.documentElement && document.documentElement.getAttribute('lang')) ||
|
||||
(navigator.language ? navigator.language.split('-')[0] : 'en');
|
||||
Theme.init({ language: lang });
|
||||
|
||||
return L.view.extend({
|
||||
load: function() {
|
||||
@ -11,58 +18,122 @@ return L.view.extend({
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var vhosts = data[0] || [];
|
||||
this.vhosts = data[0] || [];
|
||||
this.domainSelect = this.createDomainSelect();
|
||||
this.lineSelect = this.createLineSelect();
|
||||
this.logOutput = E('pre', { 'class': 'vhost-log-terminal' }, _('Select a domain to view logs'));
|
||||
|
||||
var m = new form.Map('vhost_manager', _('Access Logs'),
|
||||
_('View nginx access logs for virtual hosts'));
|
||||
return E('div', { 'class': 'vhost-page' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('vhost-manager/common.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('vhost-manager/dashboard.css') }),
|
||||
VHostUI.renderTabs('logs'),
|
||||
this.renderHeader(),
|
||||
this.renderControls()
|
||||
]);
|
||||
},
|
||||
|
||||
var s = m.section(form.NamedSection, '__logs', 'logs');
|
||||
s.anonymous = true;
|
||||
s.addremove = false;
|
||||
renderHeader: function() {
|
||||
return E('div', { 'class': 'sh-page-header' }, [
|
||||
E('div', {}, [
|
||||
E('h2', { 'class': 'sh-page-title' }, [
|
||||
E('span', { 'class': 'sh-page-title-icon' }, '📜'),
|
||||
_('Access Logs')
|
||||
]),
|
||||
E('p', { 'class': 'sh-page-subtitle' },
|
||||
_('Tail nginx access logs per virtual host without SSHing into the router.'))
|
||||
]),
|
||||
E('div', { 'class': 'sh-stats-grid' }, [
|
||||
this.renderStat(this.vhosts.length, _('Domains')),
|
||||
this.renderStat('50-500', _('Lines'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
var o;
|
||||
renderStat: function(value, label) {
|
||||
return E('div', { 'class': 'sh-stat-badge' }, [
|
||||
E('div', { 'class': 'sh-stat-value' }, value.toString()),
|
||||
E('div', { 'class': 'sh-stat-label' }, label)
|
||||
]);
|
||||
},
|
||||
|
||||
o = s.option(form.ListValue, 'domain', _('Select Domain'));
|
||||
o.rmempty = false;
|
||||
|
||||
vhosts.forEach(function(vhost) {
|
||||
o.value(vhost.domain, vhost.domain);
|
||||
});
|
||||
|
||||
if (vhosts.length === 0) {
|
||||
o.value('', _('No virtual hosts configured'));
|
||||
renderControls: function() {
|
||||
if (!this.vhosts.length) {
|
||||
return E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, ['🪵', _('Logs')]),
|
||||
E('div', { 'class': 'vhost-empty' }, _('No virtual hosts configured yet, logs unavailable.'))
|
||||
]);
|
||||
}
|
||||
|
||||
o = s.option(form.ListValue, 'lines', _('Number of Lines'));
|
||||
o.value('50', '50');
|
||||
o.value('100', '100');
|
||||
o.value('200', '200');
|
||||
o.value('500', '500');
|
||||
o.default = '50';
|
||||
this.domainSelect.addEventListener('change', this.fetchLogs.bind(this));
|
||||
this.lineSelect.addEventListener('change', this.fetchLogs.bind(this));
|
||||
|
||||
s.render = L.bind(function(view, section_id) {
|
||||
var domain = this.section.formvalue(section_id, 'domain');
|
||||
var lines = parseInt(this.section.formvalue(section_id, 'lines')) || 50;
|
||||
return E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, ['🧾', _('Viewer')]),
|
||||
E('div', { 'class': 'vhost-form-grid' }, [
|
||||
E('div', {}, [
|
||||
E('label', {}, _('Domain')),
|
||||
this.domainSelect
|
||||
]),
|
||||
E('div', {}, [
|
||||
E('label', {}, _('Lines')),
|
||||
this.lineSelect
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'vhost-actions' }, [
|
||||
E('button', {
|
||||
'class': 'sh-btn-secondary',
|
||||
'click': this.fetchLogs.bind(this)
|
||||
}, _('Refresh'))
|
||||
]),
|
||||
this.logOutput
|
||||
]);
|
||||
},
|
||||
|
||||
if (!domain || vhosts.length === 0) {
|
||||
return E('div', { 'class': 'cbi-section' }, [
|
||||
E('p', { 'style': 'font-style: italic' }, _('No virtual hosts to display logs for'))
|
||||
]);
|
||||
}
|
||||
createDomainSelect: function() {
|
||||
var select = E('select', {});
|
||||
this.vhosts.forEach(function(vhost, idx) {
|
||||
select.appendChild(E('option', {
|
||||
'value': vhost.domain,
|
||||
'selected': idx === 0
|
||||
}, vhost.domain));
|
||||
});
|
||||
return select;
|
||||
},
|
||||
|
||||
return API.getAccessLogs(domain, lines).then(L.bind(function(data) {
|
||||
var logs = data.logs || [];
|
||||
createLineSelect: function() {
|
||||
var select = E('select', {});
|
||||
[50, 100, 200, 500].forEach(function(value) {
|
||||
select.appendChild(E('option', {
|
||||
'value': value,
|
||||
'selected': value === 50
|
||||
}, value.toString()));
|
||||
});
|
||||
return select;
|
||||
},
|
||||
|
||||
return E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Access Logs for: ') + domain),
|
||||
E('pre', {
|
||||
'style': 'background: #000; color: #0f0; padding: 10px; overflow: auto; max-height: 500px; font-size: 11px; font-family: monospace'
|
||||
}, logs.length > 0 ? logs.join('\n') : _('No logs available'))
|
||||
]);
|
||||
}, this));
|
||||
}, this, this);
|
||||
fetchLogs: function(ev) {
|
||||
if (ev)
|
||||
ev.preventDefault();
|
||||
|
||||
return m.render();
|
||||
var domain = this.domainSelect.value;
|
||||
var lines = parseInt(this.lineSelect.value, 10) || 50;
|
||||
|
||||
if (!domain) {
|
||||
this.logOutput.textContent = _('Select a domain to view logs');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.logOutput.textContent = _('Loading logs for %s ...').format(domain);
|
||||
|
||||
return API.getAccessLogs(domain, lines).then(function(result) {
|
||||
var logs = (result.logs || []).join('\n');
|
||||
this.logOutput.textContent = logs || _('No logs available');
|
||||
}.bind(this)).catch(function(err) {
|
||||
var message = err && err.message ? err.message : _('Unable to load logs');
|
||||
ui.addNotification(null, E('p', message), 'error');
|
||||
this.logOutput.textContent = message;
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
|
||||
@ -1,10 +1,42 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require poll';
|
||||
'require ui';
|
||||
'require vhost-manager/api as API';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require vhost-manager/ui as VHostUI';
|
||||
|
||||
return L.view.extend({
|
||||
var lang = (typeof L !== 'undefined' && L.env && L.env.lang) ||
|
||||
(document.documentElement && document.documentElement.getAttribute('lang')) ||
|
||||
(navigator.language ? navigator.language.split('-')[0] : 'en');
|
||||
Theme.init({ language: lang });
|
||||
|
||||
function normalizeCerts(payload) {
|
||||
if (Array.isArray(payload))
|
||||
return payload;
|
||||
if (payload && Array.isArray(payload.certificates))
|
||||
return payload.certificates;
|
||||
return [];
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value)
|
||||
return _('N/A');
|
||||
try {
|
||||
return new Date(value).toLocaleDateString();
|
||||
} catch (err) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function daysUntil(dateStr) {
|
||||
if (!dateStr)
|
||||
return null;
|
||||
var ts = Date.parse(dateStr);
|
||||
if (isNaN(ts))
|
||||
return null;
|
||||
return Math.round((ts - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
API.getStatus(),
|
||||
@ -16,118 +48,172 @@ return L.view.extend({
|
||||
render: function(data) {
|
||||
var status = data[0] || {};
|
||||
var vhosts = data[1] || [];
|
||||
var certs = data[2] || [];
|
||||
var certs = normalizeCerts(data[2]);
|
||||
|
||||
var v = E('div', { 'class': 'cbi-map' }, [
|
||||
E('h2', {}, _('VHost Manager - Overview')),
|
||||
E('div', { 'class': 'cbi-map-descr' }, _('Nginx reverse proxy and SSL certificate management'))
|
||||
return E('div', { 'class': 'vhost-page' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('vhost-manager/common.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('vhost-manager/dashboard.css') }),
|
||||
VHostUI.renderTabs('overview'),
|
||||
this.renderHeader(status, vhosts, certs),
|
||||
this.renderHealth(status),
|
||||
this.renderVhostTable(vhosts, certs),
|
||||
this.renderCertWatch(certs)
|
||||
]);
|
||||
|
||||
// Status section
|
||||
var statusSection = E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('System Status')),
|
||||
E('div', { 'class': 'table' }, [
|
||||
E('div', { 'class': 'tr' }, [
|
||||
E('div', { 'class': 'td left', 'width': '33%' }, [
|
||||
E('strong', {}, _('Nginx: ')),
|
||||
E('span', {}, status.nginx_running ?
|
||||
E('span', { 'style': 'color: green' }, '● ' + _('Running')) :
|
||||
E('span', { 'style': 'color: red' }, '● ' + _('Stopped'))
|
||||
),
|
||||
E('br'),
|
||||
E('small', {}, _('Version: ') + (status.nginx_version || 'unknown'))
|
||||
]),
|
||||
E('div', { 'class': 'td left', 'width': '33%' }, [
|
||||
E('strong', {}, _('ACME/SSL: ')),
|
||||
E('span', {}, status.acme_available ?
|
||||
E('span', { 'style': 'color: green' }, '✓ ' + _('Available')) :
|
||||
E('span', { 'style': 'color: orange' }, '✗ ' + _('Not installed'))
|
||||
),
|
||||
E('br'),
|
||||
E('small', {}, status.acme_version || 'N/A')
|
||||
]),
|
||||
E('div', { 'class': 'td left', 'width': '33%' }, [
|
||||
E('strong', {}, _('Virtual Hosts: ')),
|
||||
E('span', { 'style': 'font-size: 1.5em; color: #0088cc' }, String(status.vhost_count || 0))
|
||||
])
|
||||
])
|
||||
])
|
||||
]);
|
||||
v.appendChild(statusSection);
|
||||
|
||||
// Quick stats
|
||||
var sslCount = 0;
|
||||
var authCount = 0;
|
||||
var wsCount = 0;
|
||||
|
||||
vhosts.forEach(function(vhost) {
|
||||
if (vhost.ssl) sslCount++;
|
||||
if (vhost.auth) authCount++;
|
||||
if (vhost.websocket) wsCount++;
|
||||
});
|
||||
|
||||
var statsSection = E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Virtual Hosts Summary')),
|
||||
E('div', { 'class': 'table' }, [
|
||||
E('div', { 'class': 'tr' }, [
|
||||
E('div', { 'class': 'td left', 'width': '25%' }, [
|
||||
E('strong', {}, '🔒 SSL Enabled: '),
|
||||
E('span', {}, String(sslCount))
|
||||
]),
|
||||
E('div', { 'class': 'td left', 'width': '25%' }, [
|
||||
E('strong', {}, '🔐 Auth Protected: '),
|
||||
E('span', {}, String(authCount))
|
||||
]),
|
||||
E('div', { 'class': 'td left', 'width': '25%' }, [
|
||||
E('strong', {}, '🔌 WebSocket: '),
|
||||
E('span', {}, String(wsCount))
|
||||
]),
|
||||
E('div', { 'class': 'td left', 'width': '25%' }, [
|
||||
E('strong', {}, '📜 Certificates: '),
|
||||
E('span', {}, String(certs.length))
|
||||
])
|
||||
])
|
||||
])
|
||||
]);
|
||||
v.appendChild(statsSection);
|
||||
|
||||
// Recent vhosts
|
||||
if (vhosts.length > 0) {
|
||||
var vhostSection = E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Virtual Hosts'))
|
||||
]);
|
||||
|
||||
var table = E('table', { 'class': 'table' }, [
|
||||
E('tr', { 'class': 'tr table-titles' }, [
|
||||
E('th', { 'class': 'th' }, _('Domain')),
|
||||
E('th', { 'class': 'th' }, _('Backend')),
|
||||
E('th', { 'class': 'th' }, _('Features')),
|
||||
E('th', { 'class': 'th' }, _('SSL Expires'))
|
||||
])
|
||||
]);
|
||||
|
||||
vhosts.slice(0, 10).forEach(function(vhost) {
|
||||
var features = [];
|
||||
if (vhost.ssl) features.push('🔒 SSL');
|
||||
if (vhost.auth) features.push('🔐 Auth');
|
||||
if (vhost.websocket) features.push('🔌 WS');
|
||||
|
||||
table.appendChild(E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td' }, vhost.domain),
|
||||
E('td', { 'class': 'td' }, vhost.backend),
|
||||
E('td', { 'class': 'td' }, features.join(' ')),
|
||||
E('td', { 'class': 'td' }, vhost.ssl_expires || 'N/A')
|
||||
]));
|
||||
});
|
||||
|
||||
vhostSection.appendChild(table);
|
||||
v.appendChild(vhostSection);
|
||||
}
|
||||
|
||||
return v;
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
renderHeader: function(status, vhosts, certs) {
|
||||
var sslEnabled = vhosts.filter(function(v) { return v.ssl; }).length;
|
||||
var expiringSoon = certs.filter(function(cert) {
|
||||
var days = daysUntil(cert.expires);
|
||||
return days !== null && days <= 30;
|
||||
}).length;
|
||||
|
||||
return E('div', { 'class': 'sh-page-header' }, [
|
||||
E('div', {}, [
|
||||
E('h2', { 'class': 'sh-page-title' }, [
|
||||
E('span', { 'class': 'sh-page-title-icon' }, '🌐'),
|
||||
_('VHost Manager')
|
||||
]),
|
||||
E('p', { 'class': 'sh-page-subtitle' },
|
||||
_('Reverse proxy, SSL automation and hardened headers for SecuBox deployments.'))
|
||||
]),
|
||||
E('div', { 'class': 'sh-stats-grid' }, [
|
||||
this.renderStatBadge(status.vhost_count || vhosts.length, _('Virtual Hosts')),
|
||||
this.renderStatBadge(sslEnabled, _('TLS Enabled')),
|
||||
this.renderStatBadge(expiringSoon, _('Expiring Certs'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderStatBadge: function(value, label) {
|
||||
return E('div', { 'class': 'sh-stat-badge' }, [
|
||||
E('div', { 'class': 'sh-stat-value' }, value.toString()),
|
||||
E('div', { 'class': 'sh-stat-label' }, label)
|
||||
]);
|
||||
},
|
||||
|
||||
renderHealth: function(status) {
|
||||
var items = [
|
||||
{ label: _('Nginx'), value: status.nginx_running ? _('Running') : _('Stopped'),
|
||||
pill: status.nginx_running ? 'success' : 'danger' },
|
||||
{ label: _('Version'), value: status.nginx_version || _('Unknown') },
|
||||
{ label: _('ACME'), value: status.acme_available ? _('Available') : _('Missing'),
|
||||
pill: status.acme_available ? 'success' : 'warn' }
|
||||
];
|
||||
|
||||
return E('div', { 'class': 'vhost-card-grid' }, [
|
||||
E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, ['🧭', _('Control Center')]),
|
||||
E('p', { 'class': 'vhost-card-meta' },
|
||||
_('Quick navigation to key areas.')),
|
||||
E('div', { 'class': 'vhost-actions' }, [
|
||||
E('a', {
|
||||
'class': 'sh-btn-primary',
|
||||
'href': L.url('admin', 'secubox', 'services', 'vhosts', 'vhosts')
|
||||
}, _('Manage VHosts')),
|
||||
E('a', {
|
||||
'class': 'sh-btn-secondary',
|
||||
'href': L.url('admin', 'secubox', 'services', 'vhosts', 'certificates')
|
||||
}, _('Certificates')),
|
||||
E('a', {
|
||||
'class': 'sh-btn-secondary',
|
||||
'href': L.url('admin', 'secubox', 'services', 'vhosts', 'logs')
|
||||
}, _('Access Logs'))
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, ['🩺', _('Runtime Health')]),
|
||||
E('div', { 'class': 'vhost-status-list' },
|
||||
items.map(function(item) {
|
||||
return E('div', { 'class': 'vhost-status-item' }, [
|
||||
E('span', {}, item.label),
|
||||
item.pill ? E('span', { 'class': 'vhost-pill ' + item.pill }, item.value) :
|
||||
E('strong', {}, item.value)
|
||||
]);
|
||||
})
|
||||
)
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderVhostTable: function(vhosts, certs) {
|
||||
var certMap = {};
|
||||
certs.forEach(function(cert) {
|
||||
certMap[cert.domain] = cert;
|
||||
});
|
||||
|
||||
return E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, ['📁', _('Published Domains')]),
|
||||
vhosts.length ? E('table', { 'class': 'vhost-table' }, [
|
||||
E('thead', {}, E('tr', {}, [
|
||||
E('th', {}, _('Domain')),
|
||||
E('th', {}, _('Backend')),
|
||||
E('th', {}, _('Features')),
|
||||
E('th', {}, _('Certificate'))
|
||||
])),
|
||||
E('tbody', {},
|
||||
vhosts.map(function(vhost) {
|
||||
var cert = certMap[vhost.domain];
|
||||
var features = [
|
||||
vhost.ssl ? _('SSL') : null,
|
||||
vhost.auth ? _('Auth') : null,
|
||||
vhost.websocket ? _('WebSocket') : null
|
||||
].filter(Boolean);
|
||||
|
||||
return E('tr', {}, [
|
||||
E('td', {}, vhost.domain || _('Unnamed')),
|
||||
E('td', { 'class': 'vhost-card-meta' }, vhost.backend || '-'),
|
||||
E('td', {}, features.length ? features.join(' · ') : _('None')),
|
||||
E('td', {}, cert ? formatDate(cert.expires) : _('No cert'))
|
||||
]);
|
||||
})
|
||||
)
|
||||
]) : E('div', { 'class': 'vhost-empty' }, _('No virtual hosts configured yet.'))
|
||||
]);
|
||||
},
|
||||
|
||||
renderCertWatch: function(certs) {
|
||||
if (!certs.length)
|
||||
return '';
|
||||
|
||||
var top = certs.slice().sort(function(a, b) {
|
||||
return (Date.parse(a.expires) || 0) - (Date.parse(b.expires) || 0);
|
||||
}).slice(0, 3);
|
||||
|
||||
return E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, ['⏳', _('Certificate Watchlist')]),
|
||||
E('div', { 'class': 'vhost-card-grid' },
|
||||
top.map(function(cert) {
|
||||
var days = daysUntil(cert.expires);
|
||||
var pill = 'success';
|
||||
var label = _('Valid');
|
||||
|
||||
if (days === null) {
|
||||
pill = 'danger';
|
||||
label = _('Unknown expiry');
|
||||
} else if (days <= 7) {
|
||||
pill = 'danger';
|
||||
label = _('Expiring in %d days').format(days);
|
||||
} else if (days <= 30) {
|
||||
pill = 'warn';
|
||||
label = _('Renew in %d days').format(days);
|
||||
}
|
||||
|
||||
return E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, ['🔐', cert.domain]),
|
||||
E('div', { 'class': 'vhost-card-meta' }, cert.issuer || _('Unknown issuer')),
|
||||
E('div', { 'class': 'vhost-card-meta' }, formatDate(cert.expires)),
|
||||
E('span', { 'class': 'vhost-pill ' + pill }, label)
|
||||
]);
|
||||
})
|
||||
),
|
||||
E('div', { 'class': 'vhost-actions' }, [
|
||||
E('a', {
|
||||
'class': 'sh-btn-secondary',
|
||||
'href': L.url('admin', 'secubox', 'services', 'vhosts', 'certificates')
|
||||
}, _('View certificates'))
|
||||
])
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,29 +1,86 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require vhost-manager.api as api';
|
||||
'require vhost-manager/api as API';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require vhost-manager/ui as VHostUI';
|
||||
|
||||
var lang = (typeof L !== 'undefined' && L.env && L.env.lang) ||
|
||||
(document.documentElement && document.documentElement.getAttribute('lang')) ||
|
||||
(navigator.language ? navigator.language.split('-')[0] : 'en');
|
||||
Theme.init({ language: lang });
|
||||
|
||||
var RULES = [
|
||||
{ icon: '☁️', name: _('Nextcloud → LAN'), from: 'cloud.example.com', to: 'https://nextcloud.lan', code: '301', description: _('Force remote users towards the LAN-hosted Nextcloud instance when DNS interception is active.') },
|
||||
{ icon: '🕹️', name: _('Steam CDN cache'), from: '*.cdn.steamstatic.com', to: 'http://steamcache.lan', code: '302', description: _('Redirect bulky downloads to an on-prem cache appliance to save WAN bandwidth.') },
|
||||
{ icon: '📺', name: _('YouTube → Invidious'), from: 'youtube.com/*', to: 'https://invidious.lan', code: '307', description: _('Privacy-friendly redirect of YouTube links to your Invidious deployment.') },
|
||||
{ icon: '📮', name: _('Mail failover'), from: 'mail.example.com', to: 'https://mx-backup.lan', code: '302', description: _('Gracefully fail over SaaS webmail to an alternate local service during outages.') }
|
||||
];
|
||||
|
||||
return view.extend({
|
||||
load: function() { return api.getRedirects(); },
|
||||
render: function(data) {
|
||||
var redirects = data.redirects || [];
|
||||
return E('div', {class:'cbi-map'}, [
|
||||
E('h2', {}, '↪️ External Service Redirects'),
|
||||
E('p', {style:'color:#94a3b8;margin-bottom:20px'}, 'Redirect external services to local alternatives (requires DNS interception).'),
|
||||
E('div', {style:'display:grid;gap:16px'}, redirects.map(function(r) {
|
||||
return E('div', {style:'background:#1e293b;padding:20px;border-radius:12px;opacity:'+(r.enabled?'1':'0.5')}, [
|
||||
E('div', {style:'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px'}, [
|
||||
E('div', {style:'font-weight:600;color:#f1f5f9;font-size:16px'}, r.name),
|
||||
E('span', {style:'padding:4px 8px;border-radius:4px;background:'+(r.enabled?'#f59e0b20;color:#f59e0b':'#64748b20;color:#64748b')}, r.enabled ? 'Active' : 'Disabled')
|
||||
]),
|
||||
E('div', {style:'color:#94a3b8;font-size:13px;margin-bottom:12px'}, r.description),
|
||||
E('div', {style:'display:flex;align-items:center;gap:16px;padding:12px;background:#0f172a;border-radius:8px'}, [
|
||||
E('span', {style:'font-family:monospace;color:#ef4444;text-decoration:line-through'}, r.external_domain),
|
||||
E('span', {style:'font-size:20px'}, '→'),
|
||||
E('span', {style:'font-family:monospace;color:#10b981'}, r.local_domain)
|
||||
])
|
||||
]);
|
||||
}))
|
||||
]);
|
||||
},
|
||||
handleSaveApply:null,handleSave:null,handleReset:null
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
API.listVHosts()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var vhosts = data[0] || [];
|
||||
|
||||
return E('div', { 'class': 'vhost-page' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('vhost-manager/common.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('vhost-manager/dashboard.css') }),
|
||||
VHostUI.renderTabs('redirects'),
|
||||
this.renderHeader(vhosts),
|
||||
this.renderTemplates()
|
||||
]);
|
||||
},
|
||||
|
||||
renderHeader: function(vhosts) {
|
||||
var redirectCount = vhosts.filter(function(vhost) {
|
||||
return vhost.backend && vhost.backend.indexOf('return ') === 0;
|
||||
}).length;
|
||||
|
||||
return E('div', { 'class': 'sh-page-header' }, [
|
||||
E('div', {}, [
|
||||
E('h2', { 'class': 'sh-page-title' }, [
|
||||
E('span', { 'class': 'sh-page-title-icon' }, '↪️'),
|
||||
_('Redirect Rules')
|
||||
]),
|
||||
E('p', { 'class': 'sh-page-subtitle' },
|
||||
_('Build captive portal style redirects and clean vanity links from a central place.'))
|
||||
]),
|
||||
E('div', { 'class': 'sh-stats-grid' }, [
|
||||
this.renderStat(RULES.length, _('Templates')),
|
||||
this.renderStat(redirectCount, _('Active'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderStat: function(value, label) {
|
||||
return E('div', { 'class': 'sh-stat-badge' }, [
|
||||
E('div', { 'class': 'sh-stat-value' }, value.toString()),
|
||||
E('div', { 'class': 'sh-stat-label' }, label)
|
||||
]);
|
||||
},
|
||||
|
||||
renderTemplates: function() {
|
||||
return E('div', { 'class': 'vhost-card-grid' },
|
||||
RULES.map(function(rule) {
|
||||
return E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, [rule.icon, rule.name]),
|
||||
E('p', { 'class': 'vhost-card-meta' }, rule.description),
|
||||
E('div', { 'class': 'vhost-card-meta' }, _('From: %s').format(rule.from)),
|
||||
E('div', { 'class': 'vhost-card-meta' }, _('To: %s').format(rule.to)),
|
||||
E('div', { 'class': 'vhost-actions' }, [
|
||||
E('span', { 'class': 'vhost-pill' }, _('HTTP %s').format(rule.code)),
|
||||
E('a', {
|
||||
'class': 'sh-btn-secondary',
|
||||
'href': L.url('admin', 'secubox', 'services', 'vhosts', 'vhosts')
|
||||
}, _('Create rule'))
|
||||
])
|
||||
]);
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,30 +1,157 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require vhost-manager.api as api';
|
||||
'require ui';
|
||||
'require vhost-manager/api as API';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require vhost-manager/ui as VHostUI';
|
||||
|
||||
var lang = (typeof L !== 'undefined' && L.env && L.env.lang) ||
|
||||
(document.documentElement && document.documentElement.getAttribute('lang')) ||
|
||||
(navigator.language ? navigator.language.split('-')[0] : 'en');
|
||||
Theme.init({ language: lang });
|
||||
|
||||
return view.extend({
|
||||
load: function() { return api.getCertificates(); },
|
||||
render: function(data) {
|
||||
var certs = data.certificates || [];
|
||||
return E('div', {class:'cbi-map'}, [
|
||||
E('h2', {}, '🔒 SSL Certificates'),
|
||||
E('p', {style:'color:#94a3b8;margin-bottom:20px'}, 'Manage SSL/TLS certificates for your virtual hosts.'),
|
||||
E('div', {style:'background:#1e293b;padding:20px;border-radius:12px'}, [
|
||||
certs.length ? E('table', {style:'width:100%;color:#f1f5f9'}, [
|
||||
E('tr', {style:'border-bottom:1px solid #334155'}, [
|
||||
E('th', {style:'padding:12px;text-align:left'}, 'Domain'),
|
||||
E('th', {style:'padding:12px'}, 'Expiry'),
|
||||
E('th', {style:'padding:12px'}, 'Status')
|
||||
])
|
||||
].concat(certs.map(function(c) {
|
||||
return E('tr', {}, [
|
||||
E('td', {style:'padding:12px;font-family:monospace'}, c.domain),
|
||||
E('td', {style:'padding:12px;color:#94a3b8'}, c.expiry || 'Unknown'),
|
||||
E('td', {style:'padding:12px'}, E('span', {style:'padding:4px 8px;border-radius:4px;background:#22c55e20;color:#22c55e'}, 'Valid'))
|
||||
]);
|
||||
}))) : E('p', {style:'color:#64748b;text-align:center'}, 'No certificates found')
|
||||
])
|
||||
]);
|
||||
},
|
||||
handleSaveApply:null,handleSave:null,handleReset:null
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
API.getStatus()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var status = data[0] || {};
|
||||
|
||||
return E('div', { 'class': 'vhost-page' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('vhost-manager/common.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('vhost-manager/dashboard.css') }),
|
||||
VHostUI.renderTabs('ssl'),
|
||||
this.renderHeader(status),
|
||||
this.renderBaseline(),
|
||||
this.renderHeaders(),
|
||||
this.renderActions(status)
|
||||
]);
|
||||
},
|
||||
|
||||
renderHeader: function(status) {
|
||||
return E('div', { 'class': 'sh-page-header' }, [
|
||||
E('div', {}, [
|
||||
E('h2', { 'class': 'sh-page-title' }, [
|
||||
E('span', { 'class': 'sh-page-title-icon' }, '⚙️'),
|
||||
_('SSL / TLS Configuration')
|
||||
]),
|
||||
E('p', { 'class': 'sh-page-subtitle' },
|
||||
_('Baseline cipher suites, headers, and reload helpers for hardened deployments.'))
|
||||
]),
|
||||
E('div', { 'class': 'sh-stats-grid' }, [
|
||||
this.renderStat(_('TLS1.2+'), _('Min version')),
|
||||
this.renderStat(_('OCSP stapling'), _('Status')),
|
||||
this.renderStat(status.nginx_running ? _('Running') : _('Stopped'), _('nginx'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderStat: function(value, label) {
|
||||
return E('div', { 'class': 'sh-stat-badge' }, [
|
||||
E('div', { 'class': 'sh-stat-value' }, value),
|
||||
E('div', { 'class': 'sh-stat-label' }, label)
|
||||
]);
|
||||
},
|
||||
|
||||
renderBaseline: function() {
|
||||
var snippets = [
|
||||
{
|
||||
icon: '🔐',
|
||||
title: _('TLS Versions'),
|
||||
body: [
|
||||
'ssl_protocols TLSv1.2 TLSv1.3;',
|
||||
'ssl_prefer_server_ciphers on;'
|
||||
],
|
||||
note: _('Disable legacy TLSv1.0/1.1 to prevent downgrade attacks.')
|
||||
},
|
||||
{
|
||||
icon: '🧮',
|
||||
title: _('Cipher Suites'),
|
||||
body: [
|
||||
'ssl_ciphers \'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256\';'
|
||||
],
|
||||
note: _('Prefer AEAD/GCM suites that provide forward secrecy.')
|
||||
},
|
||||
{
|
||||
icon: '🧷',
|
||||
title: _('HSTS Policy'),
|
||||
body: [
|
||||
'add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;'
|
||||
],
|
||||
note: _('Force HTTPS everywhere and preload in browsers.')
|
||||
},
|
||||
{
|
||||
icon: '📡',
|
||||
title: _('OCSP Stapling'),
|
||||
body: [
|
||||
'ssl_stapling on;',
|
||||
'ssl_stapling_verify on;'
|
||||
],
|
||||
note: _('Cache CA responses to speed up TLS handshakes.')
|
||||
}
|
||||
];
|
||||
|
||||
return E('div', { 'class': 'vhost-card-grid' },
|
||||
snippets.map(function(item) {
|
||||
return E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, [item.icon, item.title]),
|
||||
E('pre', { 'class': 'vhost-card-meta' }, item.body.join('\n')),
|
||||
E('p', { 'class': 'vhost-card-meta' }, item.note)
|
||||
]);
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
renderHeaders: function() {
|
||||
var headers = [
|
||||
{ title: 'Content-Security-Policy', desc: _('Restrict scripts, frames, and media to vetted origins. Example: default-src \'self\'.') },
|
||||
{ title: 'Permissions-Policy', desc: _('Opt-in sensors (camera, microphone, geolocation) per vhost.') },
|
||||
{ title: 'Referrer-Policy', desc: _('Use strict-origin-when-cross-origin to reduce leakage.') },
|
||||
{ title: 'X-Frame-Options', desc: _('Block clickjacking with DENY or SAMEORIGIN.') }
|
||||
];
|
||||
|
||||
return E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, ['🧱', _('Security Headers')]),
|
||||
E('div', { 'class': 'vhost-status-list' },
|
||||
headers.map(function(header) {
|
||||
return E('div', { 'class': 'vhost-status-item' }, [
|
||||
E('strong', {}, header.title),
|
||||
E('span', { 'class': 'vhost-card-meta' }, header.desc)
|
||||
]);
|
||||
})
|
||||
)
|
||||
]);
|
||||
},
|
||||
|
||||
renderActions: function(status) {
|
||||
return E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, ['🔄', _('Apply configuration')]),
|
||||
E('p', { 'class': 'vhost-card-meta' }, _('After updating snippets in /etc/nginx/conf.d include files, reload nginx to apply safely.')),
|
||||
E('div', { 'class': 'vhost-actions' }, [
|
||||
E('span', { 'class': 'vhost-pill ' + (status.nginx_running ? 'success' : 'danger') },
|
||||
status.nginx_running ? _('nginx running') : _('nginx stopped')),
|
||||
E('button', {
|
||||
'class': 'sh-btn-primary',
|
||||
'click': this.reloadNginx
|
||||
}, _('Reload nginx'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
reloadNginx: function(ev) {
|
||||
ev.preventDefault();
|
||||
ui.addNotification(null, E('p', _('Reloading nginx...')), 'info');
|
||||
|
||||
API.reloadNginx().then(function(result) {
|
||||
if (result.success) {
|
||||
ui.addNotification(null, E('p', _('Nginx reloaded successfully')), 'info');
|
||||
} else {
|
||||
ui.addNotification(null, E('p', '✗ ' + (result.message || _('Reload failed'))), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -3,25 +3,66 @@
|
||||
'require ui';
|
||||
'require form';
|
||||
'require vhost-manager/api as API';
|
||||
'require secubox-theme/theme as Theme';
|
||||
'require vhost-manager/ui as VHostUI';
|
||||
|
||||
var lang = (typeof L !== 'undefined' && L.env && L.env.lang) ||
|
||||
(document.documentElement && document.documentElement.getAttribute('lang')) ||
|
||||
(navigator.language ? navigator.language.split('-')[0] : 'en');
|
||||
Theme.init({ language: lang });
|
||||
|
||||
function normalizeCerts(payload) {
|
||||
if (Array.isArray(payload))
|
||||
return payload;
|
||||
if (payload && Array.isArray(payload.certificates))
|
||||
return payload.certificates;
|
||||
return [];
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value)
|
||||
return _('N/A');
|
||||
try {
|
||||
return new Date(value).toLocaleDateString();
|
||||
} catch (err) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return L.view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
API.listVHosts()
|
||||
API.listVHosts(),
|
||||
API.listCerts()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var vhosts = data[0] || [];
|
||||
var certs = normalizeCerts(data[1]);
|
||||
|
||||
var m = new form.Map('vhost_manager', _('Virtual Hosts'),
|
||||
_('Manage nginx reverse proxy virtual hosts'));
|
||||
var m = this.buildForm();
|
||||
|
||||
return E('div', { 'class': 'vhost-page' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('vhost-manager/common.css') }),
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('vhost-manager/dashboard.css') }),
|
||||
VHostUI.renderTabs('vhosts'),
|
||||
this.renderHeader(vhosts),
|
||||
this.renderList(vhosts, certs),
|
||||
E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, ['📝', _('Virtual Host Form')]),
|
||||
m.render()
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
buildForm: function() {
|
||||
var m = new form.Map('vhost_manager', null, null);
|
||||
var s = m.section(form.GridSection, 'vhost', _('Virtual Hosts'));
|
||||
s.anonymous = false;
|
||||
s.addremove = true;
|
||||
s.sortable = true;
|
||||
|
||||
s.modaltitle = function(section_id) {
|
||||
return _('Edit VHost: ') + section_id;
|
||||
};
|
||||
@ -30,32 +71,27 @@ return L.view.extend({
|
||||
|
||||
o = s.option(form.Value, 'domain', _('Domain'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'example.com';
|
||||
o.description = _('Domain name for this virtual host');
|
||||
o.placeholder = 'app.example.com';
|
||||
o.description = _('Public hostname for this proxy.');
|
||||
|
||||
o = s.option(form.Value, 'backend', _('Backend URL'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'http://192.168.1.100:8080';
|
||||
o.description = _('Backend server URL to proxy to');
|
||||
o.description = _('Upstream origin (HTTP/HTTPS/WebSocket).');
|
||||
|
||||
// Test backend button
|
||||
o.renderWidget = function(section_id, option_index, cfgvalue) {
|
||||
var widget = form.Value.prototype.renderWidget.apply(this, [section_id, option_index, cfgvalue]);
|
||||
|
||||
var testBtn = E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'style': 'margin-left: 10px',
|
||||
'click': function(ev) {
|
||||
ev.preventDefault();
|
||||
var backend = this.parentNode.querySelector('input').value;
|
||||
|
||||
if (!backend) {
|
||||
ui.addNotification(null, E('p', _('Please enter a backend URL')), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
ui.addNotification(null, E('p', _('Testing backend connectivity...')), 'info');
|
||||
|
||||
API.testBackend(backend).then(function(result) {
|
||||
if (result.reachable) {
|
||||
ui.addNotification(null, E('p', '✓ ' + _('Backend is reachable')), 'info');
|
||||
@ -65,22 +101,20 @@ return L.view.extend({
|
||||
});
|
||||
}
|
||||
}, _('Test'));
|
||||
|
||||
widget.appendChild(testBtn);
|
||||
return widget;
|
||||
};
|
||||
|
||||
o = s.option(form.Flag, 'ssl', _('Enable SSL'));
|
||||
o.default = o.disabled;
|
||||
o.description = _('Enable HTTPS (requires valid SSL certificate)');
|
||||
o.description = _('Serve HTTPS (requires certificate).');
|
||||
|
||||
o = s.option(form.Flag, 'auth', _('Enable Authentication'));
|
||||
o.default = o.disabled;
|
||||
o.description = _('Require HTTP basic authentication');
|
||||
o.description = _('Protect with HTTP basic auth.');
|
||||
|
||||
o = s.option(form.Value, 'auth_user', _('Auth Username'));
|
||||
o.depends('auth', '1');
|
||||
o.placeholder = 'admin';
|
||||
|
||||
o = s.option(form.Value, 'auth_pass', _('Auth Password'));
|
||||
o.depends('auth', '1');
|
||||
@ -88,11 +122,9 @@ return L.view.extend({
|
||||
|
||||
o = s.option(form.Flag, 'websocket', _('WebSocket Support'));
|
||||
o.default = o.disabled;
|
||||
o.description = _('Enable WebSocket protocol upgrade headers');
|
||||
o.description = _('Forward upgrade headers for WS backends.');
|
||||
|
||||
// Custom actions
|
||||
s.addModalOptions = function(s, section_id, ev) {
|
||||
// Get form values
|
||||
s.addModalOptions = function(s, section_id) {
|
||||
var domain = this.section.formvalue(section_id, 'domain');
|
||||
var backend = this.section.formvalue(section_id, 'backend');
|
||||
var ssl = this.section.formvalue(section_id, 'ssl') === '1';
|
||||
@ -104,11 +136,10 @@ return L.view.extend({
|
||||
return;
|
||||
}
|
||||
|
||||
// Call API to add vhost
|
||||
API.addVHost(domain, backend, ssl, auth, websocket).then(function(result) {
|
||||
if (result.success) {
|
||||
ui.addNotification(null, E('p', _('VHost created successfully')), 'info');
|
||||
|
||||
|
||||
if (result.reload_required) {
|
||||
ui.showModal(_('Reload Nginx?'), [
|
||||
E('p', {}, _('Configuration changed. Reload nginx to apply?')),
|
||||
@ -139,6 +170,71 @@ return L.view.extend({
|
||||
});
|
||||
};
|
||||
|
||||
return m.render();
|
||||
return m;
|
||||
},
|
||||
|
||||
renderHeader: function(vhosts) {
|
||||
var sslEnabled = vhosts.filter(function(v) { return v.ssl; }).length;
|
||||
var authEnabled = vhosts.filter(function(v) { return v.auth; }).length;
|
||||
var websocketEnabled = vhosts.filter(function(v) { return v.websocket; }).length;
|
||||
|
||||
return E('div', { 'class': 'sh-page-header' }, [
|
||||
E('div', {}, [
|
||||
E('h2', { 'class': 'sh-page-title' }, [
|
||||
E('span', { 'class': 'sh-page-title-icon' }, '🗂️'),
|
||||
_('Virtual Hosts')
|
||||
]),
|
||||
E('p', { 'class': 'sh-page-subtitle' },
|
||||
_('Publish LAN services through SecuBox with SSL, auth, and WebSocket support.'))
|
||||
]),
|
||||
E('div', { 'class': 'sh-stats-grid' }, [
|
||||
this.renderStatBadge(vhosts.length, _('Defined')),
|
||||
this.renderStatBadge(sslEnabled, _('TLS')),
|
||||
this.renderStatBadge(authEnabled, _('Auth')),
|
||||
this.renderStatBadge(websocketEnabled, _('WebSocket'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderStatBadge: function(value, label) {
|
||||
return E('div', { 'class': 'sh-stat-badge' }, [
|
||||
E('div', { 'class': 'sh-stat-value' }, value.toString()),
|
||||
E('div', { 'class': 'sh-stat-label' }, label)
|
||||
]);
|
||||
},
|
||||
|
||||
renderList: function(vhosts, certs) {
|
||||
var certMap = {};
|
||||
certs.forEach(function(cert) {
|
||||
certMap[cert.domain] = cert;
|
||||
});
|
||||
|
||||
if (!vhosts.length) {
|
||||
return E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, ['📂', _('Configured VHosts')]),
|
||||
E('div', { 'class': 'vhost-empty' }, _('No vhosts yet — add your first reverse proxy below.'))
|
||||
]);
|
||||
}
|
||||
|
||||
return E('div', { 'class': 'vhost-card-grid' },
|
||||
vhosts.map(function(vhost) {
|
||||
return this.renderVhostCard(vhost, certMap[vhost.domain]);
|
||||
}, this)
|
||||
);
|
||||
},
|
||||
|
||||
renderVhostCard: function(vhost, cert) {
|
||||
var pills = [];
|
||||
if (vhost.ssl) pills.push(E('span', { 'class': 'vhost-pill success' }, _('SSL')));
|
||||
if (vhost.auth) pills.push(E('span', { 'class': 'vhost-pill warn' }, _('Auth')));
|
||||
if (vhost.websocket) pills.push(E('span', { 'class': 'vhost-pill' }, _('WebSocket')));
|
||||
|
||||
return E('div', { 'class': 'vhost-card' }, [
|
||||
E('div', { 'class': 'vhost-card-title' }, ['🌐', vhost.domain || _('Unnamed')]),
|
||||
E('div', { 'class': 'vhost-card-meta' }, vhost.backend || _('No backend defined')),
|
||||
pills.length ? E('div', { 'class': 'vhost-filter-tags' }, pills) : '',
|
||||
E('div', { 'class': 'vhost-card-meta' },
|
||||
cert ? _('Certificate expires %s').format(formatDate(cert.expires)) : _('No certificate detected'))
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--sh-primary), var(--sh-primary-end));
|
||||
@ -94,7 +94,7 @@ pre {
|
||||
}
|
||||
|
||||
.sh-page-title-icon {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user