Major structural reorganization and feature additions: ## Folder Reorganization - Move 17 luci-app-* packages to package/secubox/ (except luci-app-secubox core hub) - Update all tooling to support new structure: - secubox-tools/quick-deploy.sh: search both locations - secubox-tools/validate-modules.sh: validate both directories - secubox-tools/fix-permissions.sh: fix permissions in both locations - .github/workflows/test-validate.yml: build from both paths - Update README.md links to new package/secubox/ paths ## AppStore Migration (Complete) - Add catalog entries for all remaining luci-app packages: - network-tweaks.json: Network optimization tools - secubox-bonus.json: Documentation & demos hub - Total: 24 apps in AppStore catalog (22 existing + 2 new) - New category: 'documentation' for docs/demos/tutorials ## VHost Manager v2.0 Enhancements - Add profile activation system for Internal Services and Redirects - Implement createVHost() API wrapper for template-based deployment - Fix Virtual Hosts view rendering with proper LuCI patterns - Fix RPCD backend shell script errors (remove invalid local declarations) - Extend backend validation for nginx return directives (redirect support) - Add section_id parameter for named VHost profiles - Add Remove button to Redirects page for feature parity - Update README to v2.0 with comprehensive feature documentation ## Network Tweaks Dashboard - Close button added to component details modal Files changed: 340+ (336 renames with preserved git history) Packages affected: 19 luci-app, 2 secubox-app, 1 theme, 4 tools 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
337 lines
11 KiB
JavaScript
337 lines
11 KiB
JavaScript
'use strict';
|
||
'require view';
|
||
'require mqtt-bridge/api as API';
|
||
'require secubox-theme/theme as Theme';
|
||
'require mqtt-bridge/nav as Nav';
|
||
'require ui';
|
||
'require form';
|
||
'require dom';
|
||
|
||
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.getStatus();
|
||
},
|
||
|
||
render: function(payload) {
|
||
var settings = (payload && payload.settings) || {};
|
||
var adapters = (payload && payload.adapters) || [];
|
||
var profiles = (payload && payload.profiles) || [];
|
||
this.currentAdapters = this.cloneAdapters(adapters || []);
|
||
this.liveProfiles = profiles || [];
|
||
|
||
var container = E('div', { 'class': 'mqtt-bridge-dashboard' }, [
|
||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||
E('link', { 'rel': 'stylesheet', 'href': L.resource('mqtt-bridge/common.css') }),
|
||
Nav.renderTabs('settings'),
|
||
this.renderSettingsCard(settings || {}),
|
||
this.renderAdapterCard(this.currentAdapters),
|
||
this.renderPresetCard(this.liveProfiles)
|
||
]);
|
||
return container;
|
||
},
|
||
|
||
renderSettingsCard: function(settings) {
|
||
return E('div', { 'class': 'mb-card' }, [
|
||
E('div', { 'class': 'mb-card-header' }, [
|
||
E('div', { 'class': 'mb-card-title' }, [E('span', {}, '⚙️'), _('MQTT Settings')])
|
||
]),
|
||
E('div', { 'class': 'mb-grid' }, [
|
||
this.input('broker-host', _('Broker host'), settings.host || '127.0.0.1'),
|
||
this.input('broker-port', _('Port'), settings.port || 1883),
|
||
this.input('username', _('Username'), settings.username || '', 'text'),
|
||
this.input('password', _('Password'), settings.password || '', 'password'),
|
||
this.input('base-topic', _('Base topic'), settings.base_topic || 'secubox/+/state'),
|
||
this.input('retention', _('Retention (days)'), settings.retention || 7, 'number')
|
||
]),
|
||
E('div', { 'style': 'margin-top:16px;' }, [
|
||
E('button', { 'class': 'mb-btn mb-btn-primary', 'click': ui.createHandlerFn(this, 'savePreferences') }, ['💾 ', _('Save preferences')])
|
||
])
|
||
]);
|
||
},
|
||
|
||
renderAdapterCard: function(adapters) {
|
||
var grid = E('div', { 'class': 'mb-adapter-grid' }, this.renderAdapterRows(adapters));
|
||
this.adapterGrid = grid;
|
||
|
||
return E('div', { 'class': 'mb-card' }, [
|
||
E('div', { 'class': 'mb-card-header' }, [
|
||
E('div', { 'class': 'mb-card-title' }, [E('span', {}, '🧩'), _('Adapter preferences')])
|
||
]),
|
||
grid
|
||
]);
|
||
},
|
||
|
||
renderAdapterRows: function(adapters) {
|
||
var self = this;
|
||
if (!adapters || !adapters.length) {
|
||
return [E('p', { 'style': 'color:var(--mb-muted);' },
|
||
_('No adapters configured yet. Existing `config adapter` entries will appear once the monitor updates.'))];
|
||
}
|
||
return adapters.map(function(adapter) {
|
||
return self.renderAdapterRow(adapter);
|
||
});
|
||
},
|
||
|
||
renderAdapterRow: function(adapter) {
|
||
var id = adapter.id || adapter.section || adapter.preset || adapter.vendor + ':' + adapter.product;
|
||
var inputId = this.makeAdapterInputId(id, 'label');
|
||
return E('div', { 'class': 'mb-adapter-row' }, [
|
||
E('div', { 'class': 'mb-adapter-header' }, [
|
||
E('div', {}, [
|
||
E('strong', {}, adapter.label || id || _('Adapter')),
|
||
E('div', { 'class': 'mb-profile-meta' }, [
|
||
adapter.vendor && adapter.product ? _('VID:PID ') + adapter.vendor + ':' + adapter.product : null,
|
||
adapter.port ? _('Port ') + adapter.port : null,
|
||
adapter.health ? _('Health ') + adapter.health : null,
|
||
adapter.last_seen ? _('Last seen ') + adapter.last_seen : null
|
||
].filter(Boolean).map(function(entry) {
|
||
return E('span', {}, entry);
|
||
}))
|
||
]),
|
||
E('label', { 'class': 'mb-switch' }, [
|
||
E('input', {
|
||
'type': 'checkbox',
|
||
'id': this.makeAdapterInputId(id, 'enabled'),
|
||
'checked': adapter.enabled !== false && adapter.enabled !== '0'
|
||
}),
|
||
E('span', {}, _('Enabled'))
|
||
])
|
||
]),
|
||
this.input(this.makeAdapterInputId(id, 'custom-label'), _('Display label'), adapter.label || id),
|
||
this.input(this.makeAdapterInputId(id, 'custom-port'), _('Preferred /dev/tty*'), adapter.port || '', 'text'),
|
||
E('div', { 'class': 'mb-adapter-footer' }, [
|
||
E('span', {
|
||
'class': 'mb-health ' + this.healthClass(adapter.health)
|
||
}, (adapter.health || _('unknown')).toString()),
|
||
E('div', { 'class': 'mb-adapter-actions' }, [
|
||
E('button', {
|
||
'class': 'mb-btn mb-btn-secondary',
|
||
'click': this.handleRescan.bind(this, id)
|
||
}, ['🔄 ', _('Rescan')]),
|
||
E('button', {
|
||
'class': 'mb-btn mb-btn-secondary',
|
||
'click': this.handleReset.bind(this, id)
|
||
}, ['♻️ ', _('Reset')])
|
||
])
|
||
]),
|
||
adapter.notes ? E('p', { 'class': 'mb-profile-notes' }, adapter.notes) : null
|
||
]);
|
||
},
|
||
|
||
renderPresetCard: function(profiles) {
|
||
var self = this;
|
||
var rows = (profiles && profiles.length) ? profiles.map(function(profile) {
|
||
return self.renderPresetRow(profile);
|
||
}) : [
|
||
E('p', { 'style': 'color:var(--mb-muted);' },
|
||
_('When USB presets are detected (VID/PID), they will be displayed here so you can import them as adapters.'))
|
||
];
|
||
|
||
return E('div', { 'class': 'mb-card' }, [
|
||
E('div', { 'class': 'mb-card-header' }, [
|
||
E('div', { 'class': 'mb-card-title' }, [E('span', {}, '🛰️'), _('Detected presets')])
|
||
]),
|
||
E('div', { 'class': 'mb-profile-grid' }, rows)
|
||
]);
|
||
},
|
||
|
||
renderPresetRow: function(profile) {
|
||
var self = this;
|
||
var meta = [
|
||
(profile.vendor && profile.product) ? _('VID:PID ') + profile.vendor + ':' + profile.product : null,
|
||
profile.bus ? _('Bus ') + profile.bus : null,
|
||
profile.device ? _('Device ') + profile.device : null,
|
||
profile.port ? _('Port ') + profile.port : null
|
||
].filter(Boolean);
|
||
return E('div', { 'class': 'mb-profile-card' }, [
|
||
E('div', { 'class': 'mb-profile-header' }, [
|
||
E('div', {}, [
|
||
E('strong', {}, profile.label || profile.title || _('USB profile')),
|
||
E('div', { 'class': 'mb-profile-meta' }, meta.map(function(entry) {
|
||
return E('span', {}, entry);
|
||
}))
|
||
]),
|
||
E('button', {
|
||
'class': 'mb-btn mb-btn-secondary',
|
||
'click': function() { self.importProfile(profile); }
|
||
}, ['➕ ', _('Import preset')])
|
||
]),
|
||
profile.notes ? E('p', { 'class': 'mb-profile-notes' }, profile.notes) : null
|
||
]);
|
||
},
|
||
|
||
input: function(id, label, value, type) {
|
||
return E('div', { 'class': 'mb-input-group' }, [
|
||
E('label', { 'class': 'mb-stat-label', 'for': id }, label),
|
||
E('input', {
|
||
'class': 'mb-input',
|
||
'id': id,
|
||
'type': type || 'text',
|
||
'value': value
|
||
})
|
||
]);
|
||
},
|
||
|
||
makeAdapterInputId: function(id, field) {
|
||
return 'adapter-' + (id || 'x').replace(/[^a-z0-9_-]/ig, '_') + '-' + field;
|
||
},
|
||
|
||
normalizeAdapterId: function(id) {
|
||
return (id || '').replace(/[^a-z0-9_-]/ig, '_') || 'adapter_' + Math.random().toString(36).slice(2, 7);
|
||
},
|
||
|
||
healthClass: function(val) {
|
||
return (val || 'unknown').toString().toLowerCase().replace(/[^a-z0-9_-]/g, '-');
|
||
},
|
||
|
||
cloneAdapters: function(list) {
|
||
var cloned = [];
|
||
(list || []).forEach(function(item) {
|
||
var copy = {};
|
||
if (item) {
|
||
Object.keys(item).forEach(function(key) {
|
||
copy[key] = item[key];
|
||
});
|
||
}
|
||
cloned.push(copy);
|
||
});
|
||
return cloned;
|
||
},
|
||
|
||
collectSettings: function() {
|
||
return {
|
||
host: document.getElementById('broker-host').value,
|
||
port: parseInt(document.getElementById('broker-port').value, 10) || 1883,
|
||
username: document.getElementById('username').value,
|
||
password: document.getElementById('password').value,
|
||
base_topic: document.getElementById('base-topic').value,
|
||
retention: parseInt(document.getElementById('retention').value, 10) || 7
|
||
};
|
||
},
|
||
|
||
collectAdapters: function() {
|
||
var adapters = {};
|
||
var list = this.currentAdapters || [];
|
||
list.forEach(function(adapter) {
|
||
var id = adapter.id || adapter.section;
|
||
if (!id)
|
||
return;
|
||
var enabledEl = document.getElementById(this.makeAdapterInputId(id, 'enabled'));
|
||
var labelEl = document.getElementById(this.makeAdapterInputId(id, 'custom-label'));
|
||
var portEl = document.getElementById(this.makeAdapterInputId(id, 'custom-port'));
|
||
adapters[id] = {
|
||
enabled: enabledEl ? (enabledEl.checked ? 1 : 0) : 1,
|
||
label: labelEl ? labelEl.value : (adapter.label || ''),
|
||
port: portEl ? portEl.value : (adapter.port || ''),
|
||
preset: adapter.preset || '',
|
||
vendor: adapter.vendor || '',
|
||
product: adapter.product || ''
|
||
};
|
||
}, this);
|
||
return adapters;
|
||
},
|
||
|
||
refreshAdapterGrid: function() {
|
||
if (!this.adapterGrid)
|
||
return;
|
||
dom.content(this.adapterGrid, this.renderAdapterRows(this.currentAdapters));
|
||
},
|
||
|
||
importProfile: function(profile) {
|
||
if (!profile)
|
||
return;
|
||
var idSource = profile.id || profile.preset || (profile.vendor ? profile.vendor + '_' + profile.product : null);
|
||
var id = this.normalizeAdapterId(idSource);
|
||
|
||
if (!this.currentAdapters)
|
||
this.currentAdapters = [];
|
||
|
||
var exists = this.currentAdapters.some(function(entry) {
|
||
return (entry.id || entry.section) === id;
|
||
});
|
||
if (exists) {
|
||
ui.addNotification(null, E('p', {}, _('Preset already imported. Adjust preferences below.')), 'info');
|
||
return;
|
||
}
|
||
|
||
this.currentAdapters.push({
|
||
id: id,
|
||
label: profile.label || profile.title || id,
|
||
vendor: profile.vendor || '',
|
||
product: profile.product || '',
|
||
port: profile.port || '',
|
||
enabled: profile.detected ? 1 : 0,
|
||
detected: profile.detected ? 1 : 0,
|
||
health: profile.detected ? 'online' : 'missing',
|
||
preset: profile.id || profile.preset || ''
|
||
});
|
||
this.refreshAdapterGrid();
|
||
ui.addNotification(null, E('p', {}, _('Preset added. Remember to save preferences.')), 'info');
|
||
},
|
||
|
||
handleRescan: function(id) {
|
||
ui.showModal(_('Rescanning adapters'), [
|
||
E('p', {}, _('Triggering daemon rescan…')),
|
||
E('div', { 'class': 'spinning' })
|
||
]);
|
||
return API.rescanAdapters().then(function() {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', {}, _('Rescan triggered. Refresh status after a few seconds.')), 'info');
|
||
}).catch(function(err) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', {}, err.message || err), 'error');
|
||
});
|
||
},
|
||
|
||
handleReset: function(id) {
|
||
if (!id)
|
||
return;
|
||
var self = this;
|
||
ui.showModal(_('Reset adapter'), [
|
||
E('p', {}, _('Clear cached detection info for ') + id + '?'),
|
||
E('div', { 'class': 'right' }, [
|
||
E('button', { 'class': 'btn', 'click': ui.hideModal }, _('Cancel')),
|
||
E('button', {
|
||
'class': 'btn cbi-button-negative',
|
||
'click': function() {
|
||
API.resetAdapter({ adapter: id }).then(function() {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', {}, _('Adapter reset. Wait for next daemon scan.')), 'info');
|
||
}).catch(function(err) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', {}, err.message || err), 'error');
|
||
});
|
||
}
|
||
}, _('Reset'))
|
||
])
|
||
]);
|
||
},
|
||
|
||
savePreferences: function() {
|
||
var payload = this.collectSettings();
|
||
payload.adapters = this.collectAdapters();
|
||
|
||
ui.showModal(_('Saving MQTT settings'), [
|
||
E('p', {}, _('Applying broker configuration…')),
|
||
E('div', { 'class': 'spinning' })
|
||
]);
|
||
|
||
return API.applySettings(payload).then(function(result) {
|
||
ui.hideModal();
|
||
if (result && result.success) {
|
||
ui.addNotification(null, E('p', {}, _('Settings saved. Restarting bridge if required.')), 'info');
|
||
} else {
|
||
ui.addNotification(null, E('p', {}, result.error || _('Save failed')), 'error');
|
||
}
|
||
}).catch(function(err) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', {}, err.message || err), 'error');
|
||
});
|
||
}
|
||
});
|