471 lines
17 KiB
JavaScript
471 lines
17 KiB
JavaScript
'use strict';
|
|
'require view';
|
|
'require ui';
|
|
'require dom';
|
|
'require secubox/api as API';
|
|
'require secubox-theme/theme as Theme';
|
|
'require secubox/nav as SecuNav';
|
|
|
|
// Load theme resources
|
|
document.head.appendChild(E('link', {
|
|
'rel': 'stylesheet',
|
|
'type': 'text/css',
|
|
'href': L.resource('secubox-theme/secubox-theme.css')
|
|
}));
|
|
|
|
var secuLang = (typeof L !== 'undefined' && L.env && L.env.lang) ||
|
|
(document.documentElement && document.documentElement.getAttribute('lang')) ||
|
|
(navigator.language ? navigator.language.split('-')[0] : 'en');
|
|
Theme.init({ language: secuLang });
|
|
|
|
var RUNTIME_FILTERS = [
|
|
{ id: 'all', label: _('All runtimes') },
|
|
{ id: 'docker', label: _('Docker') },
|
|
{ id: 'lxc', label: _('LXC') },
|
|
{ id: 'native', label: _('Native') }
|
|
];
|
|
|
|
var STATE_FILTERS = [
|
|
{ id: 'all', label: _('All states') },
|
|
{ id: 'installed', label: _('Installed') },
|
|
{ id: 'available', label: _('Available') }
|
|
];
|
|
|
|
var RUNTIME_ICONS = {
|
|
docker: '🐳',
|
|
lxc: '📦',
|
|
native: '⚙️',
|
|
hybrid: '🧬'
|
|
};
|
|
|
|
return view.extend({
|
|
load: function() {
|
|
return Promise.all([
|
|
API.listApps()
|
|
]);
|
|
},
|
|
|
|
render: function(payload) {
|
|
this.apps = (payload[0] && payload[0].apps) || [];
|
|
this.searchQuery = '';
|
|
this.runtimeFilter = 'all';
|
|
this.stateFilter = 'all';
|
|
this.filterButtons = { runtime: {}, state: {} };
|
|
|
|
this.root = E('div', { 'class': 'secubox-appstore-page' }, [
|
|
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/core/variables.css') }),
|
|
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox/common.css') }),
|
|
SecuNav.renderTabs('appstore'),
|
|
this.renderHeader(),
|
|
this.renderStats(),
|
|
this.renderFilterBar(),
|
|
this.renderAppGrid()
|
|
]);
|
|
|
|
this.updateStats();
|
|
this.updateAppGrid();
|
|
return this.root;
|
|
},
|
|
|
|
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' }, '🛒'),
|
|
_('SecuBox App Store')
|
|
]),
|
|
E('p', { 'class': 'sh-page-subtitle' },
|
|
_('Browse manifest-driven apps, launch guided wizards, and copy CLI commands from the SecuBox App Store.'))
|
|
])
|
|
]);
|
|
},
|
|
|
|
renderStats: function() {
|
|
this.statsNodes = {
|
|
total: E('div', { 'class': 'sb-stat-value' }, '0'),
|
|
installed: E('div', { 'class': 'sb-stat-value' }, '0'),
|
|
docker: E('div', { 'class': 'sb-stat-value' }, '0'),
|
|
lxc: E('div', { 'class': 'sb-stat-value' }, '0')
|
|
};
|
|
|
|
return E('div', { 'class': 'sb-stats-row' }, [
|
|
this.renderStatCard('📦', _('Total apps'), this.statsNodes.total, _('Manifest entries detected')),
|
|
this.renderStatCard('✅', _('Installed'), this.statsNodes.installed, _('Apps currently deployed')),
|
|
this.renderStatCard('🐳', _('Docker'), this.statsNodes.docker, _('Containerized services')),
|
|
this.renderStatCard('📦', _('LXC'), this.statsNodes.lxc, _('Lightweight containers'))
|
|
]);
|
|
},
|
|
|
|
renderStatCard: function(icon, title, valueEl, subtitle) {
|
|
return E('div', { 'class': 'sb-stat-card' }, [
|
|
E('div', { 'class': 'sb-stat-icon' }, icon),
|
|
E('div', { 'class': 'sb-stat-label' }, title),
|
|
valueEl,
|
|
E('div', { 'class': 'sb-stat-sub' }, subtitle)
|
|
]);
|
|
},
|
|
|
|
renderFilterBar: function() {
|
|
var self = this;
|
|
this.searchInput = E('input', {
|
|
'class': 'sb-wizard-input',
|
|
'type': 'search',
|
|
'placeholder': _('Search apps…')
|
|
});
|
|
this.searchInput.addEventListener('input', function(ev) {
|
|
self.searchQuery = (ev.target.value || '').trim().toLowerCase();
|
|
self.updateAppGrid();
|
|
});
|
|
|
|
return E('div', { 'class': 'secubox-appstore-filters' }, [
|
|
E('div', { 'class': 'sb-filter-group' }, [
|
|
E('div', { 'class': 'sb-filter-label' }, _('Type')),
|
|
E('div', { 'class': 'sb-filter-pills' }, RUNTIME_FILTERS.map(function(filter) {
|
|
var pill = E('button', {
|
|
'class': 'sb-filter-pill' + (filter.id === self.runtimeFilter ? ' active' : ''),
|
|
'click': self.handleFilterClick.bind(self, 'runtime', filter.id)
|
|
}, filter.label);
|
|
self.filterButtons.runtime[filter.id] = pill;
|
|
return pill;
|
|
}))
|
|
]),
|
|
E('div', { 'class': 'sb-filter-group' }, [
|
|
E('div', { 'class': 'sb-filter-label' }, _('State')),
|
|
E('div', { 'class': 'sb-filter-pills' }, STATE_FILTERS.map(function(filter) {
|
|
var pill = E('button', {
|
|
'class': 'sb-filter-pill' + (filter.id === self.stateFilter ? ' active' : ''),
|
|
'click': self.handleFilterClick.bind(self, 'state', filter.id)
|
|
}, filter.label);
|
|
self.filterButtons.state[filter.id] = pill;
|
|
return pill;
|
|
}))
|
|
]),
|
|
E('div', { 'class': 'sb-filter-search' }, [
|
|
E('span', { 'class': 'sb-filter-search-icon' }, '🔍'),
|
|
this.searchInput
|
|
])
|
|
]);
|
|
},
|
|
|
|
handleFilterClick: function(group, value, ev) {
|
|
ev.preventDefault();
|
|
if (group === 'runtime')
|
|
this.runtimeFilter = value;
|
|
else
|
|
this.stateFilter = value;
|
|
|
|
this.updateFilterButtons(group);
|
|
this.updateAppGrid();
|
|
},
|
|
|
|
updateFilterButtons: function(group) {
|
|
var buttons = this.filterButtons[group] || {};
|
|
Object.keys(buttons).forEach(function(key) {
|
|
var el = buttons[key];
|
|
if (!el)
|
|
return;
|
|
if ((group === 'runtime' && key === this.runtimeFilter) ||
|
|
(group === 'state' && key === this.stateFilter))
|
|
el.classList.add('active');
|
|
else
|
|
el.classList.remove('active');
|
|
}, this);
|
|
},
|
|
|
|
renderAppGrid: function() {
|
|
this.appGrid = E('div', { 'class': 'sb-app-grid secubox-appstore-grid' });
|
|
return this.appGrid;
|
|
},
|
|
|
|
updateStats: function() {
|
|
var total = this.apps.length;
|
|
var installed = this.apps.filter(function(app) { return app.state === 'installed'; }).length;
|
|
var docker = this.apps.filter(function(app) { return (app.runtime || app.type || '') === 'docker'; }).length;
|
|
var lxc = this.apps.filter(function(app) { return (app.runtime || app.type || '') === 'lxc'; }).length;
|
|
|
|
if (this.statsNodes) {
|
|
this.statsNodes.total.textContent = total.toString();
|
|
this.statsNodes.installed.textContent = installed.toString();
|
|
this.statsNodes.docker.textContent = docker.toString();
|
|
this.statsNodes.lxc.textContent = lxc.toString();
|
|
}
|
|
},
|
|
|
|
getFilteredApps: function() {
|
|
var q = this.searchQuery;
|
|
var runtimeFilter = this.runtimeFilter;
|
|
var state = this.stateFilter;
|
|
|
|
return this.apps.filter(function(app) {
|
|
var runtime = (app.runtime || app.type || '').toLowerCase();
|
|
var desc = ((app.description || '') + ' ' + (app.name || '') + ' ' + (app.id || '')).toLowerCase();
|
|
var matchesRuntime = runtimeFilter === 'all' || runtime === runtimeFilter;
|
|
var matchesState = state === 'all' ||
|
|
(state === 'installed' && app.state === 'installed') ||
|
|
(state === 'available' && app.state !== 'installed');
|
|
var matchesSearch = !q || desc.indexOf(q) !== -1;
|
|
return matchesRuntime && matchesState && matchesSearch;
|
|
});
|
|
},
|
|
|
|
updateAppGrid: function() {
|
|
if (!this.appGrid)
|
|
return;
|
|
var apps = this.getFilteredApps();
|
|
if (!apps.length) {
|
|
dom.content(this.appGrid, [
|
|
E('div', { 'class': 'secubox-empty-state' }, [
|
|
E('div', { 'class': 'secubox-empty-icon' }, '🕵️'),
|
|
E('div', { 'class': 'secubox-empty-title' }, _('No apps found')),
|
|
E('div', { 'class': 'secubox-empty-text' }, _('Adjust filters or add manifests under /usr/share/secubox/plugins/.'))
|
|
])
|
|
]);
|
|
return;
|
|
}
|
|
dom.content(this.appGrid, apps.map(this.renderAppCard, this));
|
|
},
|
|
|
|
renderAppCard: function(app) {
|
|
var runtime = (app.runtime || app.type || 'other').toLowerCase();
|
|
var icon = RUNTIME_ICONS[runtime] || '🧩';
|
|
var stateClass = app.state === 'installed' ? ' ok' : '';
|
|
var badges = [
|
|
E('span', { 'class': 'sb-app-tag' }, icon + ' ' + (app.runtime || app.type || _('Unknown')))
|
|
];
|
|
if (app.category)
|
|
badges.push(E('span', { 'class': 'sb-app-tag' }, _('Category: %s').format(app.category)));
|
|
if (app.maturity)
|
|
badges.push(E('span', { 'class': 'sb-app-tag' }, _('Maturity: %s').format(app.maturity)));
|
|
if (app.version)
|
|
badges.push(E('span', { 'class': 'sb-app-tag sb-app-version' }, 'v' + app.version));
|
|
|
|
return E('div', { 'class': 'sb-app-card' }, [
|
|
E('div', { 'class': 'sb-app-card-info' }, [
|
|
E('div', { 'class': 'sb-app-name' }, [
|
|
app.name || app.id,
|
|
E('span', { 'class': 'sb-app-state' + stateClass }, app.state || _('unknown'))
|
|
]),
|
|
E('div', { 'class': 'sb-app-desc' }, app.description || _('No description provided')),
|
|
E('div', { 'class': 'sb-app-tags' }, badges)
|
|
]),
|
|
E('div', { 'class': 'sb-app-actions' }, [
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-action',
|
|
'click': this.showAppDetails.bind(this, app)
|
|
}, _('Details')),
|
|
(app.has_wizard ? E('button', {
|
|
'class': 'cbi-button',
|
|
'click': this.openAppWizard.bind(this, app)
|
|
}, _('Configure')) : null)
|
|
])
|
|
]);
|
|
},
|
|
|
|
showAppDetails: function(app, ev) {
|
|
var self = this;
|
|
ui.showModal(_('Loading %s…').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 packages = manifest.packages || [];
|
|
var ports = manifest.ports || [];
|
|
var volumes = manifest.volumes || [];
|
|
var requirements = manifest.requirements || {};
|
|
var hardware = manifest.hardware || {};
|
|
var network = manifest.network || {};
|
|
var privileges = manifest.privileges || {};
|
|
var profiles = (manifest.profiles && manifest.profiles.recommended) || manifest.profiles || [];
|
|
if (!Array.isArray(profiles))
|
|
profiles = [];
|
|
|
|
var makeRow = function(label, value) {
|
|
return E('div', { 'class': 'sb-app-detail-row' }, [
|
|
E('strong', {}, label),
|
|
E('span', {}, value)
|
|
]);
|
|
};
|
|
|
|
var detailRows = [
|
|
makeRow(_('Runtime:'), manifest.runtime || app.runtime || manifest.type || app.type || _('Unknown')),
|
|
makeRow(_('Category:'), manifest.category || _('Unknown')),
|
|
makeRow(_('Maturity:'), manifest.maturity || _('Unspecified')),
|
|
makeRow(_('Version:'), manifest.version || app.version || '—'),
|
|
makeRow(_('State:'), app.state || _('unknown'))
|
|
];
|
|
|
|
var requirementRows = [];
|
|
if (requirements.arch && requirements.arch.length)
|
|
requirementRows.push(makeRow(_('Architectures:'), requirements.arch.join(', ')));
|
|
if (requirements.min_ram_mb)
|
|
requirementRows.push(makeRow(_('Min RAM:'), _('%s MB').format(requirements.min_ram_mb)));
|
|
if (requirements.min_storage_mb)
|
|
requirementRows.push(makeRow(_('Min storage:'), _('%s MB').format(requirements.min_storage_mb)));
|
|
|
|
var hardwareRows = [];
|
|
if (typeof hardware.usb === 'boolean')
|
|
hardwareRows.push(makeRow(_('USB access:'), hardware.usb ? _('Required') : _('Not needed')));
|
|
if (typeof hardware.serial === 'boolean')
|
|
hardwareRows.push(makeRow(_('Serial access:'), hardware.serial ? _('Required') : _('Not needed')));
|
|
|
|
var privilegeRows = [];
|
|
if (typeof privileges.needs_usb === 'boolean')
|
|
privilegeRows.push(makeRow(_('USB privileges:'), privileges.needs_usb ? _('Required') : _('Not needed')));
|
|
if (typeof privileges.needs_serial === 'boolean')
|
|
privilegeRows.push(makeRow(_('Serial privileges:'), privileges.needs_serial ? _('Required') : _('Not needed')));
|
|
if (typeof privileges.needs_net_admin === 'boolean')
|
|
privilegeRows.push(makeRow(_('Net admin:'), privileges.needs_net_admin ? _('Required') : _('Not needed')));
|
|
|
|
var networkRows = [];
|
|
if ((network.inbound_ports || []).length)
|
|
networkRows.push(makeRow(_('Inbound ports:'), network.inbound_ports.join(', ')));
|
|
if ((network.protocols || []).length)
|
|
networkRows.push(makeRow(_('Protocols:'), network.protocols.join(', ')));
|
|
if (typeof network.outbound_only === 'boolean')
|
|
networkRows.push(makeRow(_('Network mode:'), network.outbound_only ? _('Outbound only') : _('Inbound/Outbound')));
|
|
|
|
var cliCommands = E('pre', { 'class': 'sb-app-cli' }, [
|
|
'secubox-app install ' + app.id + '\n',
|
|
(wizard.fields && wizard.fields.length ? 'secubox-app wizard ' + app.id + '\n' : ''),
|
|
'secubox-app status ' + app.id + '\n',
|
|
'secubox-app remove ' + app.id
|
|
]);
|
|
|
|
var sections = [
|
|
E('p', { 'class': 'sb-app-desc' }, manifest.description || app.description || ''),
|
|
E('div', { 'class': 'sb-app-detail-grid' }, detailRows),
|
|
requirementRows.length ? E('div', { 'class': 'sb-app-detail-list' }, [
|
|
E('strong', {}, _('Requirements')),
|
|
E('div', { 'class': 'sb-app-detail-grid' }, requirementRows)
|
|
]) : '',
|
|
hardwareRows.length ? E('div', { 'class': 'sb-app-detail-list' }, [
|
|
E('strong', {}, _('Hardware')),
|
|
E('div', { 'class': 'sb-app-detail-grid' }, hardwareRows)
|
|
]) : '',
|
|
privilegeRows.length ? E('div', { 'class': 'sb-app-detail-list' }, [
|
|
E('strong', {}, _('Privileges')),
|
|
E('div', { 'class': 'sb-app-detail-grid' }, privilegeRows)
|
|
]) : '',
|
|
networkRows.length ? E('div', { 'class': 'sb-app-detail-list' }, [
|
|
E('strong', {}, _('Network')),
|
|
E('div', { 'class': 'sb-app-detail-grid' }, networkRows)
|
|
]) : '',
|
|
packages.length ? E('div', { 'class': 'sb-app-detail-list' }, [
|
|
E('strong', {}, _('Packages')),
|
|
E('ul', {}, packages.map(function(pkg) { return E('li', {}, pkg); }))
|
|
]) : '',
|
|
ports.length ? E('div', { 'class': 'sb-app-detail-list' }, [
|
|
E('strong', {}, _('Ports')),
|
|
E('ul', {}, ports.map(function(port) {
|
|
var label = [port.name || 'port', port.protocol || '', port.port || ''].filter(Boolean).join(' · ');
|
|
return E('li', {}, label);
|
|
}))
|
|
]) : '',
|
|
volumes.length ? E('div', { 'class': 'sb-app-detail-list' }, [
|
|
E('strong', {}, _('Volumes')),
|
|
E('ul', {}, volumes.map(function(volume) { return E('li', {}, volume); }))
|
|
]) : '',
|
|
profiles.length ? E('div', { 'class': 'sb-app-detail-list' }, [
|
|
E('strong', {}, _('Profiles')),
|
|
E('ul', {}, profiles.map(function(profile) { return E('li', {}, profile); }))
|
|
]) : '',
|
|
E('div', { 'class': 'sb-app-detail-list' }, [
|
|
E('strong', {}, _('CLI commands')),
|
|
cliCommands
|
|
])
|
|
];
|
|
|
|
var actions = [
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-cancel',
|
|
'click': ui.hideModal
|
|
}, _('Close')),
|
|
(app.has_wizard ? E('button', {
|
|
'class': 'cbi-button cbi-button-action',
|
|
'click': function() {
|
|
ui.hideModal();
|
|
self.openAppWizard(app);
|
|
}
|
|
}, _('Launch wizard')) : null)
|
|
].filter(Boolean);
|
|
|
|
ui.showModal(app.name || app.id, [
|
|
E('div', { 'class': 'sb-app-detail-body' }, sections),
|
|
E('div', { 'class': 'right', 'style': 'margin-top:16px;' }, actions)
|
|
]);
|
|
}).catch(function(err) {
|
|
ui.hideModal();
|
|
ui.addNotification(null, E('p', {}, err && err.message ? err.message : _('Unable to load manifest')), 'error');
|
|
});
|
|
},
|
|
|
|
openAppWizard: function(app) {
|
|
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(function(err) {
|
|
ui.hideModal();
|
|
ui.addNotification(null, E('p', {}, err && err.message ? err.message : _('Failed to load wizard')), 'error');
|
|
});
|
|
},
|
|
|
|
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(function(err) {
|
|
ui.hideModal();
|
|
ui.addNotification(null, E('p', {}, err && err.message ? err.message : _('Failed to apply wizard.')), 'error');
|
|
});
|
|
},
|
|
|
|
handleSaveApply: null,
|
|
handleSave: null,
|
|
handleReset: null
|
|
});
|