diff --git a/.codex/HISTORY.md b/.codex/HISTORY.md index 322a82e..4d01c20 100644 --- a/.codex/HISTORY.md +++ b/.codex/HISTORY.md @@ -22,3 +22,9 @@ - **2025-12-29 – v0.5.0-A UI polish** Monitoring hero + modules filter migrated to SecuNav styling, alerts buttons use `sh-btn`, Help page adopts shared header and navbar Bonus tab, and overall theme consistency is verified on router. + +- **2025-12-29 – Theme selector live preview** + Settings now expose dark/light/system/cyberpunk options, preview changes instantly, and save preferences via the new `set_theme` RPC. + +- **2025-12-29 – Quick Deploy tooling** + Added `secubox-tools/quick-deploy.sh` with profiles (theme, full LuCI app), interactive `--src-select`, selective uploads, verification, and cache management. diff --git a/.codex/TODO.md b/.codex/TODO.md index defce11..805a69d 100644 --- a/.codex/TODO.md +++ b/.codex/TODO.md @@ -1,21 +1,17 @@ # TODO (Codex) -1. **Theme Selector** - - Extend SecuBox Settings to expose all Theme Manager variants (dark/light/system/cyberpunk). - - Live preview when flipping options; persist via RPC. - -2. **Component Library** +1. **Component Library** - Extract header chips/nav tabs into standalone modules under `luci-static/resources/secubox/components/`. - Provide TypeScript typings (or JS docstrings) for easier reuse. -3. **Validation Scripts** +2. **Validation Scripts** - Add `npm run lint:ui` (eslint + prettier) for LuCI JS. - Add `npm run check:luci` to run `lua -l luci.dispatcher` before SCP deploys. -4. **Docs** +3. **Docs** - Update `.codex/context.md` with quick deployment recipes (`deploy-secubox-v0.1.2.sh`, etc.). - Record router credentials requirements (currently warns about missing root password). -5. **Automation** +4. **Automation** - Create `secubox-tools/deploy-theme-only.sh` for CSS/JS pushes (no RPC). - Add `make snapshot` script to package updated LuCI app for feeds. diff --git a/.codex/WIP.md b/.codex/WIP.md index 7a585ca..a6af80b 100644 --- a/.codex/WIP.md +++ b/.codex/WIP.md @@ -5,6 +5,8 @@ - Unified Monitoring + Modules filters and Help view with SecuNav styling. - Added Bonus tab to navbar, refreshed alerts action buttons, removed legacy hero blocks. - Verified on router (scp + cache reset) and tagged release v0.5.0-A. +- Settings now surface dark/light/system/cyberpunk themes with live preview + RPC persistence. +- Built `secubox-tools/quick-deploy.sh` with interactive `--src-select`, LuCI profiles, verification, and cache-bust helpers. ## In Progress diff --git a/DOCS/QUICK-START.md b/DOCS/QUICK-START.md index f327b17..56bffc0 100644 --- a/DOCS/QUICK-START.md +++ b/DOCS/QUICK-START.md @@ -167,6 +167,41 @@ ssh root@192.168.8.191 "chmod 644 /www/luci-static/resources/**/*.css" ssh root@192.168.8.191 "rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* && /etc/init.d/rpcd restart && /etc/init.d/uhttpd restart" ``` +### Quick Deploy Helper +```bash +# IPK install via opkg (auto SCP + install) +./secubox-tools/quick-deploy.sh --ipk bin/packages/luci-app-secubox.ipk + +# APK install on newer images +./secubox-tools/quick-deploy.sh --apk dist/secubox-theme.apk + +# Push local source directory to /www/luci-static +./secubox-tools/quick-deploy.sh --src luci-app-secubox/htdocs --target-path /www/luci-static + +# Clone Git repo and deploy (branch optional) +./secubox-tools/quick-deploy.sh --git https://github.com/CyberMindStudio/secubox-theme.git --branch main + +# Selective push (only CSS + Settings view) +./secubox-tools/quick-deploy.sh --src luci-app-secubox/htdocs \ + --include luci-static/resources/secubox/secubox.css \ + --include luci-static/resources/view/secubox/settings.js + +# Root tree updates (rpcd, ACLs, etc.) +./secubox-tools/quick-deploy.sh --src luci-app-secubox/root --force-root + +# Legacy theme profile (mirrors deploy-theme-system.sh) +./secubox-tools/quick-deploy.sh --profile theme + +# Deploy complete LuCI app (root + htdocs) +./secubox-tools/quick-deploy.sh --profile luci-app --src luci-app-secubox + +# Browse LuCI apps and auto-pick one +./secubox-tools/quick-deploy.sh --list-apps +./secubox-tools/quick-deploy.sh --app secubox +./secubox-tools/quick-deploy.sh --src-select # interactive picker (TTY only) +``` +*Flags:* `--include` limits uploads, `--force-root` writes relative to `/`, `--profile` triggers opinionated bundles (`theme`, `luci-app`), `--app ` auto-resolves `luci-app-`, `--list-apps` prints detected apps, `--src-select` shows the same picker interactively, `--no-auto-profile` disables automatic LuCI detection when using `--src`, `--no-cache-bust` skips clearing `/tmp/luci-*`, `--no-verify` disables post-copy checksum checks, `--router root@192.168.8.191` overrides the target, and `--post "rm -rf /tmp/luci-*"` runs extra remote commands. Environment variables `ROUTER`, `TARGET_PATH`, `CACHE_BUST`, `VERIFY`, `SSH_OPTS`, and `SCP_OPTS` can be exported ahead of time. + ### Debug ```bash # Test RPCD diff --git a/luci-app-secubox/htdocs/luci-static/resources/secubox/api.js b/luci-app-secubox/htdocs/luci-static/resources/secubox/api.js index 44f6599..3161405 100644 --- a/luci-app-secubox/htdocs/luci-static/resources/secubox/api.js +++ b/luci-app-secubox/htdocs/luci-static/resources/secubox/api.js @@ -119,6 +119,13 @@ var callGetTheme = rpc.declare({ expect: { } }); +var callSetTheme = rpc.declare({ + object: 'luci.secubox', + method: 'set_theme', + params: ['theme'], + expect: { success: false, theme: '' } +}); + var callDismissAlert = rpc.declare({ object: 'luci.secubox', method: 'dismiss_alert', @@ -177,6 +184,7 @@ return baseclass.extend({ quickAction: callQuickAction, getDashboardData: callDashboardData, getTheme: callGetTheme, + setTheme: callSetTheme, dismissAlert: callDismissAlert, clearAlerts: callClearAlerts, fixPermissions: callFixPermissions, diff --git a/luci-app-secubox/htdocs/luci-static/resources/secubox/secubox.css b/luci-app-secubox/htdocs/luci-static/resources/secubox/secubox.css index 1796db2..09673b7 100644 --- a/luci-app-secubox/htdocs/luci-static/resources/secubox/secubox.css +++ b/luci-app-secubox/htdocs/luci-static/resources/secubox/secubox.css @@ -567,6 +567,182 @@ color: var(--sb-text-muted); } +/* Settings preference showcase */ +.secubox-pref-wrapper { + margin: 20px 0 30px; + padding: 22px; + border-radius: 18px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.14), rgba(14, 165, 233, 0.08)) border-box, + var(--sb-bg-card); + border: 1px solid rgba(99, 102, 241, 0.3); + box-shadow: 0 20px 45px rgba(14, 19, 34, 0.45); +} + +.secubox-pref-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 12px; +} + +.secubox-pref-kicker { + text-transform: uppercase; + letter-spacing: 0.2em; + font-size: 11px; + margin: 0 0 6px; + color: var(--sb-text-muted); +} + +.secubox-pref-title { + margin: 0; + font-size: 20px; + color: var(--sb-text); +} + +.secubox-pref-subtitle { + margin: 6px 0 0; + color: var(--sb-text-muted); +} + +.secubox-pref-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 20px; +} + +.secubox-pref-card { + padding: 16px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(15, 23, 42, 0.55); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02); + min-height: 150px; + transition: transform 0.2s ease, border-color 0.2s ease; +} + +.secubox-pref-card:hover { + transform: translateY(-2px); + border-color: rgba(99, 102, 241, 0.4); +} + +.secubox-pref-card.success { + border-color: rgba(34, 197, 94, 0.3); +} + +.secubox-pref-card.danger { + border-color: rgba(239, 68, 68, 0.35); +} + +.secubox-pref-card.info { + border-color: rgba(14, 165, 233, 0.35); +} + +.secubox-pref-icon { + font-size: 26px; + line-height: 1; + margin-bottom: 12px; +} + +.secubox-pref-label { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.3em; + color: var(--sb-text-muted); + margin: 0 0 6px; +} + +.secubox-pref-value { + margin: 0; + font-size: 20px; + font-weight: 600; + color: var(--sb-text); +} + +.secubox-pref-detail { + margin: 8px 0 0; + color: var(--sb-text-muted); + font-size: 13px; + line-height: 1.5; +} + +.theme-preview-panel { + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.3); + background: rgba(15, 23, 42, 0.45); +} + +/* Settings form details */ +.secubox-settings-page .cbi-map { + border: none; + padding: 0; + margin: 0; +} + +.secubox-settings-page .cbi-section { + margin: 0 0 28px; + padding: 18px 20px; + border-radius: 18px; + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(255, 255, 255, 0.04); + box-shadow: 0 10px 30px rgba(8, 12, 20, 0.4); +} + +.secubox-settings-page .cbi-section legend, +.secubox-settings-page .cbi-section h3 { + margin: 0 0 18px; + font-size: 18px; + color: var(--sb-text); + font-weight: 600; +} + +.secubox-settings-page .cbi-value { + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + padding: 14px 0; +} + +.secubox-settings-page .cbi-value:last-child { + border-bottom: none; +} + +.secubox-settings-page .cbi-value-title label, +.secubox-settings-page .cbi-value-title { + color: var(--sb-text); + font-weight: 500; + font-size: 15px; +} + +.secubox-settings-page .cbi-value-field { + color: var(--sb-text-muted); +} + +.secubox-settings-page select, +.secubox-settings-page input[type="text"], +.secubox-settings-page input[type="number"] { + width: 100%; + background: rgba(15, 23, 42, 0.8); + color: var(--sb-text); + border: 1px solid rgba(99, 102, 241, 0.3); + border-radius: 10px; + padding: 10px 12px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.secubox-settings-page select:focus, +.secubox-settings-page input[type="text"]:focus, +.secubox-settings-page input[type="number"]:focus { + outline: none; + border-color: var(--sb-primary); + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.25); +} + +.secubox-settings-page .cbi-section-descr, +.secubox-settings-page .cbi-value-description { + color: var(--sb-text-muted); + font-size: 13px; +} + /* Responsive Design */ @media (max-width: 768px) { .secubox-health-grid { diff --git a/luci-app-secubox/htdocs/luci-static/resources/secubox/theme.js b/luci-app-secubox/htdocs/luci-static/resources/secubox/theme.js index a91aa78..b2ae477 100644 --- a/luci-app-secubox/htdocs/luci-static/resources/secubox/theme.js +++ b/luci-app-secubox/htdocs/luci-static/resources/secubox/theme.js @@ -4,11 +4,13 @@ /** * SecuBox Theme Manager - * Manages dark/light/system theme switching across SecuBox and all modules - * Version: 1.0.0 + * Manages dark/light/system/cyberpunk theme switching across SecuBox modules + * Version: 1.1.0 */ -console.log('🎨 SecuBox Theme Manager v1.0.0 loaded'); +var SUPPORTED_THEMES = ['dark', 'light', 'system', 'cyberpunk']; + +console.log('🎨 SecuBox Theme Manager v1.1.0 loaded'); return baseclass.extend({ /** @@ -20,6 +22,9 @@ return baseclass.extend({ return API.getTheme().then(function(data) { var themePref = data.theme || 'dark'; + if (SUPPORTED_THEMES.indexOf(themePref) === -1) { + themePref = 'dark'; + } self.applyTheme(themePref); // Listen for system theme changes if preference is 'system' @@ -37,20 +42,21 @@ return baseclass.extend({ /** * Apply theme to the page - * @param {string} theme - Theme preference: 'dark', 'light', or 'system' + * @param {string} theme - Theme preference: 'dark', 'light', 'system', or 'cyberpunk' */ applyTheme: function(theme) { - var effectiveTheme = theme; + var selectedTheme = SUPPORTED_THEMES.indexOf(theme) > -1 ? theme : 'dark'; + var effectiveTheme = selectedTheme; // If 'system', detect from OS - if (theme === 'system' && window.matchMedia) { + if (selectedTheme === 'system' && window.matchMedia) { effectiveTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } // Apply theme to document root document.documentElement.setAttribute('data-theme', effectiveTheme); - console.log('🎨 Theme applied:', theme, '(effective:', effectiveTheme + ')'); + console.log('🎨 Theme applied:', selectedTheme, '(effective:', effectiveTheme + ')'); }, /** @@ -63,7 +69,7 @@ return baseclass.extend({ /** * Get theme preference from backend - * @returns {Promise} Theme preference ('dark', 'light', or 'system') + * @returns {Promise} Theme preference ('dark', 'light', 'system', or 'cyberpunk') */ getTheme: function() { return API.getTheme().then(function(data) { @@ -72,5 +78,18 @@ return baseclass.extend({ console.error('Failed to load theme preference:', err); return 'dark'; }); + }, + + /** + * Apply and persist theme preference + * @param {string} theme + * @returns {Promise} + */ + setTheme: function(theme) { + this.applyTheme(theme); + return API.setTheme(theme).catch(function(err) { + console.error('Failed to persist theme preference:', err); + throw err; + }); } }); diff --git a/luci-app-secubox/htdocs/luci-static/resources/view/secubox/settings.js b/luci-app-secubox/htdocs/luci-static/resources/view/secubox/settings.js index 2931bd2..b6186c6 100644 --- a/luci-app-secubox/htdocs/luci-static/resources/view/secubox/settings.js +++ b/luci-app-secubox/htdocs/luci-static/resources/view/secubox/settings.js @@ -12,6 +12,79 @@ var secuLang = (typeof L !== 'undefined' && L.env && L.env.lang) || (navigator.language ? navigator.language.split('-')[0] : 'en'); Theme.init({ language: secuLang }); +var THEME_CHOICES = ['dark', 'light', 'system', 'cyberpunk']; + +function sanitizeTheme(theme) { + return THEME_CHOICES.indexOf(theme) > -1 ? theme : 'dark'; +} + +function getMainValue(option, fallback) { + var val = uci.get('secubox', 'main', option); + return (val != null && val !== '') ? val : fallback; +} + +function getMainBool(option, fallback) { + var defaultValue = fallback ? '1' : '0'; + return getMainValue(option, defaultValue) !== '0'; +} + +function getThemeLabel(theme) { + switch (theme) { + case 'light': + return _('Light'); + case 'system': + return _('System Preference'); + case 'cyberpunk': + return _('Cyberpunk'); + default: + return _('Dark (Default)'); + } +} + +function getThemeDescription(theme) { + switch (theme) { + case 'light': + return _('Bright and clean layout for well-lit spaces.'); + case 'system': + return _('Follows your OS or browser preference automatically.'); + case 'cyberpunk': + return _('Neon purples, synth glow effects, and extra contrast.'); + default: + return _('Modern neon-friendly dark interface (default).'); + } +} + +function describeThemeChoice(theme) { + var label = getThemeLabel(theme); + var desc = getThemeDescription(theme); + return desc ? label + ' - ' + desc : label; +} + +function formatRefreshLabel(interval) { + switch (interval) { + case '15': + return _('Every 15 seconds'); + case '30': + return _('Every 30 seconds'); + case '60': + return _('Every minute'); + case '0': + return _('Manual refresh only'); + default: + return _('Every %s seconds').format(interval || '30'); + } +} + +function describeAutomation(autoDiscovery, autoStart) { + if (autoDiscovery && autoStart) + return _('New modules are discovered and auto-started.'); + if (autoDiscovery && !autoStart) + return _('Discovery is on, but modules require manual start.'); + if (!autoDiscovery && autoStart) + return _('Manual discovery, but auto-start once registered.'); + return _('Fully manual provisioning workflow.'); +} + return view.extend({ load: function() { return Promise.all([ @@ -23,10 +96,23 @@ return view.extend({ render: function(data) { var status = data[1] || {}; - var theme = data[2]; + var theme = sanitizeTheme(data[2]); + var versionPref = getMainValue('version', '0.1.2'); + var refreshPref = getMainValue('refresh_interval', '30'); + var notificationsPref = getMainBool('notifications', true); + var autoDiscoveryPref = getMainBool('auto_discovery', true); + var autoStartPref = getMainBool('auto_start', false); + var secuboxEnabled = (typeof status.enabled !== 'undefined') ? !!status.enabled : getMainBool('enabled', true); + var versionDisplay = (status.version && status.version !== 'unknown') ? status.version : versionPref; + var moduleCount = (status.modules_total || status.modules_total === 0) ? status.modules_total : '—'; var m, s, o; // Create wrapper container with modern header + var versionChip = this.renderHeaderChip('🏷️', _('Version'), versionDisplay || '—', 'neutral'); + var statusChip = this.renderHeaderChip('⚡', _('Status'), secuboxEnabled ? _('On') : _('Off'), + secuboxEnabled ? 'success' : 'danger'); + var modulesChip = this.renderHeaderChip('🧩', _('Modules'), moduleCount); + var container = E('div', { 'class': 'secubox-settings-page' }, [ E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox/common.css') }), E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox/secubox.css') }), @@ -43,11 +129,18 @@ return view.extend({ 'Configure global settings for the SecuBox security suite') ]), E('div', { 'class': 'sh-header-meta' }, [ - this.renderHeaderChip('🏷️', _('Version'), status.version || '0.1.2'), - this.renderHeaderChip('⚡', _('Status'), status.enabled ? _('On') : _('Off'), status.enabled ? 'success' : 'danger'), - this.renderHeaderChip('🧩', _('Modules'), status.modules_count || '14') + versionChip, + statusChip, + modulesChip ]) - ]) + ]), + this.renderPreferenceShowcase({ + theme: theme, + refresh: refreshPref, + notifications: notificationsPref, + autoDiscovery: autoDiscoveryPref, + autoStart: autoStartPref + }) ]); // Create form @@ -66,7 +159,10 @@ return view.extend({ o = s.option(form.Value, 'version', '📦 Version', 'Current SecuBox version (read-only)'); o.readonly = true; - o.default = '0.1.2'; + o.default = versionPref; + o.cfgvalue = function() { + return versionDisplay; + }; // Dashboard Settings Section s = m.section(form.TypedSection, 'secubox', '📊 Dashboard Settings'); @@ -75,10 +171,94 @@ return view.extend({ o = s.option(form.ListValue, 'theme', '🎨 Dashboard Theme', 'Choose the visual theme for the SecuBox dashboard'); - o.value('dark', 'Dark (Default) - Modern dark interface'); - o.value('light', 'Light - Bright and clean'); - o.value('system', 'System Preference - Auto detect'); + THEME_CHOICES.forEach(function(choice) { + o.value(choice, describeThemeChoice(choice)); + }); o.default = 'dark'; + o.currentThemePref = theme; + o.renderWidget = function(section_id, option_index, cfgvalue) { + var widget = form.ListValue.prototype.renderWidget.apply(this, [section_id, option_index, cfgvalue]); + var select = widget.querySelector('select'); + if (!select) + return widget; + + var initialSelection = sanitizeTheme(this.currentThemePref || cfgvalue); + var lastPersisted = initialSelection; + var previewLabel = E('strong', { 'class': 'theme-preview-label' }, getThemeLabel(initialSelection)); + var previewHint = E('p', { + 'class': 'theme-preview-hint', + 'style': 'margin: 4px 0; font-size: 0.9em;' + }, getThemeDescription(initialSelection)); + var statusLine = E('p', { + 'class': 'theme-preview-status', + 'style': 'margin: 0; font-size: 0.85em; color: var(--sh-muted, #94a3b8);' + }, _('Synced with router preferences.')); + + var previewPanel = E('div', { + 'class': 'theme-preview-panel', + 'style': 'margin-top: 10px; padding: 10px; border: 1px dashed var(--sh-border, #475569); border-radius: 10px;' + }, [ + E('div', { + 'class': 'theme-preview-title', + 'style': 'font-weight: 600; font-size: 0.95em;' + }, _('Live preview & instant save')), + E('div', { + 'class': 'theme-preview-current', + 'style': 'margin-top: 6px; font-size: 0.95em;' + }, [ + E('span', { 'style': 'color: var(--sh-muted, #94a3b8);' }, _('Current choice: ')), + previewLabel + ]), + previewHint, + statusLine + ]); + + widget.appendChild(previewPanel); + + function updatePreview(choice) { + previewLabel.textContent = getThemeLabel(choice); + previewHint.textContent = getThemeDescription(choice); + } + + function setStatus(message) { + statusLine.textContent = message; + } + + function persistTheme(choice) { + var targetTheme = sanitizeTheme(choice); + if (targetTheme === lastPersisted) { + Theme.applyTheme(targetTheme); + setStatus(_('Theme already active.')); + return Promise.resolve(); + } + + select.disabled = true; + setStatus(_('Saving theme preference...')); + return Theme.setTheme(targetTheme).then(function() { + lastPersisted = targetTheme; + setStatus(_('Theme updated and saved via RPC.')); + }).catch(function(err) { + console.error('Failed to save SecuBox theme via RPC', err); + ui.addNotification(null, E('p', _('Unable to update theme preference. Please try again.')), 'error'); + select.value = lastPersisted; + updatePreview(lastPersisted); + Theme.applyTheme(lastPersisted); + setStatus(_('Reverted to previous theme.')); + }).finally(function() { + select.disabled = false; + }); + } + + select.addEventListener('change', function(ev) { + var nextTheme = sanitizeTheme(ev.target.value); + updatePreview(nextTheme); + persistTheme(nextTheme); + }); + + // Ensure preview reflects initial value from backend + updatePreview(initialSelection); + return widget; + }; o = s.option(form.ListValue, 'refresh_interval', '🔄 Auto-Refresh Interval', 'How often to refresh dashboard data automatically'); @@ -239,7 +419,9 @@ return view.extend({ // Render form and append to container return m.render().then(L.bind(function(formElement) { - container.appendChild(formElement); + var formWrapper = E('div', { 'class': 'secubox-settings-form' }, formElement); + container.appendChild(formWrapper); + this.bindStatusChip(formElement, statusChip); return container; }, this)); }, @@ -253,5 +435,79 @@ return view.extend({ E('strong', {}, display) ]) ]); + }, + + renderPreferenceShowcase: function(prefs) { + var cards = [ + this.renderPreferenceCard('🎨', _('Theme Preference'), + getThemeLabel(prefs.theme), + getThemeDescription(prefs.theme)), + this.renderPreferenceCard('🔄', _('Auto Refresh'), + formatRefreshLabel(prefs.refresh), + _('Controls dashboard polling cadence.')), + this.renderPreferenceCard('🔔', _('Notifications'), + prefs.notifications ? _('Enabled') : _('Disabled'), + prefs.notifications ? _('Browser alerts will be shown for module events.') : + _('Silences browser alerts but logging continues.'), + prefs.notifications ? 'success' : 'danger'), + this.renderPreferenceCard('🤖', _('Automation'), + prefs.autoStart ? _('Auto-start On') : _('Manual start'), + describeAutomation(prefs.autoDiscovery, prefs.autoStart), + prefs.autoStart ? 'info' : '') + ]; + + return E('div', { 'class': 'secubox-pref-wrapper' }, [ + E('div', { 'class': 'secubox-pref-header' }, [ + E('div', { 'class': 'secubox-pref-title-block' }, [ + E('p', { 'class': 'secubox-pref-kicker' }, _('Configuration Snapshot')), + E('h3', { 'class': 'secubox-pref-title' }, _('Current Preferences')), + E('p', { 'class': 'secubox-pref-subtitle' }, + _('Key SecuBox preferences at a glance.')) + ]) + ]), + E('div', { 'class': 'secubox-pref-grid' }, + cards.filter(function(card) { return !!card; })) + ]); + }, + + renderPreferenceCard: function(icon, label, value, detail, tone) { + return E('div', { 'class': 'secubox-pref-card sh-card' + (tone ? ' ' + tone : '') }, [ + E('div', { 'class': 'secubox-pref-icon' }, icon), + E('div', { 'class': 'secubox-pref-body' }, [ + E('p', { 'class': 'secubox-pref-label' }, label), + E('p', { 'class': 'secubox-pref-value' }, value), + detail ? E('p', { 'class': 'secubox-pref-detail' }, detail) : null + ]) + ]); + }, + + updateHeaderChip: function(chip, value, tone) { + if (!chip) + return; + + var valueEl = chip.querySelector('strong'); + if (valueEl) + valueEl.textContent = value; + + chip.classList.remove('success', 'danger', 'warning', 'info', 'neutral'); + if (tone) + chip.classList.add(tone); + }, + + bindStatusChip: function(formElement, chip) { + if (!formElement || !chip) + return; + var toggle = formElement.querySelector('input[name="cbid.secubox.secubox.enabled"]'); + if (!toggle) + return; + + var self = this; + var sync = function() { + var isOn = toggle.checked; + self.updateHeaderChip(chip, isOn ? _('On') : _('Off'), isOn ? 'success' : 'danger'); + }; + + toggle.addEventListener('change', sync); + sync(); } }); diff --git a/luci-app-secubox/root/etc/config/secubox b/luci-app-secubox/root/etc/config/secubox index 8b19a1e..6b80477 100644 --- a/luci-app-secubox/root/etc/config/secubox +++ b/luci-app-secubox/root/etc/config/secubox @@ -1,9 +1,29 @@ config secubox 'main' option enabled '1' - option version '0.1.2' + option version '0.5.0-A' option auto_discovery '1' + option auto_start '0' option notifications '1' + option notify_module_start '1' + option notify_module_stop '1' + option notify_alerts '1' + option notify_health_issues '1' + option refresh_interval '30' + option show_system_stats '1' + option show_module_grid '1' option theme 'dark' + option require_auth '1' + option audit_logging '1' + option audit_retention '30' + option debug_mode '0' + option api_timeout '30' + option max_modules '20' + option cpu_warning '70' + option cpu_critical '85' + option memory_warning '70' + option memory_critical '85' + option disk_warning '70' + option disk_critical '85' # Module definitions - populated dynamically when modules are installed # Each module adds its own section on install diff --git a/luci-app-secubox/root/usr/libexec/rpcd/luci.secubox b/luci-app-secubox/root/usr/libexec/rpcd/luci.secubox index 29f7b5c..f2178d8 100755 --- a/luci-app-secubox/root/usr/libexec/rpcd/luci.secubox +++ b/luci-app-secubox/root/usr/libexec/rpcd/luci.secubox @@ -8,9 +8,45 @@ . /usr/share/libubox/jshn.sh get_pkg_version() { + local version="" + + # OpenWrt 25.12+ uses apk packages + if command -v apk >/dev/null 2>&1; then + local apk_line=$(apk info -v luci-app-secubox 2>/dev/null | grep -E '^luci-app-secubox-[0-9]' | head -n1) + if [ -n "$apk_line" ]; then + version=$(echo "$apk_line" | sed 's/^luci-app-secubox-//' | awk '{print $1}' | sed 's/-r[0-9]*$//') + else + version=$(apk info luci-app-secubox 2>/dev/null | awk -F': ' '/^Version/ {print $2; exit}') + fi + if [ -n "$version" ]; then + echo "$version" + return + fi + fi + + # Legacy opkg metadata local ctrl="/usr/lib/opkg/info/luci-app-secubox.control" if [ -f "$ctrl" ]; then - awk -F': ' '/^Version/ { print $2; exit }' "$ctrl" + version=$(awk -F': ' '/^Version/ { print $2; exit }' "$ctrl") + if [ -n "$version" ]; then + echo "$version" + return + fi + fi + + if command -v opkg >/dev/null 2>&1; then + version=$(opkg list-installed luci-app-secubox 2>/dev/null | awk '{print $3}' | sed 's/-r[0-9]*$//' | head -n1) + if [ -n "$version" ]; then + echo "$version" + return + fi + fi + + local cfg_version="$(uci -q get secubox.main.version)" + if [ -n "$cfg_version" ]; then + echo "$cfg_version" + elif [ -f "/usr/share/secubox/VERSION" ]; then + head -n1 /usr/share/secubox/VERSION else echo "unknown" fi @@ -138,6 +174,7 @@ get_status() { json_init json_add_string "version" "$PKG_VERSION" json_add_string "hostname" "$(uci -q get system.@system[0].hostname || echo 'SecuBox')" + json_add_boolean "enabled" "$(uci -q get secubox.main.enabled || echo 1)" # System info local uptime=$(cat /proc/uptime | cut -d. -f1) @@ -966,6 +1003,36 @@ get_theme() { json_dump } +# Persist theme setting +set_theme() { + local input theme="dark" + + read -r input + [ -n "$input" ] && json_load "$input" + json_get_var theme theme "dark" + + case "$theme" in + dark|light|system|cyberpunk) + ;; + *) + json_init + json_add_boolean "success" 0 + json_add_string "message" "Invalid theme" + json_dump + return 1 + ;; + esac + + uci -q set secubox.main.theme="$theme" + uci -q commit secubox + + json_init + json_add_boolean "success" 1 + json_add_string "theme" "$theme" + json_add_string "message" "Theme updated" + json_dump +} + # Dismiss a specific alert dismiss_alert() { local alert_id="$1" @@ -1069,6 +1136,9 @@ case "$1" in json_close_object json_add_object "get_theme" json_close_object + json_add_object "set_theme" + json_add_string "theme" "string" + json_close_object json_add_object "dismiss_alert" json_add_string "alert_id" "string" json_close_object @@ -1161,6 +1231,9 @@ case "$1" in get_theme) get_theme ;; + set_theme) + set_theme + ;; dismiss_alert) read -r input json_load "$input" diff --git a/luci-app-secubox/root/usr/share/rpcd/acl.d/luci-app-secubox.json b/luci-app-secubox/root/usr/share/rpcd/acl.d/luci-app-secubox.json index 0c8335f..11def90 100644 --- a/luci-app-secubox/root/usr/share/rpcd/acl.d/luci-app-secubox.json +++ b/luci-app-secubox/root/usr/share/rpcd/acl.d/luci-app-secubox.json @@ -34,6 +34,7 @@ "enable_module", "disable_module", "quick_action", + "set_theme", "dismiss_alert", "clear_alerts", "fix_permissions" diff --git a/secubox-tools/quick-deploy.sh b/secubox-tools/quick-deploy.sh new file mode 100755 index 0000000..29f26ab --- /dev/null +++ b/secubox-tools/quick-deploy.sh @@ -0,0 +1,555 @@ +#!/bin/bash +set -euo pipefail + +ROUTER="${ROUTER:-root@192.168.8.191}" +TARGET_PATH="${TARGET_PATH:-/www/luci-static}" +SSH_OPTS=${SSH_OPTS:--o RequestTTY=no -o ForwardX11=no} +SCP_OPTS=${SCP_OPTS:-} +CACHE_BUST=${CACHE_BUST:-1} +VERIFY=${VERIFY:-1} +FORCE_ROOT="false" +INCLUDE_PATHS=() +VERIFY_ERRORS=0 +PROFILE="" +APP_NAME="" +APP_PATH="" +AUTO_PROFILE=${AUTO_PROFILE:-1} +LIST_APPS=0 +REMOTE_HASH_CMD="" + +MODE="" +PKG_PATH="" +SRC_PATH="" +GIT_URL="" +GIT_BRANCH="" +POST_CMD="" + +usage() { + cat <<'USAGE' +Usage: quick-deploy.sh [options] + +Deploy packages or source archives to the development router. + +Options (choose one source): + --ipk Upload + install an IPK via opkg. + --apk Upload + install an APK via apk add. + --src Tar + upload a local directory to --target-path. + --git Clone repo (optionally --branch) then upload. + --profile Use a predefined deployment profile (e.g. theme, luci-app). + --app Shortcut for --profile luci-app; auto-resolves `luci-app-` + --list-apps List detected `luci-app-*` directories and exit. + +Common flags: + --router Override router target (default root@192.168.8.191). + --target-path Destination for source uploads (default /www/luci-static). + --include Repeatable. Only include matching subpaths when using --src/--git. + --branch Git branch/tag when using --git. + --no-cache-bust Skip clearing /tmp/luci-* after deploy. + --no-verify Skip post-deploy file verification. + --force-root Allow --src to write directly under /. Use with caution. + --no-auto-profile Disable automatic LuCI app detection when using --src. + --post Extra remote command to run after deploy. + -h, --help Show this message. + +Environment variables: + ROUTER, TARGET_PATH, SSH_OPTS, SCP_OPTS can also be exported ahead of time. +USAGE + exit 1 +} + +log() { + echo -e "[$(date +'%H:%M:%S')] $*" +} + +remote_exec() { + ssh $SSH_OPTS "$ROUTER" "$@" +} + +copy_file() { + scp $SCP_OPTS "$1" "$ROUTER:$2" +} + +join_path() { + local base="$1" + local rel="$2" + if [[ "$base" == "/" ]]; then + echo "/$rel" | sed 's#//\+#/#g' + else + echo "$base/$rel" | sed 's#//\+#/#g' + fi +} + +ensure_remote_hash() { + if [[ -n "$REMOTE_HASH_CMD" ]]; then + return 0 + fi + local cmd + cmd=$(remote_exec "for c in sha1sum sha256sum md5sum; do if command -v \$c >/dev/null 2>&1; then echo \$c; break; fi; done") || true + if [[ -z "$cmd" ]]; then + log "⚠️ No hash utility found on router; skipping verification" + VERIFY=0 + return 1 + fi + REMOTE_HASH_CMD="$cmd" + return 0 +} + +verify_remote() { + local dir="$1" + local base="$TARGET_PATH" + [[ "$FORCE_ROOT" == "true" ]] && base="/" + ensure_remote_hash || return + local -a candidates + if [[ ${#INCLUDE_PATHS[@]} -gt 0 ]]; then + for inc in "${INCLUDE_PATHS[@]}"; do + local path="$dir/$inc" + if [[ -d "$path" ]]; then + while IFS= read -r f; do + candidates+=("$f") + done < <(find "$path" -type f -not -path '*/.git/*' | sort) + elif [[ -f "$path" ]]; then + candidates+=("$path") + fi + done + else + while IFS= read -r f; do + candidates+=("$f") + done < <(find "$dir" -type f -not -path '*/.git/*' | sort) + fi + local -a samples + for f in "${candidates[@]}"; do + samples+=("$f") + [[ ${#samples[@]} -ge 5 ]] && break + done + if [[ ${#samples[@]} -eq 0 ]]; then + log "No files to verify" + return + fi + log "Verifying ${#samples[@]} files on router..." + for file in "${samples[@]}"; do + local rel=${file#$dir/} + local local_sum=$($REMOTE_HASH_CMD "$file" | awk '{print $1}') + local remote_path=$(join_path "$base" "$rel") + local remote_sum + remote_sum=$(remote_exec "if [ -f '$remote_path' ]; then $REMOTE_HASH_CMD '$remote_path' | awk '{print \\$1}'; fi") || true + if [[ -z "$remote_sum" ]]; then + log "⚠️ Missing remote file: $remote_path" + VERIFY_ERRORS=1 + elif [[ "$remote_sum" != "$local_sum" ]]; then + log "⚠️ Mismatch for $remote_path" + log " local: $local_sum" + log " remote: $remote_sum" + VERIFY_ERRORS=1 + else + log "✅ $rel" + fi + done +} + +collect_luci_apps() { + find . -maxdepth 1 -type d -name 'luci-app-*' | LC_ALL=C sort +} + +list_luci_apps() { + local apps=() + while IFS= read -r d; do + apps+=("${d#./}") + done < <(collect_luci_apps) + if [[ ${#apps[@]} -eq 0 ]]; then + log "No luci-app-* directories detected in $(pwd)" + return 1 + fi + log "Available LuCI apps:" + for d in "${apps[@]}"; do + printf ' - %s\n' "$d" + done + return 0 +} + +prompt_select_app() { + local apps=() + while IFS= read -r d; do + apps+=("${d#./}") + done < <(collect_luci_apps) + if [[ ${#apps[@]} -eq 0 ]]; then + echo "No LuCI apps discovered." >&2 + return 1 + fi + if [[ ! -t 0 ]]; then + printf 'Available apps:%s' "\n" + for d in "${apps[@]}"; do + printf ' - %s\n' "$d" + done + echo "(non-interactive shell: rerun with --app )" >&2 + return 1 + fi + echo "Select a LuCI app to deploy (type number or name, q to abort):" + local old_ps3=${PS3:-""} + PS3="Choice (q to abort): " + select choice in "${apps[@]}"; do + if [[ "$REPLY" == "q" || "$REPLY" == "quit" ]]; then + PS3="$old_ps3" + return 1 + fi + if [[ -n "$choice" ]]; then + PS3="$old_ps3" + echo "$choice" + return 0 + fi + echo "Invalid selection." + done + PS3="$old_ps3" +} + +resolve_app_dir() { + local input="$1" + if [[ -z "$input" ]]; then + return 1 + fi + if [[ -d "$input" ]]; then + echo "$input" + return 0 + fi + if [[ -d "luci-app-$input" ]]; then + echo "luci-app-$input" + return 0 + fi + return 1 +} + +normalize_app_path() { + local input="$1" + local candidate="" + + if [[ -z "$input" ]]; then + return 1 + fi + + # If absolute path, trust it + if [[ "$input" == /* ]]; then + candidate="$input" + else + candidate="$PWD/$input" + fi + + if [[ -d "$candidate" ]]; then + echo "$candidate" + return 0 + fi + + # Fall back to raw input (in case caller already passed ./relative) + if [[ -d "$input" ]]; then + if [[ "$input" == /* ]]; then + echo "$input" + else + echo "$PWD/$input" + fi + return 0 + fi + + return 1 +} + +deploy_profile_theme() { + log "🎨 Deploying theme profile to $ROUTER" + local files=( + "luci-app-secubox/root/usr/libexec/rpcd/luci.secubox:/usr/libexec/rpcd/" + "luci-app-secubox/root/usr/share/rpcd/acl.d/luci-app-secubox.json:/usr/share/rpcd/acl.d/" + "luci-app-secubox/htdocs/luci-static/resources/secubox/api.js:/www/luci-static/resources/secubox/" + "luci-app-secubox/htdocs/luci-static/resources/secubox/theme.js:/www/luci-static/resources/secubox/" + "luci-app-secubox/htdocs/luci-static/resources/secubox/secubox.css:/www/luci-static/resources/secubox/" + "luci-app-secubox/htdocs/luci-static/resources/view/secubox/dashboard.js:/www/luci-static/resources/view/secubox/" + "luci-app-system-hub/htdocs/luci-static/resources/system-hub/theme.js:/www/luci-static/resources/system-hub/" + "luci-app-system-hub/htdocs/luci-static/resources/system-hub/dashboard.css:/www/luci-static/resources/system-hub/" + "luci-app-system-hub/htdocs/luci-static/resources/view/system-hub/overview.js:/www/luci-static/resources/view/system-hub/" + ) + for entry in "${files[@]}"; do + local src=${entry%%:*} + local dest=${entry##*:} + log "Copying $src -> $dest" + copy_file "$src" "$dest" + done + log "Setting permissions + restarting rpcd" + remote_exec "chmod +x /usr/libexec/rpcd/luci.secubox && \\ + chmod 644 /www/luci-static/resources/secubox/*.{js,css} 2>/dev/null || true && \\ + chmod 644 /www/luci-static/resources/system-hub/*.{js,css} 2>/dev/null || true && \\ + chmod 644 /www/luci-static/resources/view/secubox/*.js 2>/dev/null || true && \\ + chmod 644 /www/luci-static/resources/view/system-hub/*.js 2>/dev/null || true && \\ + rm -rf /tmp/luci-* && /etc/init.d/rpcd restart" +} + +deploy_profile_luci_app() { + local app_dir="$1" + if [[ -z "$app_dir" || ! -d "$app_dir" ]]; then + echo "Error: --profile luci-app requires --src to point at the application root" >&2 + exit 1 + fi + local app_name=$(basename "$app_dir") + log "📦 Deploying LuCI app $app_name" + local prev_target="$TARGET_PATH" + local prev_force="$FORCE_ROOT" + local prev_includes=("${INCLUDE_PATHS[@]}") + INCLUDE_PATHS=() + local root_src="$app_dir/root" + local htdocs_src="$app_dir/htdocs" + if [[ -d "$root_src" ]]; then + FORCE_ROOT="true" + TARGET_PATH="/" + upload_source_dir "$root_src" + fi + if [[ -d "$htdocs_src" ]]; then + FORCE_ROOT="false" + TARGET_PATH="/www" + upload_source_dir "$htdocs_src" + fi + remote_exec "rm -rf /tmp/luci-*" + TARGET_PATH="$prev_target" + FORCE_ROOT="$prev_force" + INCLUDE_PATHS=("${prev_includes[@]}") +} + +cleanup_tmp="" +trap '[[ -n "$cleanup_tmp" && -d "$cleanup_tmp" ]] && rm -rf "$cleanup_tmp"' EXIT + +while [[ $# -gt 0 ]]; do + case "$1" in + --router) + ROUTER="$2"; shift 2 ;; + --target-path) + TARGET_PATH="$2"; shift 2 ;; + --include) + INCLUDE_PATHS+=("$2"); shift 2 ;; + --ipk) + MODE="ipk"; PKG_PATH="$2"; shift 2 ;; + --apk) + MODE="apk"; PKG_PATH="$2"; shift 2 ;; + --src) + MODE="src"; SRC_PATH="$2"; shift 2 ;; + --src-select) + MODE="src"; SRC_PATH=""; shift ;; + --git) + MODE="git"; GIT_URL="$2"; shift 2 ;; + --profile) + PROFILE="$2"; shift 2 ;; + --app) + APP_NAME="$2"; shift 2 ;; + --list-apps) + LIST_APPS=1; shift ;; + --branch) + GIT_BRANCH="$2"; shift 2 ;; + --post) + POST_CMD="$2"; shift 2 ;; + --no-cache-bust) + CACHE_BUST=0; shift ;; + --no-verify) + VERIFY=0; shift ;; + --force-root) + FORCE_ROOT="true"; shift ;; + --no-auto-profile) + AUTO_PROFILE=0; shift ;; + -h|--help) + usage ;; + *) + echo "Unknown option: $1" >&2 + usage ;; + esac +done + +if [[ "$APP_NAME" == "list" ]]; then + LIST_APPS=1 + APP_NAME="" +fi + +if [[ $LIST_APPS -eq 1 ]]; then + list_luci_apps + exit 0 +fi + +if [[ -n "$APP_NAME" ]]; then + APP_PATH=$(resolve_app_dir "$APP_NAME") || true + if [[ -z "$APP_PATH" ]]; then + echo "Unable to locate LuCI app '$APP_NAME'" >&2 + list_luci_apps + exit 1 + fi + SRC_PATH=$(normalize_app_path "$APP_PATH") || { + echo "Unable to normalize app path '$APP_PATH'" >&2 + exit 1 + } + PROFILE="luci-app" + MODE="" +fi + +if [[ "$MODE" == "src" && -z "$SRC_PATH" ]]; then + if [[ -t 0 ]]; then + selection=$(prompt_select_app) || { echo "Aborting." >&2; exit 1; } + SRC_PATH=$(normalize_app_path "$selection") || { + echo "Unable to locate LuCI app directory for '$selection'" >&2 + exit 1 + } + PROFILE="${PROFILE:-luci-app}" + MODE="" + log "Selected LuCI app path: $SRC_PATH" + else + list_luci_apps + exit 1 + fi +elif [[ -n "$SRC_PATH" && ! -d "$SRC_PATH" ]]; then + echo "Specified --src path '$SRC_PATH' not found." + if [[ -t 0 ]]; then + selection=$(prompt_select_app) || { echo "Aborting." >&2; exit 1; } + SRC_PATH=$(normalize_app_path "$selection") || { + echo "Unable to locate LuCI app directory for '$selection'" >&2 + exit 1 + } + PROFILE="${PROFILE:-luci-app}" + MODE="" + log "Selected LuCI app path: $SRC_PATH" + else + list_luci_apps + exit 1 + fi +fi + +if [[ -z "$PROFILE" && "$MODE" == "src" && "$AUTO_PROFILE" -eq 1 && -n "$SRC_PATH" && ( -d "$SRC_PATH/root" || -d "$SRC_PATH/htdocs" ) ]]; then + PROFILE="luci-app" + MODE="" + log "Auto-detected LuCI app at $SRC_PATH (use --no-auto-profile to disable)." +fi + +if [[ -z "$MODE" && -z "$PROFILE" ]]; then + echo "Error: specify one of --ipk/--apk/--src/--git or --profile" >&2 + usage +fi + +if [[ -n "$MODE" && -n "$PROFILE" ]]; then + echo "Error: --profile cannot be combined with other source options" >&2 + exit 1 +fi + +if [[ "$FORCE_ROOT" == "true" && "$MODE" != "src" && "$PROFILE" != "luci-app" ]]; then + echo "Error: --force-root is only valid with --src" >&2 + exit 1 +fi + +if [[ "$FORCE_ROOT" == "true" && "$PROFILE" != "luci-app" ]]; then + log "⚠️ Force root mode enabled: archives will extract relative to /" +fi + +if [[ "$MODE" =~ ^(ipk|apk)$ && ! -f "$PKG_PATH" ]]; then + echo "Error: package file not found: $PKG_PATH" >&2 + exit 1 +fi + +if [[ "$MODE" == "git" && -z "$GIT_URL" ]]; then + echo "Error: --git requires a repository URL" >&2 + exit 1 +fi + +install_ipk() { + local file="$1" + local remote="/tmp/$(basename "$file")" + log "Uploading $file to $remote" + copy_file "$file" "$remote" + log "Installing via opkg" + remote_exec "if command -v opkg >/dev/null 2>&1; then opkg install --force-reinstall $remote; else echo 'opkg not available' >&2; exit 1; fi" + remote_exec "rm -f $remote" +} + +install_apk() { + local file="$1" + local remote="/tmp/$(basename "$file")" + log "Uploading $file to $remote" + copy_file "$file" "$remote" + log "Installing via apk" + remote_exec "if command -v apk >/dev/null 2>&1; then apk add --allow-untrusted $remote; else echo 'apk not available' >&2; exit 1; fi" + remote_exec "rm -f $remote" +} + +upload_source_dir() { + local dir="$1" + local archive + archive=$(mktemp /tmp/secubox-src-XXXX.tar.gz) + log "Packing $dir" + if [[ ${#INCLUDE_PATHS[@]} -gt 0 ]]; then + ( cd "$dir" && tar -czf "$archive" "${INCLUDE_PATHS[@]}" ) + else + tar -C "$dir" -czf "$archive" . + fi + local remote="/tmp/$(basename "$archive")" + log "Uploading archive to $remote" + copy_file "$archive" "$remote" + local extract_target="$TARGET_PATH" + if [[ "$FORCE_ROOT" == "true" ]]; then + extract_target="/" + fi + log "Extracting to $extract_target" + remote_exec "mkdir -p $extract_target && tar -xzf $remote -C $extract_target && rm -f $remote" + if [[ "$CACHE_BUST" -eq 1 ]]; then + remote_exec "rm -rf /tmp/luci-*" + fi + rm -f "$archive" + if [[ "$VERIFY" -eq 1 ]]; then + verify_remote "$dir" + fi +} + +clone_and_upload() { + cleanup_tmp=$(mktemp -d /tmp/secubox-git-XXXX) + log "Cloning $GIT_URL" + if [[ -n "$GIT_BRANCH" ]]; then + git clone --depth 1 --branch "$GIT_BRANCH" "$GIT_URL" "$cleanup_tmp" + else + git clone --depth 1 "$GIT_URL" "$cleanup_tmp" + fi + upload_source_dir "$cleanup_tmp" +} + +if [[ -n "$PROFILE" ]]; then + case "$PROFILE" in + theme|theme-system) + deploy_profile_theme ;; + luci-app) + if [[ -z "$SRC_PATH" ]]; then + if [[ -t 0 ]]; then + selection=$(prompt_select_app) || { echo "Aborting." >&2; exit 1; } + SRC_PATH=$(normalize_app_path "$selection") || { + echo "Unable to locate LuCI app directory for '$selection'" >&2 + exit 1 + } + else + list_luci_apps + exit 1 + fi + fi + deploy_profile_luci_app "$SRC_PATH" ;; + *) + echo "Unknown profile: $PROFILE" >&2 + exit 1 ;; + esac +else + case "$MODE" in + ipk) + install_ipk "$PKG_PATH" ;; + apk) + install_apk "$PKG_PATH" ;; + src) + upload_source_dir "$SRC_PATH" ;; + git) + clone_and_upload ;; + *) + echo "Unsupported mode: $MODE" >&2 + exit 1 ;; + esac +fi + +if [[ -n "$POST_CMD" ]]; then + log "Running post-deploy command: $POST_CMD" + remote_exec "$POST_CMD" +fi + +if [[ "$VERIFY" -eq 1 && $VERIFY_ERRORS -ne 0 ]]; then + log "⚠️ Verification reported differences. Inspect logs above." +fi + +log "Deployment complete ✅"