446 lines
14 KiB
JavaScript
446 lines
14 KiB
JavaScript
'use strict';
|
||
'require view';
|
||
'require rpc';
|
||
'require ui';
|
||
'require dom';
|
||
'require poll';
|
||
'require network-modes.helpers as helpers';
|
||
'require network-modes/api as API';
|
||
'require secubox-theme/theme as Theme';
|
||
|
||
var callGetAvailableModes = rpc.declare({
|
||
object: 'luci.network-modes',
|
||
method: 'get_available_modes',
|
||
expect: { modes: [] }
|
||
});
|
||
|
||
var callGetCurrentMode = rpc.declare({
|
||
object: 'luci.network-modes',
|
||
method: 'get_current_mode',
|
||
expect: { }
|
||
});
|
||
|
||
var callSetMode = rpc.declare({
|
||
object: 'luci.network-modes',
|
||
method: 'set_mode',
|
||
params: ['mode'],
|
||
expect: { }
|
||
});
|
||
|
||
var callPreviewChanges = rpc.declare({
|
||
object: 'luci.network-modes',
|
||
method: 'preview_changes',
|
||
expect: { }
|
||
});
|
||
|
||
var callApplyMode = rpc.declare({
|
||
object: 'luci.network-modes',
|
||
method: 'apply_mode',
|
||
expect: { }
|
||
});
|
||
|
||
var callConfirmMode = rpc.declare({
|
||
object: 'luci.network-modes',
|
||
method: 'confirm_mode',
|
||
expect: { }
|
||
});
|
||
|
||
var callRollback = rpc.declare({
|
||
object: 'luci.network-modes',
|
||
method: 'rollback',
|
||
expect: { }
|
||
});
|
||
|
||
var nmLang = (typeof L !== 'undefined' && L.env && L.env.lang) ||
|
||
(document.documentElement && document.documentElement.getAttribute('lang')) ||
|
||
(navigator.language ? navigator.language.split('-')[0] : 'en');
|
||
Theme.init({ language: nmLang });
|
||
|
||
return view.extend({
|
||
title: _('Network Mode Wizard'),
|
||
|
||
load: function() {
|
||
return Promise.all([
|
||
callGetAvailableModes(),
|
||
callGetCurrentMode(),
|
||
API.getStatus()
|
||
]);
|
||
},
|
||
|
||
render: function(data) {
|
||
var modes = data[0].modes || [];
|
||
var currentModeData = data[1] || {};
|
||
var status = data[2] || {};
|
||
|
||
var hero = helpers.createHero({
|
||
icon: '🌐',
|
||
title: _('Network Configuration Wizard'),
|
||
subtitle: _('Switch between Router, Access Point, Relay, Travel, Bridge, and Sniffer topologies with automatic preview + rollback safety.'),
|
||
gradient: 'linear-gradient(135deg,#6366f1,#9333ea)',
|
||
actions: [
|
||
currentModeData.rollback_active ? E('button', {
|
||
'class': 'nm-btn nm-btn-primary',
|
||
'click': ui.createHandlerFn(this, 'handleConfirmMode')
|
||
}, ['✅ ', _('Confirm Mode')]) : null,
|
||
currentModeData.rollback_active ? E('button', {
|
||
'class': 'nm-btn',
|
||
'click': ui.createHandlerFn(this, 'handleRollbackNow')
|
||
}, ['↩ ', _('Rollback Now')]) : null
|
||
].filter(Boolean)
|
||
});
|
||
|
||
var stepper = helpers.createStepper([
|
||
{ title: _('Select Topology'), description: _('Pick Router / AP / Relay / Travel / Bridge / Sniffer') },
|
||
{ title: _('Review Changes'), description: _('Preview network config+services impacted') },
|
||
{ title: _('Apply Mode'), description: _('Network restarts, rollback timer starts') },
|
||
{ title: _('Confirm or Rollback'), description: _('2 minute confirmation window') }
|
||
], currentModeData.rollback_active ? 2 : 1);
|
||
|
||
var container = E('div', { 'class': 'network-modes-dashboard nm-wizard' }, [
|
||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||
E('link', { 'rel': 'stylesheet', 'href': L.resource('network-modes/common.css') }),
|
||
E('link', { 'rel': 'stylesheet', 'href': L.resource('network-modes/dashboard.css') }),
|
||
helpers.createNavigationTabs('wizard'),
|
||
this.renderStatusBadges(status, currentModeData),
|
||
hero,
|
||
stepper,
|
||
currentModeData.rollback_active ? this.renderRollbackBanner(currentModeData) : this.renderCurrentMode(currentModeData),
|
||
this.renderModesGrid(modes, currentModeData.current_mode),
|
||
this.renderInstructions()
|
||
]);
|
||
|
||
if (currentModeData.rollback_active) {
|
||
this.startRollbackPoll();
|
||
}
|
||
|
||
return container;
|
||
},
|
||
|
||
renderStatusBadges: function(status, currentMode) {
|
||
var chips = [
|
||
{ label: _('Version'), value: status.version || _('unknown') },
|
||
{ label: _('Mode'), value: currentMode.mode_name || currentMode.current_mode || _('–') },
|
||
{ label: _('WAN IP'), value: this.lookupInterfaceIp(status, 'wan') || _('Unknown') },
|
||
{ label: _('LAN IP'), value: this.lookupInterfaceIp(status, 'lan') || _('Unknown') }
|
||
];
|
||
return E('div', { 'class': 'nm-hero-meta', 'style': 'margin-bottom:12px;' }, chips.map(function(chip) {
|
||
return E('div', { 'class': 'nm-hero-chip' }, [
|
||
E('span', { 'class': 'nm-hero-chip-label' }, chip.label),
|
||
E('strong', {}, chip.value)
|
||
]);
|
||
}));
|
||
},
|
||
|
||
renderPendingModeCard: function(data, modes) {
|
||
if (!data.pending_mode)
|
||
return null;
|
||
var info = modes.find(function(mode) { return mode.id === data.pending_mode; }) || {
|
||
id: data.pending_mode,
|
||
name: data.pending_mode_name || data.pending_mode,
|
||
description: ''
|
||
};
|
||
return helpers.createSection({
|
||
title: _('Planned Mode'),
|
||
icon: '🗂️',
|
||
badge: info.name,
|
||
body: [
|
||
E('p', { 'style': 'color:#94a3b8;' }, info.description || _('Ready to apply when you need to test this template.')),
|
||
E('div', { 'class': 'nm-btn-group' }, [
|
||
E('button', {
|
||
'class': 'nm-btn',
|
||
'click': ui.createHandlerFn(this, 'handlePreviewPending', info)
|
||
}, '📝 ' + _('Review Changes')),
|
||
E('button', {
|
||
'class': 'nm-btn nm-btn-primary',
|
||
'click': ui.createHandlerFn(this, 'handleApplyPending', info)
|
||
}, '✅ ' + _('Apply Planned Mode'))
|
||
])
|
||
]
|
||
});
|
||
},
|
||
|
||
renderBackupCard: function(data) {
|
||
var lastBackup = data.last_backup_time || _('Not yet created');
|
||
if (!data.last_backup_time && !data.pending_mode)
|
||
return null;
|
||
return helpers.createSection({
|
||
title: _('Safety & Backups'),
|
||
icon: '💾',
|
||
body: [
|
||
E('p', {}, _('Latest snapshot: ') + (data.last_backup_time || _('Not available'))),
|
||
data.pending_mode ? E('p', { 'style': 'color:#94a3b8;' },
|
||
_('Applying the planned mode will create a fresh backup before changes.')) : null,
|
||
E('div', { 'style': 'margin-top:10px;font-family:monospace;font-size:12px;' }, data.last_backup || '')
|
||
]
|
||
});
|
||
},
|
||
|
||
renderCurrentMode: function(data) {
|
||
return helpers.createSection({
|
||
title: _('Current Mode'),
|
||
icon: '✅',
|
||
badge: data.current_mode || '--',
|
||
body: [
|
||
E('p', { 'style': 'color:#94a3b8;' }, [
|
||
E('strong', {}, data.mode_name || data.current_mode),
|
||
E('br'),
|
||
E('span', {}, data.description || ''),
|
||
E('br'),
|
||
E('span', { 'style': 'font-size:12px;' }, _('Last change: ') + (data.last_change || _('Never')))
|
||
])
|
||
]
|
||
});
|
||
},
|
||
|
||
renderRollbackBanner: function(data) {
|
||
var remaining = data.rollback_remaining || 0;
|
||
var minutes = Math.floor(remaining / 60);
|
||
var seconds = remaining % 60;
|
||
return E('div', {
|
||
'class': 'nm-card',
|
||
'style': 'background:rgba(245,158,11,.15);border:1px solid rgba(245,158,11,.4);'
|
||
}, [
|
||
E('div', { 'class': 'nm-card-header' }, [
|
||
E('div', { 'class': 'nm-card-title' }, [
|
||
E('span', { 'class': 'nm-card-title-icon' }, '⏱️'),
|
||
_('Auto-Rollback Active')
|
||
]),
|
||
E('div', { 'class': 'nm-card-badge' }, data.current_mode || '')
|
||
]),
|
||
E('div', { 'class': 'nm-card-body' }, [
|
||
E('div', { 'id': 'rollback-timer', 'style': 'font-size:20px;font-weight:600;margin-bottom:8px;' },
|
||
_('Time left: ') + minutes + 'm ' + seconds + 's'),
|
||
E('p', {}, _('Confirm the mode if the network is stable, otherwise rollback will trigger automatically.')),
|
||
E('div', { 'class': 'nm-btn-group' }, [
|
||
E('button', { 'class': 'nm-btn nm-btn-primary', 'click': ui.createHandlerFn(this, 'handleConfirmMode') }, '✅ ' + _('Confirm Mode')),
|
||
E('button', { 'class': 'nm-btn', 'click': ui.createHandlerFn(this, 'handleRollbackNow') }, '↩ ' + _('Rollback Now'))
|
||
])
|
||
])
|
||
]);
|
||
},
|
||
|
||
renderModesGrid: function(modes, currentMode) {
|
||
var grid = E('div', { 'class': 'nm-modes-grid nm-wizard-grid' });
|
||
modes.forEach(L.bind(function(mode) {
|
||
grid.appendChild(this.renderModeCard(mode, currentMode));
|
||
}, this));
|
||
return grid;
|
||
},
|
||
|
||
renderModeCard: function(mode, currentMode) {
|
||
var isCurrent = mode.id === currentMode;
|
||
var card = E('div', {
|
||
'class': 'nm-mode-card wizard-card' + (isCurrent ? ' active' : '')
|
||
}, [
|
||
E('div', { 'class': 'nm-mode-header' }, [
|
||
E('div', { 'class': 'nm-mode-icon' }, mode.icon || '🌐'),
|
||
E('div', { 'class': 'nm-mode-title' }, [
|
||
E('h3', {}, mode.name),
|
||
E('p', {}, mode.description || '')
|
||
])
|
||
]),
|
||
E('div', { 'class': 'nm-mode-features' }, (mode.features || []).slice(0, 4).map(function(feature) {
|
||
return E('span', { 'class': 'nm-mode-feature' }, ['✓ ', feature]);
|
||
})),
|
||
isCurrent ?
|
||
E('div', { 'class': 'nm-mode-active-indicator' }, _('Active Mode')) :
|
||
E('button', {
|
||
'class': 'nm-btn nm-btn-primary',
|
||
'type': 'button',
|
||
'click': ui.createHandlerFn(this, 'handleSwitchMode', mode)
|
||
}, _('Switch to ') + mode.name)
|
||
]);
|
||
|
||
return card;
|
||
},
|
||
|
||
renderInstructions: function() {
|
||
return helpers.createSection({
|
||
title: _('Wizard Guidance'),
|
||
icon: '🧭',
|
||
body: [
|
||
E('ol', { 'style': 'color:#94a3b8;line-height:1.8;margin-left:16px;' }, [
|
||
E('li', {}, _('Select the target mode from the cards above.')),
|
||
E('li', {}, _('Review configuration diffs + services impacted.')),
|
||
E('li', {}, _('Apply mode; device takes a snapshot and restarts network.')),
|
||
E('li', {}, _('Confirm within 2 minutes or automatic rollback restores previous state.'))
|
||
])
|
||
]
|
||
});
|
||
},
|
||
|
||
handleSwitchMode: function(mode) {
|
||
ui.showModal(_('Switch to ') + mode.name, [
|
||
E('p', { 'class': 'spinning' }, _('Preparing mode change...'))
|
||
]);
|
||
|
||
return callSetMode(mode.id).then(L.bind(function(result) {
|
||
if (!result.success) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', {}, result.error || _('Failed to prepare mode')), 'error');
|
||
return;
|
||
}
|
||
|
||
return callPreviewChanges().then(L.bind(function(preview) {
|
||
this.showPreviewModal(mode, preview);
|
||
}, this));
|
||
}, this)).catch(function(err) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', {}, err.message || err), 'error');
|
||
});
|
||
},
|
||
|
||
showPreviewModal: function(mode, preview) {
|
||
if (!preview.success) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', {}, preview.error || _('Preview failed')), 'error');
|
||
return;
|
||
}
|
||
|
||
var content = [
|
||
E('h4', {}, _('Changes to apply')),
|
||
E('p', {}, preview.current_mode + ' → ' + preview.target_mode),
|
||
E('ul', {},
|
||
(preview.changes || []).map(function(change) {
|
||
return E('li', {}, [
|
||
E('strong', {}, change.file + ': '),
|
||
E('span', {}, change.change)
|
||
]);
|
||
})
|
||
)
|
||
];
|
||
|
||
if (preview.warnings && preview.warnings.length) {
|
||
content.push(E('div', {
|
||
'class': 'alert-message warning',
|
||
'style': 'margin-top:12px;'
|
||
}, [
|
||
E('strong', {}, _('Warnings')),
|
||
E('ul', {}, preview.warnings.map(function(w) { return E('li', {}, w); }))
|
||
]));
|
||
}
|
||
|
||
content.push(E('div', { 'class': 'right', 'style': 'margin-top:16px;' }, [
|
||
E('button', {
|
||
'class': 'nm-btn',
|
||
'click': function() { ui.hideModal(); window.location.reload(); }
|
||
}, _('Cancel')),
|
||
' ',
|
||
E('button', { 'class': 'nm-btn nm-btn-primary', 'click': L.bind(this.handleApplyMode, this, mode) }, _('Apply Mode'))
|
||
]));
|
||
|
||
ui.showModal(_('Preview: ') + mode.name, content);
|
||
},
|
||
|
||
handleApplyMode: function(mode) {
|
||
ui.showModal(_('Applying ') + mode.name, [
|
||
E('p', { 'class': 'spinning' }, _('Updating network configuration…'))
|
||
]);
|
||
|
||
return callApplyMode().then(function(result) {
|
||
ui.hideModal();
|
||
if (!result.success) {
|
||
ui.addNotification(null, E('p', {}, result.error || _('Failed to apply mode')), 'error');
|
||
return;
|
||
}
|
||
|
||
ui.showModal(_('Mode applied'), [
|
||
E('p', {}, _('Network restarting. If you lose connectivity, reconnect to the new subnet.')),
|
||
E('p', {}, _('Confirm within 2 minutes to keep this mode.')),
|
||
E('div', { 'class': 'right', 'style': 'margin-top:12px;' }, [
|
||
E('button', {
|
||
'class': 'nm-btn nm-btn-primary',
|
||
'click': function() {
|
||
ui.hideModal();
|
||
window.location.reload();
|
||
}
|
||
}, _('Reload view'))
|
||
])
|
||
]);
|
||
}).catch(function(err) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', {}, err.message || err), 'error');
|
||
});
|
||
},
|
||
|
||
handleConfirmMode: function() {
|
||
return callConfirmMode().then(function(result) {
|
||
if (result.success) {
|
||
ui.addNotification(null, E('p', {}, result.message || _('Mode confirmed')), 'info');
|
||
setTimeout(function() { window.location.reload(); }, 1000);
|
||
} else {
|
||
ui.addNotification(null, E('p', {}, result.error || _('Confirmation failed')), 'error');
|
||
}
|
||
}).catch(function(err) {
|
||
ui.addNotification(null, E('p', {}, err.message || err), 'error');
|
||
});
|
||
},
|
||
|
||
handleRollbackNow: function() {
|
||
ui.showModal(_('Rollback...'), [
|
||
E('p', { 'class': 'spinning' }, _('Restoring previous configuration...'))
|
||
]);
|
||
|
||
return callRollback().then(function(result) {
|
||
ui.hideModal();
|
||
if (result.success) {
|
||
ui.addNotification(null, E('p', {}, result.message || _('Rollback started')), 'info');
|
||
setTimeout(function() { window.location.reload(); }, 1500);
|
||
} else {
|
||
ui.addNotification(null, E('p', {}, result.error || _('Rollback failed')), 'error');
|
||
}
|
||
}).catch(function(err) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', {}, err.message || err), 'error');
|
||
});
|
||
},
|
||
|
||
startRollbackPoll: function() {
|
||
poll.add(L.bind(function() {
|
||
return callGetCurrentMode().then(L.bind(function(data) {
|
||
if (!data.rollback_active) {
|
||
poll.stop();
|
||
window.location.reload();
|
||
return;
|
||
}
|
||
|
||
var timerElem = document.getElementById('rollback-timer');
|
||
if (timerElem) {
|
||
var remaining = data.rollback_remaining || 0;
|
||
var minutes = Math.floor(remaining / 60);
|
||
var seconds = remaining % 60;
|
||
timerElem.textContent = _('Time left: ') + minutes + 'm ' + seconds + 's';
|
||
}
|
||
}, this));
|
||
}, this), 1);
|
||
},
|
||
|
||
handleSaveApply: null,
|
||
handleSave: null,
|
||
handleReset: null,
|
||
|
||
handlePreviewPending: function(modeInfo) {
|
||
ui.showModal(_('Previewing pending mode…'), [
|
||
E('p', { 'class': 'spinning' }, _('Loading diff preview'))
|
||
]);
|
||
return callPreviewChanges().then(L.bind(function(preview) {
|
||
this.showPreviewModal(modeInfo, preview);
|
||
}, this)).catch(function(err) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', {}, err.message || err), 'error');
|
||
});
|
||
},
|
||
|
||
handleApplyPending: function(modeInfo) {
|
||
return this.handleApplyMode(modeInfo);
|
||
},
|
||
|
||
lookupInterfaceIp: function(status, match) {
|
||
var ifaces = (status && status.interfaces) || [];
|
||
var iface = ifaces.find(function(item) {
|
||
return item.name === match || item.name === (match === 'lan' ? 'br-lan' : match);
|
||
});
|
||
return iface && iface.ip ? iface.ip : '';
|
||
}
|
||
});
|