secubox: add first-run and app wizards
This commit is contained in:
parent
7c5ad8e53d
commit
eab24f9609
@ -17,7 +17,7 @@ These notes summarize the repository structure, conventions, and supporting tool
|
||||
- `luci-app-system-hub`: system health/remote assistance.
|
||||
- `luci-app-network-modes`: prebuilt router/sniffer modes (bridges, AP, relay, etc.).
|
||||
- `luci-app-vhost-manager`: reverse-proxy/vhost configuration (existing baseline for future work).
|
||||
- **Tooling (`secubox-tools/`)** – Bash/POSIX scripts for validation, building, deployment, permission repair, etc. The README documents workflows such as `validate-modules.sh`, `local-build.sh`, `fix-permissions.sh`, and `deploy-*.sh`.
|
||||
- **Tooling (`secubox-tools/`)** – Bash/POSIX scripts for validation, building, deployment, permission repair, etc. The README documents workflows such as `validate-modules.sh`, `local-build.sh`, `fix-permissions.sh`, and `deploy-*.sh`. The newer `secubox-app` helper consumes manifests under `/usr/share/secubox/plugins/` to install and configure “apps”.
|
||||
- **Automation & Docs**
|
||||
- `DOCS/` + `docs/`: mirrored, versioned documentation tree (design system, prompts, module templates, validation, permissions, etc.).
|
||||
- `EXAMPLES/` and `templates/`: snippets and scaffolding.
|
||||
|
||||
@ -23,8 +23,15 @@ Each plugin folder contains a `manifest.json`. Example (Zigbee2MQTT):
|
||||
"volumes": ["/srv/zigbee2mqtt"],
|
||||
"network": { "default_mode": "lan", "dmz_supported": true },
|
||||
"wizard": {
|
||||
"uci": "/etc/config/zigbee2mqtt",
|
||||
"steps": ["serial_port", "mqtt_host", "credentials", "frontend_port"]
|
||||
"uci": { "config": "zigbee2mqtt", "section": "main" },
|
||||
"fields": [
|
||||
{ "id": "serial_port", "label": "Serial Port", "type": "text", "uci_option": "serial_port" },
|
||||
{ "id": "mqtt_host", "label": "MQTT Host", "type": "text", "uci_option": "mqtt_host" },
|
||||
{ "id": "mqtt_username", "label": "MQTT Username", "type": "text", "uci_option": "mqtt_username" },
|
||||
{ "id": "mqtt_password", "label": "MQTT Password", "type": "password", "uci_option": "mqtt_password" },
|
||||
{ "id": "base_topic", "label": "Base Topic", "type": "text", "uci_option": "base_topic" },
|
||||
{ "id": "frontend_port", "label": "Frontend Port", "type": "number", "uci_option": "frontend_port" }
|
||||
]
|
||||
},
|
||||
"profiles": ["home", "lab"],
|
||||
"actions": {
|
||||
@ -51,14 +58,14 @@ Each plugin folder contains a `manifest.json`. Example (Zigbee2MQTT):
|
||||
- `ports`: Document exposed services for the App Store UI.
|
||||
- `volumes`: Persistent directories (e.g., `/srv/zigbee2mqtt`).
|
||||
- `network`: Defaults + whether DMZ mode is supported.
|
||||
- `wizard`: UCI file and logical steps for the future wizard UI.
|
||||
- `wizard`: UCI target plus the declarative field list consumed by the LuCI wizard.
|
||||
- `profiles`: Tags to pre-load when applying OS-like profiles.
|
||||
|
||||
---
|
||||
|
||||
## CLI Usage (`secubox-tools/secubox-app`)
|
||||
## CLI Usage (`secubox-app`)
|
||||
|
||||
Copy or install `secubox-tools/secubox-app` on the router (ensure it’s executable). Commands:
|
||||
`luci-app-secubox` installs the CLI as `/usr/sbin/secubox-app` (also available under `secubox-tools/` for development). Commands:
|
||||
|
||||
```bash
|
||||
# List manifests
|
||||
@ -92,4 +99,3 @@ The CLI relies on `opkg` and `jsonfilter`, so run it on the router (or within th
|
||||
- Profiles can bundle manifests with specific network modes (e.g., DMZ + Zigbee2MQTT + Lyrion).
|
||||
|
||||
For now, Zigbee2MQTT demonstrates the format. Additional manifests should follow the same schema to ensure the CLI and future UIs remain consistent.
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ These notes capture the current repository structure, conventions, and supportin
|
||||
`luci-theme-secubox` carries the design system (dark palette, `sh-*`/`sb-*` classes, Inter + JetBrains Mono). Every LuCI view imports `secubox-theme/secubox-theme.css` plus module-specific CSS as needed.
|
||||
|
||||
- **Tooling (`secubox-tools/`)**
|
||||
Contains validation (`validate-modules.sh`), build automation (`local-build.sh`), permission repair, deployment helpers, and debug loggers. These scripts mirror GitHub Actions workflows and should be reused for any new installer/test tooling.
|
||||
Contains validation (`validate-modules.sh`), build automation (`local-build.sh`), permission repair, deployment helpers, and debug loggers. These scripts mirror GitHub Actions workflows and should be reused for new installer/test tooling. The `secubox-app` CLI now lives here as well, consuming manifests under `/usr/share/secubox/plugins/` to install and configure “apps”.
|
||||
|
||||
- **Automation/scripts (`scripts/`)**
|
||||
Hosts documentation publishing helpers plus existing diagnostics/smoke scripts. New repo-wide diagnostics should live alongside them.
|
||||
|
||||
@ -23,8 +23,15 @@ Each plugin folder contains a `manifest.json`. Example (Zigbee2MQTT):
|
||||
"volumes": ["/srv/zigbee2mqtt"],
|
||||
"network": { "default_mode": "lan", "dmz_supported": true },
|
||||
"wizard": {
|
||||
"uci": "/etc/config/zigbee2mqtt",
|
||||
"steps": ["serial_port", "mqtt_host", "credentials", "frontend_port"]
|
||||
"uci": { "config": "zigbee2mqtt", "section": "main" },
|
||||
"fields": [
|
||||
{ "id": "serial_port", "label": "Serial Port", "type": "text", "uci_option": "serial_port", "placeholder": "/dev/ttyACM0" },
|
||||
{ "id": "mqtt_host", "label": "MQTT Host", "type": "text", "uci_option": "mqtt_host", "placeholder": "mqtt://127.0.0.1:1883" },
|
||||
{ "id": "mqtt_username", "label": "MQTT Username", "type": "text", "uci_option": "mqtt_username" },
|
||||
{ "id": "mqtt_password", "label": "MQTT Password", "type": "password", "uci_option": "mqtt_password" },
|
||||
{ "id": "base_topic", "label": "Base Topic", "type": "text", "uci_option": "base_topic" },
|
||||
{ "id": "frontend_port", "label": "Frontend Port", "type": "number", "uci_option": "frontend_port" }
|
||||
]
|
||||
},
|
||||
"profiles": ["home", "lab"],
|
||||
"actions": {
|
||||
@ -51,14 +58,14 @@ Each plugin folder contains a `manifest.json`. Example (Zigbee2MQTT):
|
||||
- `ports`: Document exposed services for the App Store UI.
|
||||
- `volumes`: Persistent directories (e.g., `/srv/zigbee2mqtt`).
|
||||
- `network`: Defaults + whether DMZ mode is supported.
|
||||
- `wizard`: UCI file and logical steps for the future wizard UI.
|
||||
- `wizard`: Declarative form metadata (`uci.config`, `uci.section`, `fields[*].uci_option`).
|
||||
- `profiles`: Tags to pre-load when applying OS-like profiles.
|
||||
|
||||
---
|
||||
|
||||
## CLI Usage (`secubox-tools/secubox-app`)
|
||||
## CLI Usage (`secubox-app`)
|
||||
|
||||
Copy or install `secubox-tools/secubox-app` on the router (ensure it’s executable). Commands:
|
||||
`luci-app-secubox` installs the CLI as `/usr/sbin/secubox-app` (also available under `secubox-tools/` for development). Commands:
|
||||
|
||||
```bash
|
||||
# List manifests
|
||||
@ -92,4 +99,3 @@ The CLI relies on `opkg` and `jsonfilter`, so run it on the router (or within th
|
||||
- Profiles can bundle manifests with specific network modes (e.g., DMZ + Zigbee2MQTT + Lyrion).
|
||||
|
||||
For now, Zigbee2MQTT demonstrates the format. Additional manifests should follow the same schema to ensure the CLI and future UIs remain consistent.
|
||||
|
||||
|
||||
@ -22,4 +22,12 @@ PKG_FILE_MODES:=/usr/libexec/rpcd/luci.secubox:root:root:755 \
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
define Package/$(PKG_NAME)/install
|
||||
$(call Package/luci/install,$(1))
|
||||
$(INSTALL_DIR) $(1)/usr/share/secubox/plugins/zigbee2mqtt
|
||||
$(INSTALL_DATA) $(CURDIR)/../plugins/zigbee2mqtt/manifest.json $(1)/usr/share/secubox/plugins/zigbee2mqtt/manifest.json
|
||||
$(INSTALL_DIR) $(1)/usr/sbin
|
||||
$(INSTALL_BIN) $(CURDIR)/../secubox-tools/secubox-app $(1)/usr/sbin/secubox-app
|
||||
endef
|
||||
|
||||
# call BuildPackage - OpenWrt buildroot
|
||||
|
||||
@ -52,6 +52,12 @@ Auto-detection and status monitoring for all SecuBox modules:
|
||||
- **CDN Cache** - Local caching proxy
|
||||
- **Virtual Host Manager** - Virtual host configuration
|
||||
|
||||
### Wizard & App Store Integration
|
||||
- First-run assistant to verify password, timezone, storage, and preferred network mode
|
||||
- Manifest-driven app wizards (e.g., Zigbee2MQTT) surfaced directly inside SecuBox
|
||||
- `secubox-app` CLI (installed under `/usr/sbin/`) for scripted installs/updates via manifests
|
||||
- Plugins stored under `/usr/share/secubox/plugins/<app>/manifest.json` for easy expansion
|
||||
|
||||
## LuCI Menu Structure
|
||||
|
||||
The SecuBox hub organizes all modules into a hierarchical menu structure in LuCI:
|
||||
|
||||
@ -145,6 +145,35 @@ var callFixPermissions = rpc.declare({
|
||||
expect: { success: false, message: '', output: '' }
|
||||
});
|
||||
|
||||
var callFirstRunStatus = rpc.declare({
|
||||
object: 'luci.secubox',
|
||||
method: 'first_run_status',
|
||||
expect: { }
|
||||
});
|
||||
|
||||
var callApplyFirstRun = rpc.declare({
|
||||
object: 'luci.secubox',
|
||||
method: 'apply_first_run'
|
||||
});
|
||||
|
||||
var callListApps = rpc.declare({
|
||||
object: 'luci.secubox',
|
||||
method: 'list_apps',
|
||||
expect: { apps: [] }
|
||||
});
|
||||
|
||||
var callGetAppManifest = rpc.declare({
|
||||
object: 'luci.secubox',
|
||||
method: 'get_app_manifest',
|
||||
params: ['app_id']
|
||||
});
|
||||
|
||||
var callApplyAppWizard = rpc.declare({
|
||||
object: 'luci.secubox',
|
||||
method: 'apply_app_wizard',
|
||||
params: ['app_id', 'values']
|
||||
});
|
||||
|
||||
function formatUptime(seconds) {
|
||||
if (!seconds) return '0s';
|
||||
var d = Math.floor(seconds / 86400);
|
||||
@ -188,6 +217,11 @@ return baseclass.extend({
|
||||
dismissAlert: callDismissAlert,
|
||||
clearAlerts: callClearAlerts,
|
||||
fixPermissions: callFixPermissions,
|
||||
getFirstRunStatus: callFirstRunStatus,
|
||||
applyFirstRun: callApplyFirstRun,
|
||||
listApps: callListApps,
|
||||
getAppManifest: callGetAppManifest,
|
||||
applyAppWizard: callApplyAppWizard,
|
||||
// Utilities
|
||||
formatUptime: formatUptime,
|
||||
formatBytes: formatBytes
|
||||
|
||||
@ -577,3 +577,158 @@ pre {
|
||||
box-shadow: 0 14px 28px rgba(239, 68, 68, 0.5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* === Wizard Styles === */
|
||||
.secubox-wizard-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.sb-wizard-card {
|
||||
background: var(--sh-bg-card);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--sh-border);
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 3px var(--sh-shadow);
|
||||
}
|
||||
|
||||
.sb-wizard-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.sb-wizard-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sb-wizard-step {
|
||||
border: 1px solid var(--sh-border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--sh-bg-secondary);
|
||||
}
|
||||
|
||||
.sb-wizard-step.complete {
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
}
|
||||
|
||||
.sb-wizard-step-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sb-wizard-step-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.sb-wizard-step-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sb-wizard-step-desc {
|
||||
font-size: 13px;
|
||||
color: var(--sh-text-secondary);
|
||||
}
|
||||
|
||||
.sb-wizard-step-body {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.sb-wizard-inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sb-wizard-input,
|
||||
.sb-wizard-select {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--sh-border);
|
||||
background: var(--sh-bg-primary);
|
||||
color: var(--sh-text-primary);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.sb-wizard-status {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sb-wizard-status.ok {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.sb-wizard-status.warn {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.sb-app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sb-app-card {
|
||||
border: 1px solid var(--sh-border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--sh-bg-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sb-app-name {
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sb-app-version {
|
||||
font-size: 12px;
|
||||
color: var(--sh-text-secondary);
|
||||
}
|
||||
|
||||
.sb-app-desc {
|
||||
font-size: 13px;
|
||||
color: var(--sh-text-secondary);
|
||||
}
|
||||
|
||||
.sb-app-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sb-app-state {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: var(--sh-warning);
|
||||
}
|
||||
|
||||
.sb-app-state.ok {
|
||||
color: var(--sh-success);
|
||||
}
|
||||
|
||||
.sb-form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sb-app-wizard-form label {
|
||||
font-size: 13px;
|
||||
color: var(--sh-text-secondary);
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
var tabs = [
|
||||
{ id: 'dashboard', icon: '🚀', label: _('Dashboard'), path: ['admin', 'secubox', 'dashboard'] },
|
||||
{ id: 'modules', icon: '🧩', label: _('Modules'), path: ['admin', 'secubox', 'modules'] },
|
||||
{ id: 'wizard', icon: '✨', label: _('Wizard'), path: ['admin', 'secubox', 'wizard'] },
|
||||
{ id: 'monitoring', icon: '📡', label: _('Monitoring'), path: ['admin', 'secubox', 'monitoring'] },
|
||||
{ id: 'alerts', icon: '⚠️', label: _('Alerts'), path: ['admin', 'secubox', 'alerts'] },
|
||||
{ id: 'settings', icon: '⚙️', label: _('Settings'), path: ['admin', 'secubox', 'settings'] },
|
||||
|
||||
@ -0,0 +1,245 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require ui';
|
||||
'require secubox/api as API';
|
||||
'require secubox/nav as SecuNav';
|
||||
|
||||
var TIMEZONES = [
|
||||
{ id: 'UTC', label: 'UTC' },
|
||||
{ id: 'Europe/Paris', label: 'Europe/Paris' },
|
||||
{ id: 'Europe/Berlin', label: 'Europe/Berlin' },
|
||||
{ id: 'America/New_York', label: 'America/New_York' },
|
||||
{ id: 'America/Los_Angeles', label: 'America/Los_Angeles' },
|
||||
{ id: 'Asia/Singapore', label: 'Asia/Singapore' }
|
||||
];
|
||||
|
||||
var NETWORK_MODES = [
|
||||
{ id: 'router', label: _('Router (default)') },
|
||||
{ id: 'dmz', label: _('Router + DMZ') }
|
||||
];
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
API.getFirstRunStatus(),
|
||||
API.listApps()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(payload) {
|
||||
this.firstRun = payload[0] || {};
|
||||
this.appList = (payload[1] && payload[1].apps) || [];
|
||||
var container = E('div', { 'class': 'secubox-wizard-page' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox/common.css') }),
|
||||
SecuNav.renderTabs('wizard'),
|
||||
this.renderHeader(),
|
||||
this.renderFirstRunCard(),
|
||||
this.renderAppsCard()
|
||||
]);
|
||||
return container;
|
||||
},
|
||||
|
||||
renderHeader: function() {
|
||||
return E('div', { 'class': 'sh-page-header sh-page-header-lite' }, [
|
||||
E('div', {}, [
|
||||
E('h2', { 'class': 'sh-page-title' }, [
|
||||
E('span', { 'class': 'sh-page-title-icon' }, '🧭'),
|
||||
_('Setup Wizard')
|
||||
]),
|
||||
E('p', { 'class': 'sh-page-subtitle' }, _('Guide the first-run experience and configure apps with manifest-driven wizards.'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderFirstRunCard: function() {
|
||||
var data = this.firstRun || {};
|
||||
var steps = [
|
||||
{ icon: '🔐', label: _('Secure Admin Account'), description: _('Set a LuCI/root password to protect the router.'), complete: !!data.password_set, content: this.renderPasswordStep(data) },
|
||||
{ icon: '🌍', label: _('Timezone & Locale'), description: _('Align system time with your region.'), complete: false, content: this.renderTimezoneStep(data) },
|
||||
{ icon: '💾', label: _('Storage Path'), description: _('Choose where SecuBox apps store data (USB/NAS recommended).'), complete: !!data.storage_ready, content: this.renderStorageStep(data) },
|
||||
{ icon: '🛡️', label: _('Network Mode'), description: _('Pick a default SecuBox network mode (router or DMZ).'), complete: false, content: this.renderModeStep(data) }
|
||||
];
|
||||
|
||||
return E('div', { 'class': 'sb-wizard-card' }, [
|
||||
E('div', { 'class': 'sb-wizard-title' }, ['🧩 ', _('First-run Checklist')]),
|
||||
E('div', { 'class': 'sb-wizard-steps' }, steps.map(function(step) {
|
||||
return E('div', { 'class': 'sb-wizard-step' + (step.complete ? ' complete' : '') }, [
|
||||
E('div', { 'class': 'sb-wizard-step-header' }, [
|
||||
E('span', { 'class': 'sb-wizard-step-icon' }, step.icon),
|
||||
E('div', {}, [
|
||||
E('div', { 'class': 'sb-wizard-step-label' }, step.label),
|
||||
E('div', { 'class': 'sb-wizard-step-desc' }, step.description)
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'sb-wizard-step-body' }, [step.content])
|
||||
]);
|
||||
}, this))
|
||||
]);
|
||||
},
|
||||
|
||||
renderPasswordStep: function(data) {
|
||||
return E('div', { 'class': 'sb-wizard-inline' }, [
|
||||
E('div', { 'class': 'sb-wizard-status ' + (data.password_set ? 'ok' : 'warn') }, data.password_set ? _('Password set') : _('Password missing')),
|
||||
E('a', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'href': L.url('admin', 'system', 'admin')
|
||||
}, _('Open password page'))
|
||||
]);
|
||||
},
|
||||
|
||||
renderTimezoneStep: function(data) {
|
||||
var selected = data.timezone || 'UTC';
|
||||
var select = E('select', { 'class': 'sb-wizard-select', 'id': 'wizard-timezone' }, TIMEZONES.map(function(zone) {
|
||||
return E('option', { 'value': zone.id, 'selected': zone.id === selected }, zone.label);
|
||||
}));
|
||||
return E('div', { 'class': 'sb-wizard-inline' }, [
|
||||
select,
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': this.applyTimezone.bind(this)
|
||||
}, _('Apply'))
|
||||
]);
|
||||
},
|
||||
|
||||
renderStorageStep: function(data) {
|
||||
var input = E('input', {
|
||||
'class': 'sb-wizard-input',
|
||||
'id': 'wizard-storage',
|
||||
'value': data.storage_path || '/srv/secubox'
|
||||
});
|
||||
return E('div', { 'class': 'sb-wizard-inline' }, [
|
||||
input,
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': this.prepareStorage.bind(this)
|
||||
}, _('Prepare'))
|
||||
]);
|
||||
},
|
||||
|
||||
renderModeStep: function(data) {
|
||||
var select = E('select', { 'class': 'sb-wizard-select', 'id': 'wizard-network-mode' }, NETWORK_MODES.map(function(mode) {
|
||||
return E('option', { 'value': mode.id, 'selected': mode.id === data.network_mode }, mode.label);
|
||||
}));
|
||||
return E('div', { 'class': 'sb-wizard-inline' }, [
|
||||
select,
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': this.applyNetworkMode.bind(this)
|
||||
}, _('Switch'))
|
||||
]);
|
||||
},
|
||||
|
||||
renderAppsCard: function() {
|
||||
var apps = this.appList || [];
|
||||
return E('div', { 'class': 'sb-wizard-card' }, [
|
||||
E('div', { 'class': 'sb-wizard-title' }, ['📦 ', _('App Wizards')]),
|
||||
apps.length ? E('div', { 'class': 'sb-app-grid' }, apps.map(this.renderAppCard, this)) :
|
||||
E('div', { 'class': 'secubox-empty-state' }, [
|
||||
E('div', { 'class': 'secubox-empty-icon' }, '📭'),
|
||||
E('div', { 'class': 'secubox-empty-title' }, _('No manifests detected')),
|
||||
E('div', { 'class': 'secubox-empty-text' }, _('Install manifests under /usr/share/secubox/plugins/.'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderAppCard: function(app) {
|
||||
return E('div', { 'class': 'sb-app-card' }, [
|
||||
E('div', { 'class': 'sb-app-card-info' }, [
|
||||
E('div', { 'class': 'sb-app-name' }, [app.name || app.id, app.version ? E('span', { 'class': 'sb-app-version' }, 'v' + app.version) : '']),
|
||||
E('div', { 'class': 'sb-app-desc' }, app.description || '')
|
||||
]),
|
||||
E('div', { 'class': 'sb-app-actions' }, [
|
||||
E('span', { 'class': 'sb-app-state' + (app.state === 'installed' ? ' ok' : '') }, app.state || 'n/a'),
|
||||
(app.has_wizard ? E('button', { 'class': 'cbi-button cbi-button-action', 'click': this.openAppWizard.bind(this, app) }, _('Configure')) : '')
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
applyTimezone: function(ev) {
|
||||
var tz = document.getElementById('wizard-timezone').value;
|
||||
API.applyFirstRun({ timezone: tz }).then(this.reloadPage).catch(this.showError);
|
||||
},
|
||||
|
||||
prepareStorage: function(ev) {
|
||||
var path = document.getElementById('wizard-storage').value.trim();
|
||||
if (!path) {
|
||||
ui.addNotification(null, E('p', {}, _('Storage path is required')), 'error');
|
||||
return;
|
||||
}
|
||||
API.applyFirstRun({ storage_path: path }).then(this.reloadPage).catch(this.showError);
|
||||
},
|
||||
|
||||
applyNetworkMode: function(ev) {
|
||||
var mode = document.getElementById('wizard-network-mode').value;
|
||||
API.applyFirstRun({ network_mode: mode }).then(this.reloadPage).catch(this.showError);
|
||||
},
|
||||
|
||||
reloadPage: function() {
|
||||
ui.hideModal();
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
showError: function(err) {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', {}, err && err.message ? err.message : err), 'error');
|
||||
},
|
||||
|
||||
openAppWizard: function(app, ev) {
|
||||
var self = this;
|
||||
ui.showModal(_('Loading %s wizard…').format(app.name || app.id), [E('div', { 'class': 'spinning' })]);
|
||||
API.getAppManifest(app.id).then(function(manifest) {
|
||||
ui.hideModal();
|
||||
manifest = manifest || {};
|
||||
var wizard = manifest.wizard || {};
|
||||
var fields = wizard.fields || [];
|
||||
if (!fields.length) {
|
||||
ui.addNotification(null, E('p', {}, _('No wizard metadata for this app.')), 'warn');
|
||||
return;
|
||||
}
|
||||
var form = E('div', { 'class': 'sb-app-wizard-form' }, fields.map(function(field) {
|
||||
return E('div', { 'class': 'sb-form-group' }, [
|
||||
E('label', {}, field.label || field.id),
|
||||
E('input', {
|
||||
'class': 'sb-wizard-input',
|
||||
'name': field.id,
|
||||
'type': field.type || 'text',
|
||||
'placeholder': field.placeholder || ''
|
||||
})
|
||||
]);
|
||||
}));
|
||||
ui.showModal(_('Configure %s').format(app.name || app.id), [
|
||||
form,
|
||||
E('div', { 'class': 'right', 'style': 'margin-top:16px;' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-cancel',
|
||||
'click': ui.hideModal
|
||||
}, _('Cancel')),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': function() {
|
||||
self.submitAppWizard(app.id, form, fields);
|
||||
}
|
||||
}, _('Apply'))
|
||||
])
|
||||
]);
|
||||
}).catch(this.showError);
|
||||
},
|
||||
|
||||
submitAppWizard: function(appId, form, fields) {
|
||||
var values = {};
|
||||
fields.forEach(function(field) {
|
||||
var input = form.querySelector('[name="' + field.id + '"]');
|
||||
if (input && input.value !== '')
|
||||
values[field.id] = input.value;
|
||||
});
|
||||
ui.showModal(_('Saving…'), [E('div', { 'class': 'spinning' })]);
|
||||
API.applyAppWizard(appId, values).then(function(result) {
|
||||
ui.hideModal();
|
||||
if (result && result.success) {
|
||||
ui.addNotification(null, E('p', {}, _('Wizard applied.')), 'info');
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, _('Failed to apply wizard.')), 'error');
|
||||
}
|
||||
}).catch(this.showError);
|
||||
}
|
||||
});
|
||||
@ -54,6 +54,9 @@ get_pkg_version() {
|
||||
|
||||
PKG_VERSION="$(get_pkg_version)"
|
||||
|
||||
PLUGIN_DIR="/usr/share/secubox/plugins"
|
||||
DEFAULT_STORAGE_PATH="/srv/secubox"
|
||||
|
||||
# Module registry - auto-detected from /usr/libexec/rpcd/
|
||||
detect_modules() {
|
||||
local modules=""
|
||||
@ -72,6 +75,58 @@ detect_modules() {
|
||||
|
||||
MODULES=$(detect_modules)
|
||||
|
||||
is_root_password_set() {
|
||||
local hash
|
||||
hash=$(grep '^root:' /etc/shadow 2>/dev/null | cut -d: -f2)
|
||||
[ -n "$hash" ] && [ "$hash" != "!" ] && [ "$hash" != "*" ]
|
||||
}
|
||||
|
||||
get_storage_path() {
|
||||
local path
|
||||
path=$(uci -q get secubox.main.storage_path)
|
||||
if [ -z "$path" ]; then
|
||||
path="$DEFAULT_STORAGE_PATH"
|
||||
fi
|
||||
echo "$path"
|
||||
}
|
||||
|
||||
package_installed() {
|
||||
local pkg="$1"
|
||||
if command -v opkg >/dev/null 2>&1; then
|
||||
opkg status "$pkg" >/dev/null 2>&1 && return 0
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk info -e "$pkg" >/dev/null 2>&1 && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
packages_state() {
|
||||
local manifest="$1"
|
||||
local total=0 installed=0 missing=0 pkg
|
||||
for pkg in $(jsonfilter -s "$manifest" -e '@.packages[*]' 2>/dev/null); do
|
||||
total=$((total + 1))
|
||||
if package_installed "$pkg"; then
|
||||
installed=$((installed + 1))
|
||||
else
|
||||
missing=$((missing + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$total" -eq 0 ]; then
|
||||
echo "n/a"
|
||||
elif [ "$missing" -eq 0 ]; then
|
||||
echo "installed"
|
||||
elif [ "$installed" -eq 0 ]; then
|
||||
echo "missing"
|
||||
else
|
||||
echo "partial"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_directory() {
|
||||
local dir="$1"
|
||||
[ -d "$dir" ] || mkdir -p "$dir"
|
||||
}
|
||||
|
||||
# Check if a module is installed (supports both opkg and apk)
|
||||
check_module_installed() {
|
||||
local module="$1"
|
||||
@ -1090,6 +1145,185 @@ fix_permissions() {
|
||||
}
|
||||
|
||||
# Main dispatcher
|
||||
first_run_status() {
|
||||
local timezone storage network_json mode_name mode_id storage_path
|
||||
storage_path=$(get_storage_path)
|
||||
local storage_ready=0
|
||||
[ -d "$storage_path" ] && storage_ready=1
|
||||
local password_state=0
|
||||
if is_root_password_set; then
|
||||
password_state=1
|
||||
fi
|
||||
timezone=$(uci -q get system.@system[0].timezone || echo "UTC")
|
||||
mode_id="unknown"
|
||||
mode_name="N/A"
|
||||
if command -v ubus >/dev/null 2>&1; then
|
||||
network_json=$(ubus call luci.network-modes status 2>/dev/null || printf '')
|
||||
if [ -n "$network_json" ]; then
|
||||
mode_id=$(jsonfilter -s "$network_json" -e '@.current_mode' 2>/dev/null || echo "$mode_id")
|
||||
mode_name=$(jsonfilter -s "$network_json" -e '@.mode_name' 2>/dev/null || echo "$mode_name")
|
||||
fi
|
||||
fi
|
||||
json_init
|
||||
json_add_boolean "password_set" "$password_state"
|
||||
json_add_string "timezone" "$timezone"
|
||||
json_add_string "storage_path" "$storage_path"
|
||||
json_add_boolean "storage_ready" "$storage_ready"
|
||||
json_add_string "network_mode" "$mode_id"
|
||||
json_add_string "network_mode_name" "$mode_name"
|
||||
json_add_array "recommended_modes"
|
||||
json_add_string "" "router"
|
||||
json_add_string "" "dmz"
|
||||
json_close_array
|
||||
json_dump
|
||||
}
|
||||
|
||||
apply_first_run() {
|
||||
local input
|
||||
read input
|
||||
json_load "$input"
|
||||
json_get_var timezone timezone
|
||||
json_get_var storage_path storage_path
|
||||
json_get_var network_mode network_mode
|
||||
json_init
|
||||
json_add_array "messages"
|
||||
if [ -n "$timezone" ]; then
|
||||
uci set system.@system[0].timezone="$timezone"
|
||||
uci set system.@system[0].zonename="$timezone"
|
||||
uci commit system
|
||||
if [ -f "/usr/share/zoneinfo/$timezone" ]; then
|
||||
ln -sf "/usr/share/zoneinfo/$timezone" /etc/localtime
|
||||
fi
|
||||
json_add_string "" "Timezone updated"
|
||||
fi
|
||||
if [ -n "$storage_path" ]; then
|
||||
mkdir -p "$storage_path"
|
||||
chmod 755 "$storage_path"
|
||||
uci set secubox.main.storage_path="$storage_path"
|
||||
uci commit secubox
|
||||
json_add_string "" "Storage prepared at $storage_path"
|
||||
fi
|
||||
if [ -n "$network_mode" ]; then
|
||||
if command -v ubus >/dev/null 2>&1; then
|
||||
if ubus call luci.network-modes set_mode "{\"mode\":\"$network_mode\"}" >/dev/null 2>&1; then
|
||||
if ubus call luci.network-modes apply_mode >/dev/null 2>&1; then
|
||||
json_add_string "" "Network mode $network_mode applied"
|
||||
else
|
||||
json_add_string "" "Failed to apply network mode"
|
||||
fi
|
||||
else
|
||||
json_add_string "" "Mode $network_mode unsupported"
|
||||
fi
|
||||
else
|
||||
json_add_string "" "ubus unavailable; cannot change network mode"
|
||||
fi
|
||||
fi
|
||||
json_close_array
|
||||
json_add_boolean "success" 1
|
||||
json_dump
|
||||
}
|
||||
|
||||
list_apps() {
|
||||
ensure_directory "$PLUGIN_DIR"
|
||||
json_init
|
||||
json_add_array "apps"
|
||||
local manifest_file
|
||||
for manifest_file in "$PLUGIN_DIR"/*/manifest.json; do
|
||||
[ -f "$manifest_file" ] || continue
|
||||
local manifest
|
||||
manifest=$(cat "$manifest_file")
|
||||
local id name type version description state wizard_field
|
||||
id=$(jsonfilter -s "$manifest" -e '@.id' 2>/dev/null)
|
||||
name=$(jsonfilter -s "$manifest" -e '@.name' 2>/dev/null)
|
||||
type=$(jsonfilter -s "$manifest" -e '@.type' 2>/dev/null)
|
||||
version=$(jsonfilter -s "$manifest" -e '@.version' 2>/dev/null)
|
||||
description=$(jsonfilter -s "$manifest" -e '@.description' 2>/dev/null)
|
||||
[ -n "$id" ] || continue
|
||||
state=$(packages_state "$manifest")
|
||||
wizard_field=$(jsonfilter -s "$manifest" -e '@.wizard.fields[0].id' 2>/dev/null)
|
||||
json_add_object
|
||||
json_add_string "id" "$id"
|
||||
json_add_string "name" "$name"
|
||||
json_add_string "type" "$type"
|
||||
json_add_string "version" "$version"
|
||||
json_add_string "description" "$description"
|
||||
json_add_string "state" "$state"
|
||||
json_add_boolean "has_wizard" "$([ -n "$wizard_field" ] && echo 1 || echo 0)"
|
||||
json_close_object
|
||||
done
|
||||
json_close_array
|
||||
json_dump
|
||||
}
|
||||
|
||||
get_app_manifest() {
|
||||
local input app_id file manifest state
|
||||
read input
|
||||
app_id=$(jsonfilter -s "$input" -e '@.app_id' 2>/dev/null)
|
||||
if [ -z "$app_id" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "app_id required"
|
||||
json_dump
|
||||
return
|
||||
fi
|
||||
file="$PLUGIN_DIR/$app_id/manifest.json"
|
||||
if [ ! -f "$file" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Manifest not found"
|
||||
json_dump
|
||||
return
|
||||
fi
|
||||
manifest=$(cat "$file")
|
||||
state=$(packages_state "$manifest")
|
||||
json_load "$manifest"
|
||||
json_add_string "state" "$state"
|
||||
json_dump
|
||||
}
|
||||
|
||||
apply_app_wizard() {
|
||||
local input app_id file manifest config section fields field option value
|
||||
read input
|
||||
app_id=$(jsonfilter -s "$input" -e '@.app_id' 2>/dev/null)
|
||||
if [ -z "$app_id" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "app_id required"
|
||||
json_dump
|
||||
return
|
||||
fi
|
||||
file="$PLUGIN_DIR/$app_id/manifest.json"
|
||||
if [ ! -f "$file" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Manifest not found"
|
||||
json_dump
|
||||
return
|
||||
fi
|
||||
manifest=$(cat "$file")
|
||||
config=$(jsonfilter -s "$manifest" -e '@.wizard.uci.config' 2>/dev/null)
|
||||
section=$(jsonfilter -s "$manifest" -e '@.wizard.uci.section' 2>/dev/null)
|
||||
[ -n "$section" ] || section="main"
|
||||
if [ -z "$config" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Wizard missing UCI config"
|
||||
json_dump
|
||||
return
|
||||
fi
|
||||
fields=$(jsonfilter -s "$manifest" -e '@.wizard.fields[*].id' 2>/dev/null)
|
||||
for field in $fields; do
|
||||
option=$(jsonfilter -s "$manifest" -e "@.wizard.fields[@.id='$field'].uci_option" 2>/dev/null)
|
||||
[ -n "$option" ] || continue
|
||||
value=$(jsonfilter -s "$input" -e "@.values.$field" 2>/dev/null)
|
||||
[ -n "$value" ] || continue
|
||||
uci set ${config}.${section}.${option}="$value"
|
||||
done
|
||||
uci commit "$config"
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_dump
|
||||
}
|
||||
case "$1" in
|
||||
list)
|
||||
json_init
|
||||
@ -1146,6 +1380,18 @@ case "$1" in
|
||||
json_close_object
|
||||
json_add_object "fix_permissions"
|
||||
json_close_object
|
||||
json_add_object "first_run_status"
|
||||
json_close_object
|
||||
json_add_object "apply_first_run"
|
||||
json_close_object
|
||||
json_add_object "list_apps"
|
||||
json_close_object
|
||||
json_add_object "get_app_manifest"
|
||||
json_add_string "app_id" "string"
|
||||
json_close_object
|
||||
json_add_object "apply_app_wizard"
|
||||
json_add_string "app_id" "string"
|
||||
json_close_object
|
||||
json_dump
|
||||
;;
|
||||
call)
|
||||
@ -1243,9 +1489,24 @@ case "$1" in
|
||||
clear_alerts)
|
||||
clear_alerts
|
||||
;;
|
||||
fix_permissions)
|
||||
fix_permissions
|
||||
;;
|
||||
fix_permissions)
|
||||
fix_permissions
|
||||
;;
|
||||
first_run_status)
|
||||
first_run_status
|
||||
;;
|
||||
apply_first_run)
|
||||
apply_first_run
|
||||
;;
|
||||
list_apps)
|
||||
list_apps
|
||||
;;
|
||||
get_app_manifest)
|
||||
get_app_manifest
|
||||
;;
|
||||
apply_app_wizard)
|
||||
apply_app_wizard
|
||||
;;
|
||||
*)
|
||||
echo '{"error":"Unknown method"}'
|
||||
;;
|
||||
|
||||
@ -14,6 +14,14 @@
|
||||
"path": "secubox/dashboard"
|
||||
}
|
||||
},
|
||||
"admin/secubox/wizard": {
|
||||
"title": "First-Run Wizard",
|
||||
"order": 15,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "secubox/wizard"
|
||||
}
|
||||
},
|
||||
"admin/secubox/modules": {
|
||||
"title": "Modules",
|
||||
"order": 20,
|
||||
@ -242,4 +250,4 @@
|
||||
"path": "network-modes/settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,21 +1,24 @@
|
||||
{
|
||||
"luci-app-secubox": {
|
||||
"description": "SecuBox Dashboard",
|
||||
"read": {
|
||||
"ubus": {
|
||||
"luci.secubox": [
|
||||
"status",
|
||||
"modules",
|
||||
"modules_by_category",
|
||||
"module_info",
|
||||
"check_module_enabled",
|
||||
"health",
|
||||
"diagnostics",
|
||||
"get_system_health",
|
||||
"get_alerts",
|
||||
"get_dashboard_data",
|
||||
"get_theme"
|
||||
],
|
||||
"read": {
|
||||
"ubus": {
|
||||
"luci.secubox": [
|
||||
"status",
|
||||
"modules",
|
||||
"modules_by_category",
|
||||
"module_info",
|
||||
"check_module_enabled",
|
||||
"health",
|
||||
"diagnostics",
|
||||
"get_system_health",
|
||||
"get_alerts",
|
||||
"get_dashboard_data",
|
||||
"get_theme",
|
||||
"first_run_status",
|
||||
"list_apps",
|
||||
"get_app_manifest"
|
||||
],
|
||||
"uci": [
|
||||
"get",
|
||||
"state"
|
||||
@ -25,20 +28,22 @@
|
||||
"secubox"
|
||||
]
|
||||
},
|
||||
"write": {
|
||||
"ubus": {
|
||||
"luci.secubox": [
|
||||
"start_module",
|
||||
"stop_module",
|
||||
"restart_module",
|
||||
"enable_module",
|
||||
"disable_module",
|
||||
"quick_action",
|
||||
"set_theme",
|
||||
"dismiss_alert",
|
||||
"clear_alerts",
|
||||
"fix_permissions"
|
||||
],
|
||||
"write": {
|
||||
"ubus": {
|
||||
"luci.secubox": [
|
||||
"start_module",
|
||||
"stop_module",
|
||||
"restart_module",
|
||||
"enable_module",
|
||||
"disable_module",
|
||||
"quick_action",
|
||||
"set_theme",
|
||||
"dismiss_alert",
|
||||
"clear_alerts",
|
||||
"fix_permissions",
|
||||
"apply_first_run",
|
||||
"apply_app_wizard"
|
||||
],
|
||||
"uci": [
|
||||
"set",
|
||||
"delete",
|
||||
|
||||
@ -18,12 +18,17 @@
|
||||
"dmz_supported": true
|
||||
],
|
||||
"wizard": {
|
||||
"uci": "/etc/config/zigbee2mqtt",
|
||||
"steps": [
|
||||
"serial_port",
|
||||
"mqtt_host",
|
||||
"credentials",
|
||||
"frontend_port"
|
||||
"uci": {
|
||||
"config": "zigbee2mqtt",
|
||||
"section": "main"
|
||||
},
|
||||
"fields": [
|
||||
{ "id": "serial_port", "label": "Serial Port", "type": "text", "uci_option": "serial_port", "placeholder": "/dev/ttyACM0" },
|
||||
{ "id": "mqtt_host", "label": "MQTT Host", "type": "text", "uci_option": "mqtt_host", "placeholder": "mqtt://127.0.0.1:1883" },
|
||||
{ "id": "mqtt_username", "label": "MQTT Username", "type": "text", "uci_option": "mqtt_username" },
|
||||
{ "id": "mqtt_password", "label": "MQTT Password", "type": "password", "uci_option": "mqtt_password" },
|
||||
{ "id": "base_topic", "label": "Base Topic", "type": "text", "uci_option": "base_topic", "placeholder": "zigbee2mqtt" },
|
||||
{ "id": "frontend_port", "label": "Frontend Port", "type": "number", "uci_option": "frontend_port", "placeholder": "8080" }
|
||||
]
|
||||
},
|
||||
"profiles": ["home", "lab"],
|
||||
|
||||
@ -4,7 +4,15 @@
|
||||
|
||||
set -eu
|
||||
|
||||
PLUGINS_DIR="${SECUBOX_PLUGINS_DIR:-$(cd "$(dirname "$0")/../plugins" && pwd)}"
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
DEFAULT_PLUGINS_DIR="/usr/share/secubox/plugins"
|
||||
if [ -n "${SECUBOX_PLUGINS_DIR:-}" ]; then
|
||||
PLUGINS_DIR="$SECUBOX_PLUGINS_DIR"
|
||||
elif [ -d "$SCRIPT_DIR/../plugins" ]; then
|
||||
PLUGINS_DIR=$(cd "$SCRIPT_DIR/../plugins" && pwd)
|
||||
else
|
||||
PLUGINS_DIR="$DEFAULT_PLUGINS_DIR"
|
||||
fi
|
||||
OPKG_UPDATED=0
|
||||
|
||||
info() { printf '[INFO] %s\n' "$*"; }
|
||||
@ -25,7 +33,7 @@ Commands:
|
||||
update <app-id> Run plugin update action or opkg upgrade
|
||||
|
||||
Environment:
|
||||
SECUBOX_PLUGINS_DIR Override plugin manifest directory (default: ../plugins)
|
||||
SECUBOX_PLUGINS_DIR Override manifest directory (default: /usr/share/secubox/plugins)
|
||||
USAGE
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user