feat: Add scheduled backups, live logs, and component detection (v0.6.0-r30)

System Hub enhancements:
- Add cron-based scheduled backup configuration (daily/weekly/monthly)
- Add backup schedule RPCD methods (get_backup_schedule, set_backup_schedule)
- Add live streaming logs with LIVE badge, play/pause, 2s refresh
- Add real component installation detection from secubox state field
- Add service running status detection for components
- Add category-based icons for components (security, network, monitoring)
- Fix status emoji display ( ⚠️ ) for Quick Status Indicators

UI improvements:
- New Scheduled Backups card in backup page with enable/disable toggle
- Time picker for backup schedule (hour/minute selectors)
- Day of week/month selectors for weekly/monthly backups
- Live indicator badge with pulse animation for logs
- Play/Pause button for log streaming control
- New log highlighting with fade-in animation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-08 15:37:50 +01:00
parent 327cc5b285
commit 8255cc6f39
9 changed files with 832 additions and 68 deletions

View File

@ -63,6 +63,19 @@ var callRestoreConfig = rpc.declare({
expect: {}
});
var callGetBackupSchedule = rpc.declare({
object: 'luci.system-hub',
method: 'get_backup_schedule',
expect: {}
});
var callSetBackupSchedule = rpc.declare({
object: 'luci.system-hub',
method: 'set_backup_schedule',
params: ['enabled', 'frequency', 'hour', 'minute', 'day_of_week', 'day_of_month'],
expect: {}
});
var callReboot = rpc.declare({
object: 'luci.system-hub',
method: 'reboot',
@ -214,6 +227,10 @@ return baseclass.extend({
data: data
});
},
getBackupSchedule: callGetBackupSchedule,
setBackupSchedule: function(data) {
return callSetBackupSchedule(data);
},
reboot: callReboot,
getStorage: callGetStorage,
getSettings: callGetSettings,

View File

@ -98,3 +98,86 @@
display: flex;
justify-content: flex-end;
}
/* Schedule form styles */
.sh-schedule-form {
margin-top: 16px;
}
.sh-toggle-main {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
margin-bottom: 16px;
font-weight: 500;
}
.sh-toggle-main input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: var(--sh-accent, #6366f1);
}
.sh-schedule-options {
transition: opacity 0.2s ease;
}
.sh-form-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.sh-form-row > label {
min-width: 100px;
font-size: 14px;
color: var(--sh-text-secondary);
}
.sh-select {
padding: 8px 12px;
border-radius: 10px;
border: 1px solid var(--sh-border);
background: rgba(15,23,42,0.6);
color: var(--sh-text-primary);
font-size: 14px;
min-width: 120px;
}
.sh-select:focus {
outline: none;
border-color: var(--sh-accent, #6366f1);
}
.sh-select-time {
min-width: 70px;
}
.sh-time-picker {
display: flex;
align-items: center;
gap: 6px;
}
.sh-time-picker span {
font-size: 18px;
color: var(--sh-text-secondary);
}
.sh-badge-success {
background: linear-gradient(135deg, rgba(34,197,94,0.2), rgba(22,163,74,0.2)) !important;
color: #22c55e !important;
border: 1px solid rgba(34,197,94,0.3) !important;
}
.sh-badge-muted {
background: rgba(100,116,139,0.2) !important;
color: #94a3b8 !important;
}
.sh-last-backup {
font-style: italic;
opacity: 0.8;
}

View File

@ -132,6 +132,31 @@
border-color: transparent;
}
.sh-btn-success {
background: linear-gradient(135deg,#22c55e,#16a34a);
border-color: transparent;
}
.sh-btn-danger {
background: linear-gradient(135deg,#ef4444,#dc2626);
border-color: transparent;
}
/* Live indicator animations */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes fadeIn {
from { opacity: 0; background: rgba(34, 197, 94, 0.3); }
to { opacity: 1; background: transparent; }
}
.sh-log-new {
border-left: 3px solid #22c55e;
}
.sh-logs-body {
display: grid;
grid-template-columns: 3fr 1fr;

View File

@ -13,11 +13,16 @@ Theme.init({ language: shLang });
return view.extend({
statusData: {},
scheduleData: {},
load: function() {
return API.getSystemInfo().then(L.bind(function(info) {
this.statusData = info || {};
return info;
return Promise.all([
API.getSystemInfo(),
API.getBackupSchedule()
]).then(L.bind(function(results) {
this.statusData = results[0] || {};
this.scheduleData = results[1] || {};
return results;
}, this));
},
@ -32,6 +37,7 @@ return view.extend({
this.renderHero(),
E('div', { 'class': 'sh-backup-grid' }, [
this.renderBackupCard(),
this.renderScheduleCard(),
this.renderRestoreCard(),
this.renderMaintenanceCard()
])
@ -97,6 +103,174 @@ return view.extend({
]);
},
renderScheduleCard: function() {
var self = this;
var schedule = this.scheduleData || {};
var enabled = schedule.enabled || false;
var frequency = schedule.frequency || 'weekly';
var hour = schedule.hour || '03';
var minute = schedule.minute || '00';
var dayOfWeek = schedule.day_of_week || '0';
var dayOfMonth = schedule.day_of_month || '1';
var dayNames = [
_('Sunday'), _('Monday'), _('Tuesday'), _('Wednesday'),
_('Thursday'), _('Friday'), _('Saturday')
];
var frequencySelect = E('select', {
'id': 'schedule-frequency',
'class': 'sh-select',
'change': function() { self.updateScheduleVisibility(); }
}, [
E('option', { 'value': 'daily', 'selected': frequency === 'daily' ? 'selected' : null }, _('Daily')),
E('option', { 'value': 'weekly', 'selected': frequency === 'weekly' ? 'selected' : null }, _('Weekly')),
E('option', { 'value': 'monthly', 'selected': frequency === 'monthly' ? 'selected' : null }, _('Monthly'))
]);
var hourSelect = E('select', { 'id': 'schedule-hour', 'class': 'sh-select sh-select-time' });
for (var h = 0; h < 24; h++) {
var hStr = (h < 10 ? '0' : '') + h;
hourSelect.appendChild(E('option', { 'value': hStr, 'selected': hStr === hour ? 'selected' : null }, hStr));
}
var minuteSelect = E('select', { 'id': 'schedule-minute', 'class': 'sh-select sh-select-time' });
for (var m = 0; m < 60; m += 15) {
var mStr = (m < 10 ? '0' : '') + m;
minuteSelect.appendChild(E('option', { 'value': mStr, 'selected': mStr === minute ? 'selected' : null }, mStr));
}
var dowSelect = E('select', { 'id': 'schedule-dow', 'class': 'sh-select' });
for (var d = 0; d < 7; d++) {
dowSelect.appendChild(E('option', { 'value': String(d), 'selected': String(d) === dayOfWeek ? 'selected' : null }, dayNames[d]));
}
var domSelect = E('select', { 'id': 'schedule-dom', 'class': 'sh-select' });
for (var day = 1; day <= 28; day++) {
domSelect.appendChild(E('option', { 'value': String(day), 'selected': String(day) === dayOfMonth ? 'selected' : null }, String(day)));
}
var statusText = enabled
? (schedule.next_backup ? _('Next: ') + schedule.next_backup : _('Enabled'))
: _('Disabled');
return E('section', { 'class': 'sh-card' }, [
E('div', { 'class': 'sh-card-header' }, [
E('div', { 'class': 'sh-card-title' }, [
E('span', { 'class': 'sh-card-title-icon' }, '📅'),
_('Scheduled Backups')
]),
E('span', {
'class': 'sh-card-badge ' + (enabled ? 'sh-badge-success' : 'sh-badge-muted'),
'id': 'schedule-status-badge'
}, statusText)
]),
E('div', { 'class': 'sh-card-body' }, [
E('p', { 'class': 'sh-text-muted' }, _('Automatically create backups on a schedule. Backups are saved to /root/backups with auto-cleanup after 30 days.')),
E('div', { 'class': 'sh-schedule-form' }, [
E('label', { 'class': 'sh-toggle sh-toggle-main' }, [
E('input', {
'type': 'checkbox',
'id': 'schedule-enabled',
'checked': enabled ? 'checked' : null,
'change': function() { self.updateScheduleVisibility(); }
}),
E('span', {}, _('Enable scheduled backups'))
]),
E('div', { 'class': 'sh-schedule-options', 'id': 'schedule-options', 'style': enabled ? '' : 'opacity: 0.5; pointer-events: none;' }, [
E('div', { 'class': 'sh-form-row' }, [
E('label', {}, _('Frequency')),
frequencySelect
]),
E('div', { 'class': 'sh-form-row' }, [
E('label', {}, _('Time')),
E('div', { 'class': 'sh-time-picker' }, [
hourSelect,
E('span', {}, ':'),
minuteSelect
])
]),
E('div', { 'class': 'sh-form-row', 'id': 'dow-row', 'style': frequency === 'weekly' ? '' : 'display: none;' }, [
E('label', {}, _('Day of week')),
dowSelect
]),
E('div', { 'class': 'sh-form-row', 'id': 'dom-row', 'style': frequency === 'monthly' ? '' : 'display: none;' }, [
E('label', {}, _('Day of month')),
domSelect
])
])
]),
E('div', { 'class': 'sh-action-row', 'style': 'margin-top: 16px;' }, [
E('button', {
'class': 'sh-btn sh-btn-primary',
'type': 'button',
'click': ui.createHandlerFn(this, 'saveSchedule')
}, '💾 ' + _('Save Schedule'))
]),
schedule.last_backup ? E('p', { 'class': 'sh-text-muted sh-last-backup', 'style': 'margin-top: 12px; font-size: 13px;' },
_('Last backup: ') + schedule.last_backup) : ''
])
]);
},
updateScheduleVisibility: function() {
var enabled = document.getElementById('schedule-enabled');
var options = document.getElementById('schedule-options');
var frequency = document.getElementById('schedule-frequency');
var dowRow = document.getElementById('dow-row');
var domRow = document.getElementById('dom-row');
if (enabled && options) {
options.style.opacity = enabled.checked ? '1' : '0.5';
options.style.pointerEvents = enabled.checked ? 'auto' : 'none';
}
if (frequency && dowRow && domRow) {
var freq = frequency.value;
dowRow.style.display = freq === 'weekly' ? '' : 'none';
domRow.style.display = freq === 'monthly' ? '' : 'none';
}
},
saveSchedule: function() {
var enabled = document.getElementById('schedule-enabled');
var frequency = document.getElementById('schedule-frequency');
var hour = document.getElementById('schedule-hour');
var minute = document.getElementById('schedule-minute');
var dow = document.getElementById('schedule-dow');
var dom = document.getElementById('schedule-dom');
var data = {
enabled: enabled && enabled.checked ? 1 : 0,
frequency: frequency ? frequency.value : 'weekly',
hour: hour ? hour.value : '03',
minute: minute ? minute.value : '00',
day_of_week: dow ? dow.value : '0',
day_of_month: dom ? dom.value : '1'
};
ui.showModal(_('Saving schedule...'), [
E('p', { 'class': 'spinning' }, _('Updating cron configuration...'))
]);
return API.setBackupSchedule(data).then(function(result) {
ui.hideModal();
if (result && result.success) {
ui.addNotification(null, E('p', {}, _('Backup schedule saved successfully')), 'info');
var badge = document.getElementById('schedule-status-badge');
if (badge) {
badge.className = 'sh-card-badge ' + (data.enabled ? 'sh-badge-success' : 'sh-badge-muted');
badge.textContent = data.enabled ? _('Enabled') : _('Disabled');
}
} else {
ui.addNotification(null, E('p', {}, (result && result.message) || _('Failed to save schedule')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, err.message || err), 'error');
});
},
renderRestoreCard: function() {
return E('section', { 'class': 'sh-card' }, [
E('div', { 'class': 'sh-card-header' }, [

View File

@ -197,6 +197,7 @@ return view.extend({
renderComponentActions: function(component) {
var self = this;
var actions = [];
var serviceName = component.service || component.id;
if (component.installed) {
if (component.running) {
@ -204,7 +205,7 @@ return view.extend({
actions.push(
E('button', {
'class': 'sh-action-btn sh-btn-danger',
'click': function() { self.handleComponentAction(component.id, 'stop'); }
'click': function() { self.handleComponentAction(component, 'stop'); }
}, [
E('span', {}, '⏹️'),
' Stop'
@ -215,16 +216,16 @@ return view.extend({
actions.push(
E('button', {
'class': 'sh-action-btn sh-btn-warning',
'click': function() { self.handleComponentAction(component.id, 'restart'); }
'click': function() { self.handleComponentAction(component, 'restart'); }
}, [
E('span', {}, '🔄'),
' Restart'
])
);
// Dashboard button (if has dashboard)
if (component.package && component.package.includes('dashboard')) {
var dashboardUrl = '/cgi-bin/luci/admin/secubox/' + component.category + '/' + component.id;
// Dashboard button for security/monitoring components
if (component.category === 'security' || component.category === 'monitoring') {
var dashboardUrl = L.url('admin/secubox/' + component.category + '/' + component.id);
actions.push(
E('a', {
'class': 'sh-action-btn sh-btn-primary',
@ -240,7 +241,7 @@ return view.extend({
actions.push(
E('button', {
'class': 'sh-action-btn sh-btn-success',
'click': function() { self.handleComponentAction(component.id, 'start'); }
'click': function() { self.handleComponentAction(component, 'start'); }
}, [
E('span', {}, '▶️'),
' Start'
@ -248,12 +249,12 @@ return view.extend({
);
}
} else {
// Install button
// Not installed - show package info
actions.push(
E('button', {
'class': 'sh-action-btn sh-btn-secondary',
'disabled': 'disabled',
'title': 'Manual installation required'
'title': 'Install via: opkg install ' + component.package
}, [
E('span', {}, '📥'),
' Not Installed'
@ -264,36 +265,43 @@ return view.extend({
return actions;
},
handleComponentAction: function(componentId, action) {
handleComponentAction: function(component, action) {
var self = this;
var serviceName = component.service || component.id;
var displayName = component.name || component.id;
ui.showModal(_('Component Action'), [
E('p', {}, 'Performing ' + action + ' on ' + componentId + '...'),
E('p', {}, _('Performing ') + action + _(' on ') + displayName + '...'),
E('div', { 'class': 'spinning' })
]);
// Call service action via system-hub API
API.serviceAction(componentId, action).then(function(result) {
// Call service action via system-hub API using service name
API.serviceAction(serviceName, action).then(function(result) {
ui.hideModal();
if (result && result.success) {
ui.addNotification(null,
E('p', {}, '✅ ' + componentId + ' ' + action + ' successful'),
E('p', {}, '✅ ' + displayName + ' ' + action + ' ' + _('successful')),
'success');
// Refresh components
// Refresh components after a short delay
setTimeout(function() {
self.updateComponentsGrid();
}, 2000);
API.getComponents().then(function(data) {
if (data && data.modules) {
self.componentsData = data.modules;
self.updateComponentsGrid();
}
});
}, 1500);
} else {
ui.addNotification(null,
E('p', {}, '❌ Failed to ' + action + ' ' + componentId),
E('p', {}, '❌ ' + _('Failed to ') + action + ' ' + displayName + (result && result.message ? ': ' + result.message : '')),
'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null,
E('p', {}, '❌ Error: ' + (err.message || err)),
E('p', {}, '❌ ' + _('Error: ') + (err.message || err)),
'error');
});
},

View File

@ -20,13 +20,16 @@ return view.extend({
autoScroll: true,
searchQuery: '',
severityFilter: 'all',
lastLogCount: 0,
pollInterval: 2,
load: function() {
return API.getLogs(this.lineCount, '');
},
render: function(data) {
this.logs = (data && data.logs) || [];
this.logs = Array.isArray(data) ? data : (data && data.logs) || [];
this.lastLogCount = this.logs.length;
var container = E('div', { 'class': 'sh-logs-view' }, [
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
@ -45,12 +48,17 @@ return view.extend({
var self = this;
poll.add(function() {
if (!self.autoRefresh) return;
self.updateLiveIndicator(true);
return API.getLogs(self.lineCount, '').then(function(result) {
self.logs = (result && result.logs) || [];
var newLogs = Array.isArray(result) ? result : (result && result.logs) || [];
var hasNewLogs = newLogs.length !== self.lastLogCount;
self.logs = newLogs;
self.lastLogCount = newLogs.length;
self.updateStats();
self.updateLogStream();
self.updateLogStream(hasNewLogs);
self.updateLiveIndicator(false);
});
}, 5);
}, this.pollInterval);
return container;
},
@ -58,8 +66,18 @@ return view.extend({
renderHero: function() {
return E('section', { 'class': 'sh-logs-hero' }, [
E('div', {}, [
E('h1', {}, _('System Logs Live Stream')),
E('p', {}, _('Follow kernel, service, and security events in real time'))
E('h1', { 'style': 'display: flex; align-items: center; gap: 0.5em;' }, [
_('System Logs'),
E('span', {
'id': 'sh-live-indicator',
'class': 'sh-live-badge',
'style': 'display: inline-flex; align-items: center; gap: 0.3em; font-size: 0.5em; padding: 0.3em 0.6em; background: #22c55e; color: #fff; border-radius: 4px; animation: pulse 2s infinite;'
}, [
E('span', { 'class': 'sh-live-dot', 'style': 'width: 8px; height: 8px; background: #fff; border-radius: 50%;' }),
'LIVE'
])
]),
E('p', {}, _('Real-time kernel, service, and security events'))
]),
E('div', { 'class': 'sh-log-stats', 'id': 'sh-log-stats' }, [
this.createStat('sh-log-total', _('Lines'), this.logs.length),
@ -92,6 +110,17 @@ return view.extend({
})
]),
E('div', { 'class': 'sh-log-selectors' }, [
E('button', {
'id': 'sh-play-pause',
'class': 'sh-btn ' + (this.autoRefresh ? 'sh-btn-danger' : 'sh-btn-success'),
'type': 'button',
'style': 'min-width: 100px; font-size: 1.1em;',
'click': function(ev) {
self.autoRefresh = !self.autoRefresh;
self.updatePlayPauseButton();
self.updateLiveBadge();
}
}, this.autoRefresh ? '⏸ ' + _('Pause') : '▶ ' + _('Play')),
E('select', {
'change': function(ev) {
self.lineCount = parseInt(ev.target.value, 10);
@ -103,13 +132,15 @@ return view.extend({
E('option', { 'value': '500' }, '500 lines'),
E('option', { 'value': '1000' }, '1000 lines')
]),
E('div', { 'class': 'sh-toggle-group' }, [
this.renderToggle(_('Auto Refresh'), this.autoRefresh, function(enabled) {
self.autoRefresh = enabled;
E('label', { 'class': 'sh-toggle', 'style': 'display: flex; align-items: center; gap: 0.5em;' }, [
E('input', {
'type': 'checkbox',
'checked': this.autoScroll ? 'checked' : null,
'change': function(ev) {
self.autoScroll = ev.target.checked;
}
}),
this.renderToggle(_('Auto Scroll'), this.autoScroll, function(enabled) {
self.autoScroll = enabled;
})
E('span', {}, _('Auto Scroll'))
]),
E('button', {
'class': 'sh-btn sh-btn-primary',
@ -120,6 +151,40 @@ return view.extend({
]);
},
updatePlayPauseButton: function() {
var btn = document.getElementById('sh-play-pause');
if (btn) {
btn.textContent = this.autoRefresh ? '⏸ ' + _('Pause') : '▶ ' + _('Play');
btn.className = 'sh-btn ' + (this.autoRefresh ? 'sh-btn-danger' : 'sh-btn-success');
}
},
updateLiveBadge: function() {
var badge = document.getElementById('sh-live-indicator');
if (badge) {
if (this.autoRefresh) {
badge.style.background = '#22c55e';
badge.style.animation = 'pulse 2s infinite';
badge.innerHTML = '<span class="sh-live-dot" style="width: 8px; height: 8px; background: #fff; border-radius: 50%;"></span>LIVE';
} else {
badge.style.background = '#6b7280';
badge.style.animation = 'none';
badge.innerHTML = '⏸ PAUSED';
}
}
},
updateLiveIndicator: function(fetching) {
var badge = document.getElementById('sh-live-indicator');
if (badge && this.autoRefresh) {
if (fetching) {
badge.style.background = '#f59e0b';
} else {
badge.style.background = '#22c55e';
}
}
},
renderToggle: function(label, state, handler) {
return E('label', { 'class': 'sh-toggle' }, [
E('input', {
@ -194,13 +259,18 @@ return view.extend({
}, this);
},
updateLogStream: function() {
updateLogStream: function(hasNewLogs) {
var container = document.getElementById('sh-log-stream');
if (!container) return;
var filtered = this.getFilteredLogs();
var totalLines = filtered.length;
var frag = filtered.map(function(line, idx) {
var severity = this.detectSeverity(line);
return E('div', { 'class': 'sh-log-line ' + severity }, [
var isNew = hasNewLogs && idx >= totalLines - 5;
return E('div', {
'class': 'sh-log-line ' + severity + (isNew ? ' sh-log-new' : ''),
'style': isNew ? 'animation: fadeIn 0.5s ease;' : ''
}, [
E('span', { 'class': 'sh-log-index' }, idx + 1),
E('span', { 'class': 'sh-log-message' }, line)
]);
@ -252,7 +322,7 @@ return view.extend({
]);
return API.getLogs(this.lineCount, '').then(function(result) {
ui.hideModal();
self.logs = (result && result.logs) || [];
self.logs = Array.isArray(result) ? result : (result && result.logs) || [];
self.updateStats();
self.updateLogStream();
}).catch(function(err) {

View File

@ -292,8 +292,8 @@ return view.extend({
},
getStatusLabel: function(state) {
if (state === undefined || state === null) return _('Unknown');
return state ? _('Healthy') : _('Attention');
if (state === undefined || state === null) return '❓';
return state ? '✅' : '⚠️';
},
getScoreLabel: function(score) {

View File

@ -117,6 +117,60 @@ get_system_info() {
local localtime=$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "unknown")
json_add_string "local_time" "$localtime"
# Load averages
local load=$(cat /proc/loadavg 2>/dev/null || echo "0 0 0")
local load1=$(echo $load | awk '{print $1}')
local load5=$(echo $load | awk '{print $2}')
local load15=$(echo $load | awk '{print $3}')
json_add_array "load"
json_add_string "" "$load1"
json_add_string "" "$load5"
json_add_string "" "$load15"
json_close_array
# Quick Status Indicators
json_add_object "status"
# Internet connectivity (ping 8.8.8.8)
local internet_ok=0
if ping -c 1 -W 2 8.8.8.8 >/dev/null 2>&1; then
internet_ok=1
fi
json_add_boolean "internet" "$internet_ok"
# DNS resolution (resolve cloudflare.com)
local dns_ok=0
if nslookup cloudflare.com >/dev/null 2>&1 || host cloudflare.com >/dev/null 2>&1; then
dns_ok=1
fi
json_add_boolean "dns" "$dns_ok"
# NTP sync status
local ntp_ok=0
if [ -f /var/state/ntpd ] || pgrep -x ntpd >/dev/null 2>&1 || pgrep -x chronyd >/dev/null 2>&1; then
# Check if time seems reasonable (after year 2020)
local year=$(date +%Y)
[ "$year" -ge 2020 ] && ntp_ok=1
fi
json_add_boolean "ntp" "$ntp_ok"
# Firewall status (check if nftables or iptables rules exist)
local firewall_ok=0
local firewall_rules=0
# Count nftables rules (chains with rules)
firewall_rules=$(nft list ruleset 2>/dev/null | grep -cE "^\s+(type|counter|accept|drop|reject|jump|goto)" || echo 0)
if [ "$firewall_rules" -gt 0 ]; then
firewall_ok=1
else
# Fallback to iptables
firewall_rules=$(iptables -L -n 2>/dev/null | grep -cE "^(ACCEPT|DROP|REJECT)" || echo 0)
[ "$firewall_rules" -gt 0 ] && firewall_ok=1
fi
json_add_boolean "firewall" "$firewall_ok"
json_add_int "firewall_rules" "$firewall_rules"
json_close_object
json_dump
}
@ -577,6 +631,147 @@ reboot_system() {
( sleep 3 && reboot ) &
}
# Get backup schedule from crontab
get_backup_schedule() {
json_init
local cron_line=""
local enabled=0
local frequency="weekly"
local hour="03"
local minute="00"
local day_of_week="0"
local day_of_month="1"
local last_backup=""
local next_backup=""
# Check for existing backup cron job
if [ -f /etc/crontabs/root ]; then
cron_line=$(grep "sysupgrade -b" /etc/crontabs/root 2>/dev/null | head -n 1)
fi
if [ -n "$cron_line" ]; then
enabled=1
# Parse cron schedule: minute hour day_of_month month day_of_week command
minute=$(echo "$cron_line" | awk '{print $1}')
hour=$(echo "$cron_line" | awk '{print $2}')
local dom=$(echo "$cron_line" | awk '{print $3}')
local dow=$(echo "$cron_line" | awk '{print $5}')
# Determine frequency from cron pattern
if [ "$dom" != "*" ] && [ "$dow" = "*" ]; then
frequency="monthly"
day_of_month="$dom"
elif [ "$dow" != "*" ]; then
frequency="weekly"
day_of_week="$dow"
else
frequency="daily"
fi
fi
# Find last backup file
local last_file=$(ls -t /tmp/backup-*.tar.gz 2>/dev/null | head -n 1)
if [ -n "$last_file" ] && [ -f "$last_file" ]; then
last_backup=$(date -r "$last_file" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "")
fi
# Calculate next backup time (simplified)
if [ "$enabled" = "1" ]; then
local now_hour=$(date +%H)
local now_min=$(date +%M)
local target_time="${hour}:${minute}"
case "$frequency" in
daily)
if [ "$now_hour$now_min" -lt "${hour}${minute}" ]; then
next_backup="Today at $target_time"
else
next_backup="Tomorrow at $target_time"
fi
;;
weekly)
local dow_names="Sun Mon Tue Wed Thu Fri Sat"
local dow_name=$(echo "$dow_names" | cut -d' ' -f$((day_of_week + 1)))
next_backup="$dow_name at $target_time"
;;
monthly)
next_backup="Day $day_of_month at $target_time"
;;
esac
fi
json_add_boolean "enabled" "$enabled"
json_add_string "frequency" "$frequency"
json_add_string "hour" "$hour"
json_add_string "minute" "$minute"
json_add_string "day_of_week" "$day_of_week"
json_add_string "day_of_month" "$day_of_month"
json_add_string "last_backup" "$last_backup"
json_add_string "next_backup" "$next_backup"
json_dump
}
# Set backup schedule in crontab
set_backup_schedule() {
read -r input
json_load "$input"
local enabled frequency hour minute day_of_week day_of_month
json_get_var enabled enabled "0"
json_get_var frequency frequency "weekly"
json_get_var hour hour "03"
json_get_var minute minute "00"
json_get_var day_of_week day_of_week "0"
json_get_var day_of_month day_of_month "1"
json_cleanup
# Validate inputs
hour=$(printf "%02d" "$((${hour:-3} % 24))")
minute=$(printf "%02d" "$((${minute:-0} % 60))")
day_of_week=$(printf "%d" "$((${day_of_week:-0} % 7))")
day_of_month=$(printf "%d" "$((${day_of_month:-1}))")
[ "$day_of_month" -lt 1 ] && day_of_month=1
[ "$day_of_month" -gt 28 ] && day_of_month=28
# Backup destination
local backup_dir="/root/backups"
local backup_cmd="mkdir -p $backup_dir && sysupgrade -b $backup_dir/backup-\$(date +%Y%m%d-%H%M%S).tar.gz && find $backup_dir -name 'backup-*.tar.gz' -mtime +30 -delete"
# Remove existing backup cron entries
if [ -f /etc/crontabs/root ]; then
grep -v "sysupgrade -b" /etc/crontabs/root > /tmp/crontab.tmp 2>/dev/null || touch /tmp/crontab.tmp
mv /tmp/crontab.tmp /etc/crontabs/root
else
touch /etc/crontabs/root
fi
# Add new cron entry if enabled
if [ "$enabled" = "1" ]; then
local cron_schedule=""
case "$frequency" in
daily)
cron_schedule="$minute $hour * * *"
;;
weekly)
cron_schedule="$minute $hour * * $day_of_week"
;;
monthly)
cron_schedule="$minute $hour $day_of_month * *"
;;
esac
echo "$cron_schedule $backup_cmd" >> /etc/crontabs/root
fi
# Reload cron
/etc/init.d/cron restart >/dev/null 2>&1 || true
json_init
json_add_boolean "success" 1
json_add_string "message" "Backup schedule updated"
json_dump
}
# Get storage details
get_storage() {
json_init
@ -1404,38 +1599,217 @@ save_settings() {
json_dump
}
# Get components (leverages secubox module detection)
get_components() {
# Call secubox backend to get apps list
local apps_result=$(ubus call luci.secubox list_apps 2>/dev/null)
# Check if a package is installed (supports wildcards)
is_package_installed() {
local pkg="$1"
[ -z "$pkg" ] && return 1
# Check exact match first
opkg list-installed 2>/dev/null | grep -q "^${pkg} " && return 0
# Check if it's a LuCI app package
opkg list-installed 2>/dev/null | grep -q "^luci-app-${pkg} " && return 0
return 1
}
if [ -n "$apps_result" ]; then
# Transform apps to components format
echo "$apps_result" | jq '{
modules: [
.apps[] | {
id: .id,
name: .name,
version: (.pkg_version // .version // "1.0.0"),
category: (.category // "system"),
description: (.description // "No description"),
icon: (.icon // "📦"),
package: (.packages.required[0] // ""),
installed: false,
running: false,
color: (
if .category == "security" then "#ef4444"
elif .category == "monitoring" then "#10b981"
elif .category == "network" then "#3b82f6"
else "#64748b"
end
)
}
]
}'
# Get installed package info (name and version) for a component
get_installed_package_info() {
local pkg="$1"
[ -z "$pkg" ] && return
# Try exact match
local info=$(opkg list-installed "$pkg" 2>/dev/null | head -n1)
if [ -z "$info" ]; then
# Try luci-app- prefix
info=$(opkg list-installed "luci-app-$pkg" 2>/dev/null | head -n1)
fi
echo "$info"
}
# Check if a service is running
is_service_running() {
local svc="$1"
[ -z "$svc" ] && return 1
# Check init script
if [ -x "/etc/init.d/$svc" ]; then
/etc/init.d/"$svc" running >/dev/null 2>&1 && return 0
fi
# Fallback: check process
pgrep -x "$svc" >/dev/null 2>&1 && return 0
return 1
}
# Get package version
get_package_version() {
local pkg="$1"
[ -z "$pkg" ] && echo "" && return
local ver=$(opkg list-installed "$pkg" 2>/dev/null | awk '{print $3}' | head -n1)
echo "${ver:-}"
}
# Add a single component to the JSON output
add_component() {
local id="$1" name="$2" category="$3" icon="$4" package="$5" service="$6" description="$7" color="$8"
local installed=0
local running=0
local version=""
if is_package_installed "$package"; then
installed=1
version=$(get_package_version "$package")
fi
if [ "$installed" = "1" ] && is_service_running "$service"; then
running=1
fi
json_add_object ""
json_add_string "id" "$id"
json_add_string "name" "$name"
json_add_string "category" "$category"
json_add_string "icon" "$icon"
json_add_string "package" "$package"
json_add_string "service" "$service"
json_add_string "description" "$description"
json_add_string "color" "$color"
json_add_string "version" "$version"
json_add_boolean "installed" "$installed"
json_add_boolean "running" "$running"
json_close_object
}
# Get components with real installation/running status
get_components() {
# First try to get apps from secubox backend
local apps_json=$(ubus call luci.secubox list_apps 2>/dev/null)
if [ -n "$apps_json" ]; then
json_init
json_add_array "modules"
# Parse using jsonfilter - get all app IDs first
local app_ids=$(echo "$apps_json" | jsonfilter -e '@.apps[*].id' 2>/dev/null)
local i=0
for id in $app_ids; do
[ -z "$id" ] && continue
local name=$(echo "$apps_json" | jsonfilter -e "@.apps[$i].name" 2>/dev/null)
local category=$(echo "$apps_json" | jsonfilter -e "@.apps[$i].category" 2>/dev/null)
local state=$(echo "$apps_json" | jsonfilter -e "@.apps[$i].state" 2>/dev/null)
local version=$(echo "$apps_json" | jsonfilter -e "@.apps[$i].version" 2>/dev/null)
local description=$(echo "$apps_json" | jsonfilter -e "@.apps[$i].description" 2>/dev/null)
[ -z "$name" ] && name="$id"
[ -z "$category" ] && category="system"
[ -z "$state" ] && state="missing"
[ -z "$description" ] && description="No description"
local installed=0
local running=0
local color="#64748b"
local icon="📦"
local service="$id"
# Determine if installed based on state from secubox
case "$state" in
installed|partial) installed=1 ;;
esac
# Determine color and icon based on category
case "$category" in
security) color="#ef4444"; icon="🛡️" ;;
monitoring) color="#10b981"; icon="📊" ;;
network) color="#3b82f6"; icon="🌐" ;;
system) color="#64748b"; icon="⚙️" ;;
esac
# Override icon based on app ID
case "$id" in
crowdsec*) icon="🛡️" ;;
auth-guardian) icon="🔐" ;;
client-guardian) icon="👥" ;;
key-storage*|ksm*) icon="🔑" ;;
vaultwarden) icon="🔒" ;;
wireguard*) icon="🔒" ;;
bandwidth*|nlbwmon) icon="📊" ;;
netdata*) icon="📉" ;;
esac
# Check if service is running
if [ "$installed" = "1" ]; then
# Try direct service name
if [ -x "/etc/init.d/$id" ]; then
/etc/init.d/"$id" running >/dev/null 2>&1 && running=1
fi
# Try base service name (e.g., crowdsec-dashboard -> crowdsec)
if [ "$running" = "0" ]; then
local base_svc=$(echo "$id" | sed 's/-dashboard$//' | sed 's/-guardian$//' | sed 's/-manager$//')
if [ -x "/etc/init.d/$base_svc" ]; then
/etc/init.d/"$base_svc" running >/dev/null 2>&1 && running=1
service="$base_svc"
fi
fi
# Fallback: check process
if [ "$running" = "0" ]; then
pgrep -f "$id" >/dev/null 2>&1 && running=1
fi
fi
# Get version from opkg if not provided
if [ -z "$version" ] && [ "$installed" = "1" ]; then
local luci_pkg="luci-app-${id}"
version=$(opkg list-installed "$luci_pkg" 2>/dev/null | awk '{print $3}' | head -n1)
fi
json_add_object ""
json_add_string "id" "$id"
json_add_string "name" "$name"
json_add_string "category" "$category"
json_add_string "icon" "$icon"
json_add_string "package" "luci-app-$id"
json_add_string "service" "$service"
json_add_string "description" "$description"
json_add_string "color" "$color"
json_add_string "version" "$version"
json_add_string "state" "$state"
json_add_boolean "installed" "$installed"
json_add_boolean "running" "$running"
json_close_object
i=$((i + 1))
done
# Also add core system components
add_component "firewall4" "Firewall" "system" "🧱" "firewall4" "firewall" "nftables-based firewall" "#64748b"
add_component "dnsmasq" "DNSMasq" "system" "🔍" "dnsmasq-full" "dnsmasq" "DNS and DHCP server" "#64748b"
add_component "dropbear" "SSH Server" "system" "🔑" "dropbear" "dropbear" "Lightweight SSH server" "#64748b"
add_component "uhttpd" "uHTTPd" "system" "🌐" "uhttpd" "uhttpd" "Lightweight HTTP server" "#64748b"
json_close_array
json_dump
else
# Fallback if secubox is not available
echo '{"modules":[]}'
# Fallback: use hardcoded list with real status checks
json_init
json_add_array "modules"
add_component "crowdsec" "CrowdSec" "security" "🛡️" "crowdsec" "crowdsec" "Collaborative security engine" "#ef4444"
add_component "crowdsec-firewall-bouncer" "CrowdSec Bouncer" "security" "🔥" "crowdsec-firewall-bouncer-nftables" "crowdsec-firewall-bouncer" "Firewall bouncer" "#ef4444"
add_component "adguardhome" "AdGuard Home" "security" "🚫" "adguardhome" "AdGuardHome" "Ad and tracker blocking" "#22c55e"
add_component "banip" "BanIP" "security" "🚷" "banip" "banip" "IP blocking service" "#ef4444"
add_component "wireguard" "WireGuard" "network" "🔒" "wireguard-tools" "wg-quick@wg0" "Modern VPN tunnel" "#3b82f6"
add_component "sqm" "SQM QoS" "network" "⚡" "sqm-scripts" "sqm" "Smart Queue Management" "#3b82f6"
add_component "mwan3" "Multi-WAN" "network" "🔀" "mwan3" "mwan3" "Multi-WAN failover" "#3b82f6"
add_component "nlbwmon" "Bandwidth Monitor" "monitoring" "📊" "nlbwmon" "nlbwmon" "Bandwidth monitoring" "#10b981"
add_component "vnstat2" "Traffic Stats" "monitoring" "📈" "vnstat2" "vnstatd" "Traffic statistics" "#10b981"
add_component "firewall4" "Firewall" "system" "🧱" "firewall4" "firewall" "nftables firewall" "#64748b"
add_component "dnsmasq" "DNSMasq" "system" "🔍" "dnsmasq-full" "dnsmasq" "DNS/DHCP server" "#64748b"
add_component "dropbear" "SSH Server" "system" "🔑" "dropbear" "dropbear" "SSH server" "#64748b"
add_component "uhttpd" "uHTTPd" "system" "🌐" "uhttpd" "uhttpd" "HTTP server" "#64748b"
json_close_array
json_dump
fi
}
@ -1478,6 +1852,15 @@ case "$1" in
"get_logs": { "lines": 100, "filter": "" },
"backup_config": {},
"restore_config": { "file_name": "string", "data": "string" },
"get_backup_schedule": {},
"set_backup_schedule": {
"enabled": 1,
"frequency": "weekly",
"hour": "03",
"minute": "00",
"day_of_week": "0",
"day_of_month": "1"
},
"reboot": {},
"get_storage": {},
"get_settings": {},
@ -1539,6 +1922,8 @@ EOF
get_logs) get_logs ;;
backup_config) backup_config ;;
restore_config) restore_config ;;
get_backup_schedule) get_backup_schedule ;;
set_backup_schedule) set_backup_schedule ;;
reboot) reboot_system ;;
get_storage) get_storage ;;
get_settings) get_settings ;;

View File

@ -13,6 +13,7 @@
"get_logs",
"get_storage",
"get_settings",
"get_backup_schedule",
"list_diagnostics",
"list_diagnostic_profiles",
"get_diagnostic_profile",
@ -38,6 +39,7 @@
"service_action",
"backup_config",
"restore_config",
"set_backup_schedule",
"reboot",
"save_settings",
"collect_diagnostics",