feat(luci): Add provisioning and ttyd deployment UI
Config Vault Dashboard (overview.js): - "Device Provisioning" card with 3 action buttons - Provision Remote: Modal to push clone to remote node - Serve via HTTP: Generate clone for HTTP download - Restore All: Confirmation modal to restore all modules - RPC calls: provision, serve_clone, restore_all RTTY Remote Dashboard (dashboard.js): - "Deploy ttyd to All" global button in controls - Per-node "ttyd" button in Connected Nodes table - Confirmation modal for bulk deployment - Progress spinner and result display - RPC calls: deploy_ttyd, install_remote Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e86545bd3a
commit
c80b10f18d
@ -5253,3 +5253,9 @@ git checkout HEAD -- index.html
|
|||||||
- CLI commands: restore-all, provision, pull-config, serve-clone
|
- CLI commands: restore-all, provision, pull-config, serve-clone
|
||||||
- 6 new RPCD methods: restore_all, import_apply, provision, pull_config, export_clone_b64, serve_clone
|
- 6 new RPCD methods: restore_all, import_apply, provision, pull_config, export_clone_b64, serve_clone
|
||||||
- Use case: Zero-touch provisioning of new SecuBox devices from master configuration
|
- Use case: Zero-touch provisioning of new SecuBox devices from master configuration
|
||||||
|
|
||||||
|
- **LuCI Provisioning & ttyd Deployment UI (Complete)**
|
||||||
|
- Config Vault Dashboard: "Device Provisioning" card with Provision Remote, Serve via HTTP, Restore All buttons
|
||||||
|
- RTTY Remote Dashboard: "Deploy ttyd to All" button and per-node ttyd button in actions column
|
||||||
|
- Modal dialogs for confirmation, progress, and result display
|
||||||
|
- Full mesh provisioning workflow now accessible via web UI
|
||||||
|
|||||||
@ -10,6 +10,20 @@ _Last updated: 2026-03-16 (DPI LAN Passive Analysis)_
|
|||||||
|
|
||||||
### 2026-03-16
|
### 2026-03-16
|
||||||
|
|
||||||
|
- **LuCI Provisioning Dashboard (Complete)**
|
||||||
|
- Config Vault dashboard: "Device Provisioning" card with 3 action buttons
|
||||||
|
- "Provision Remote" - Modal dialog to push clone to remote node
|
||||||
|
- "Serve via HTTP" - Generate clone for HTTP download, shows URL
|
||||||
|
- "Restore All" - Confirmation modal to restore all modules from vault
|
||||||
|
- Full provisioning workflow accessible from web UI
|
||||||
|
|
||||||
|
- **LuCI Deploy ttyd Button (Complete)**
|
||||||
|
- RTTY Remote Control dashboard: "Deploy ttyd to All" global button
|
||||||
|
- Per-node "ttyd" button in Connected Nodes table
|
||||||
|
- Confirmation modal for bulk deployment
|
||||||
|
- Progress spinner and result display
|
||||||
|
- Enables web terminal deployment to mesh nodes via UI
|
||||||
|
|
||||||
- **Device Provisioning System (Complete)**
|
- **Device Provisioning System (Complete)**
|
||||||
- **Auto-Restore**: `configvaultctl import-clone <file> --apply` auto-restores all modules
|
- **Auto-Restore**: `configvaultctl import-clone <file> --apply` auto-restores all modules
|
||||||
- **Remote Provisioning**: `configvaultctl provision <node|all>` pushes clone to remote nodes
|
- **Remote Provisioning**: `configvaultctl provision <node|all>` pushes clone to remote nodes
|
||||||
@ -645,8 +659,7 @@ _Last updated: 2026-03-16 (DPI LAN Passive Analysis)_
|
|||||||
|
|
||||||
### v1.0 Release Prep
|
### v1.0 Release Prep
|
||||||
|
|
||||||
1. **LuCI Provisioning Dashboard** - Add provisioning UI to Config Vault dashboard (optional)
|
All core features complete. Optional polish tasks remain.
|
||||||
2. **LuCI Remote Install Button** - Add "Deploy ttyd" action to Remote Control dashboard (optional)
|
|
||||||
|
|
||||||
### v1.1+ Extended Mesh
|
### v1.1+ Extended Mesh
|
||||||
|
|
||||||
|
|||||||
@ -56,6 +56,26 @@ var callExportClone = rpc.declare({
|
|||||||
expect: {}
|
expect: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var callProvision = rpc.declare({
|
||||||
|
object: 'luci.config-vault',
|
||||||
|
method: 'provision',
|
||||||
|
params: ['target', 'clone_file'],
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var callServeClone = rpc.declare({
|
||||||
|
object: 'luci.config-vault',
|
||||||
|
method: 'serve_clone',
|
||||||
|
params: ['output_dir'],
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var callRestoreAll = rpc.declare({
|
||||||
|
object: 'luci.config-vault',
|
||||||
|
method: 'restore_all',
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
// KissTheme helper
|
// KissTheme helper
|
||||||
var KissTheme = {
|
var KissTheme = {
|
||||||
colors: {
|
colors: {
|
||||||
@ -267,6 +287,162 @@ return view.extend({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleProvision: function() {
|
||||||
|
var self = this;
|
||||||
|
var targetInput;
|
||||||
|
|
||||||
|
ui.showModal('Provision Remote Node', [
|
||||||
|
E('div', { 'style': 'margin-bottom:1rem;' }, [
|
||||||
|
E('p', { 'style': 'margin-bottom:0.5rem;color:#888;' }, 'Push configuration clone to a remote SecuBox node:'),
|
||||||
|
E('div', { 'style': 'display:flex;gap:0.5rem;align-items:center;' }, [
|
||||||
|
targetInput = E('input', {
|
||||||
|
'type': 'text',
|
||||||
|
'placeholder': 'IP address or hostname (e.g., 192.168.255.2)',
|
||||||
|
'style': 'flex:1;padding:0.5rem;border-radius:6px;border:1px solid rgba(255,255,255,0.1);background:#0a0a0f;color:#f0f2ff;'
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
E('p', { 'style': 'margin-top:0.5rem;font-size:0.75rem;color:#666;' },
|
||||||
|
'Enter "all" to provision all mesh nodes.')
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'right', 'style': 'margin-top:1rem;display:flex;gap:0.5rem;justify-content:flex-end;' }, [
|
||||||
|
E('button', {
|
||||||
|
'class': 'cbi-button',
|
||||||
|
'click': ui.hideModal
|
||||||
|
}, 'Cancel'),
|
||||||
|
E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-positive',
|
||||||
|
'click': function() {
|
||||||
|
var target = targetInput.value.trim();
|
||||||
|
if (!target) {
|
||||||
|
ui.addNotification(null, E('p', {}, 'Please enter a target node'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.hideModal();
|
||||||
|
self.doProvision(target);
|
||||||
|
}
|
||||||
|
}, 'Provision')
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
doProvision: function(target) {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
ui.showModal('Provisioning ' + target + '...', [
|
||||||
|
E('p', { 'class': 'spinning' }, 'Creating clone and pushing to remote node...')
|
||||||
|
]);
|
||||||
|
|
||||||
|
callProvision(target, '').then(function(res) {
|
||||||
|
ui.hideModal();
|
||||||
|
if (res.success) {
|
||||||
|
ui.showModal('Provisioning Complete', [
|
||||||
|
E('div', { 'style': 'text-align:center;' }, [
|
||||||
|
E('div', { 'style': 'font-size:3rem;margin-bottom:1rem;' }, '🚀'),
|
||||||
|
E('p', {}, 'Configuration pushed to ' + target),
|
||||||
|
E('pre', {
|
||||||
|
'style': 'text-align:left;background:#0a0a0f;padding:1rem;border-radius:8px;max-height:200px;overflow-y:auto;font-size:0.75rem;color:#888;'
|
||||||
|
}, res.output || 'Provisioning successful')
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'right', 'style': 'margin-top:1rem;' }, [
|
||||||
|
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, 'Close')
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
ui.addNotification(null, E('p', {}, 'Provisioning failed: ' + (res.error || res.output || 'Unknown error')), 'error');
|
||||||
|
}
|
||||||
|
}).catch(function(err) {
|
||||||
|
ui.hideModal();
|
||||||
|
ui.addNotification(null, E('p', {}, 'Provisioning error: ' + err.message), 'error');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
handleServeClone: function() {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
ui.showModal('Serving Clone via HTTP...', [
|
||||||
|
E('p', { 'class': 'spinning' }, 'Generating clone for HTTP download...')
|
||||||
|
]);
|
||||||
|
|
||||||
|
callServeClone('/www/config-vault').then(function(res) {
|
||||||
|
ui.hideModal();
|
||||||
|
if (res.success) {
|
||||||
|
var hostname = window.location.hostname;
|
||||||
|
var cloneUrl = 'http://' + hostname + '/config-vault/clone.tar.gz';
|
||||||
|
|
||||||
|
ui.showModal('Clone Available via HTTP', [
|
||||||
|
E('div', { 'style': 'text-align:center;' }, [
|
||||||
|
E('div', { 'style': 'font-size:3rem;margin-bottom:1rem;' }, '🌐'),
|
||||||
|
E('p', {}, 'Clone is now available for HTTP download:'),
|
||||||
|
E('p', {
|
||||||
|
'style': 'font-family:monospace;background:#0a0a0f;padding:0.75rem;border-radius:6px;margin:1rem 0;word-break:break-all;color:#06b6d4;'
|
||||||
|
}, cloneUrl),
|
||||||
|
E('p', { 'style': 'font-size:0.8rem;color:#888;margin-top:1rem;' }, [
|
||||||
|
'New devices can pull this config with:',
|
||||||
|
E('br'),
|
||||||
|
E('code', { 'style': 'color:#6366f1;' }, 'configvaultctl pull-config ' + hostname)
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'right', 'style': 'margin-top:1rem;' }, [
|
||||||
|
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, 'Close')
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
ui.addNotification(null, E('p', {}, 'Serve failed: ' + (res.output || 'Unknown error')), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
handleRestoreAll: function() {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
ui.showModal('Confirm Restore All', [
|
||||||
|
E('div', { 'style': 'text-align:center;' }, [
|
||||||
|
E('div', { 'style': 'font-size:3rem;margin-bottom:1rem;' }, '⚠️'),
|
||||||
|
E('p', { 'style': 'color:#f97316;font-weight:600;' }, 'This will restore ALL modules from the vault!'),
|
||||||
|
E('p', { 'style': 'color:#888;' }, 'Current configurations will be overwritten.')
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'right', 'style': 'margin-top:1rem;display:flex;gap:0.5rem;justify-content:flex-end;' }, [
|
||||||
|
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, 'Cancel'),
|
||||||
|
E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-negative',
|
||||||
|
'click': function() {
|
||||||
|
ui.hideModal();
|
||||||
|
self.doRestoreAll();
|
||||||
|
}
|
||||||
|
}, 'Restore All')
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
doRestoreAll: function() {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
ui.showModal('Restoring All Modules...', [
|
||||||
|
E('p', { 'class': 'spinning' }, 'Applying configurations from vault...')
|
||||||
|
]);
|
||||||
|
|
||||||
|
callRestoreAll().then(function(res) {
|
||||||
|
ui.hideModal();
|
||||||
|
if (res.success) {
|
||||||
|
ui.showModal('Restore Complete', [
|
||||||
|
E('div', { 'style': 'text-align:center;' }, [
|
||||||
|
E('div', { 'style': 'font-size:3rem;margin-bottom:1rem;' }, '✅'),
|
||||||
|
E('p', {}, 'All modules restored successfully!'),
|
||||||
|
E('p', { 'style': 'color:#888;font-size:0.85rem;' }, 'A reboot may be required to apply all changes.'),
|
||||||
|
E('pre', {
|
||||||
|
'style': 'text-align:left;background:#0a0a0f;padding:1rem;border-radius:8px;max-height:150px;overflow-y:auto;font-size:0.75rem;color:#888;'
|
||||||
|
}, res.output || '')
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'right', 'style': 'margin-top:1rem;' }, [
|
||||||
|
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, 'Close')
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
ui.addNotification(null, E('p', {}, 'Restore failed: ' + (res.output || 'Unknown error')), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
renderContent: function(data) {
|
renderContent: function(data) {
|
||||||
var status = data[0] || {};
|
var status = data[0] || {};
|
||||||
var modulesData = data[1] || {};
|
var modulesData = data[1] || {};
|
||||||
@ -287,7 +463,7 @@ return view.extend({
|
|||||||
KissTheme.statCard('⚠️', status.uncommitted || 0, 'Uncommitted', status.uncommitted > 0 ? 'yellow' : 'green')
|
KissTheme.statCard('⚠️', status.uncommitted || 0, 'Uncommitted', status.uncommitted > 0 ? 'yellow' : 'green')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Quick Actions
|
// Quick Actions - Backup & Sync
|
||||||
var actionsContent = E('div', {
|
var actionsContent = E('div', {
|
||||||
'style': 'display:flex;gap:1rem;flex-wrap:wrap;'
|
'style': 'display:flex;gap:1rem;flex-wrap:wrap;'
|
||||||
}, [
|
}, [
|
||||||
@ -298,6 +474,15 @@ return view.extend({
|
|||||||
!status.initialized ? KissTheme.actionBtn('Initialize Vault', '🚀', 'red', function() { self.handleInit(); }) : ''
|
!status.initialized ? KissTheme.actionBtn('Initialize Vault', '🚀', 'red', function() { self.handleInit(); }) : ''
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Provisioning Actions
|
||||||
|
var provisionContent = E('div', {
|
||||||
|
'style': 'display:flex;gap:1rem;flex-wrap:wrap;'
|
||||||
|
}, [
|
||||||
|
KissTheme.actionBtn('Provision Remote', '🚀', 'cyan', function() { self.handleProvision(); }),
|
||||||
|
KissTheme.actionBtn('Serve via HTTP', '🌐', 'green', function() { self.handleServeClone(); }),
|
||||||
|
KissTheme.actionBtn('Restore All', '🔄', 'orange', function() { self.handleRestoreAll(); })
|
||||||
|
]);
|
||||||
|
|
||||||
// Modules table
|
// Modules table
|
||||||
var modulesTable = E('table', {
|
var modulesTable = E('table', {
|
||||||
'style': 'width:100%;border-collapse:collapse;font-size:0.9rem;'
|
'style': 'width:100%;border-collapse:collapse;font-size:0.9rem;'
|
||||||
@ -369,6 +554,8 @@ return view.extend({
|
|||||||
|
|
||||||
KissTheme.card('Quick Actions', '⚡', actionsContent),
|
KissTheme.card('Quick Actions', '⚡', actionsContent),
|
||||||
|
|
||||||
|
KissTheme.card('Device Provisioning', '🚀', provisionContent),
|
||||||
|
|
||||||
KissTheme.card('Repository Info', '📊', gitInfo),
|
KissTheme.card('Repository Info', '📊', gitInfo),
|
||||||
|
|
||||||
KissTheme.card('Modules', '📦', modulesTable, { text: modules.length + ' configured', color: 'purple' }),
|
KissTheme.card('Modules', '📦', modulesTable, { text: modules.length + ' configured', color: 'purple' }),
|
||||||
|
|||||||
@ -51,6 +51,20 @@ var callRpcCall = rpc.declare({
|
|||||||
expect: {}
|
expect: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var callDeployTtyd = rpc.declare({
|
||||||
|
object: 'luci.rtty-remote',
|
||||||
|
method: 'deploy_ttyd',
|
||||||
|
params: ['target'],
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var callInstallRemote = rpc.declare({
|
||||||
|
object: 'luci.rtty-remote',
|
||||||
|
method: 'install_remote',
|
||||||
|
params: ['node_id', 'app_id'],
|
||||||
|
expect: {}
|
||||||
|
});
|
||||||
|
|
||||||
return view.extend({
|
return view.extend({
|
||||||
handleSaveApply: null,
|
handleSaveApply: null,
|
||||||
handleSave: null,
|
handleSave: null,
|
||||||
@ -124,7 +138,13 @@ return view.extend({
|
|||||||
'class': 'kiss-btn',
|
'class': 'kiss-btn',
|
||||||
'style': 'padding: 4px 10px; font-size: 11px;',
|
'style': 'padding: 4px 10px; font-size: 11px;',
|
||||||
'click': function() { self.handleConnect(node); }
|
'click': function() { self.handleConnect(node); }
|
||||||
}, 'Term')
|
}, 'Term'),
|
||||||
|
E('button', {
|
||||||
|
'class': 'kiss-btn kiss-btn-green',
|
||||||
|
'style': 'padding: 4px 10px; font-size: 11px;',
|
||||||
|
'title': 'Deploy ttyd web terminal',
|
||||||
|
'click': function() { self.handleDeployTtydToNode(node); }
|
||||||
|
}, 'ttyd')
|
||||||
]))
|
]))
|
||||||
]);
|
]);
|
||||||
}))
|
}))
|
||||||
@ -203,14 +223,18 @@ return view.extend({
|
|||||||
var self = this;
|
var self = this;
|
||||||
var isRunning = status.running;
|
var isRunning = status.running;
|
||||||
|
|
||||||
return E('div', { 'style': 'display: flex; gap: 12px;' }, [
|
return E('div', { 'style': 'display: flex; gap: 12px; flex-wrap: wrap;' }, [
|
||||||
isRunning ? E('button', {
|
isRunning ? E('button', {
|
||||||
'class': 'kiss-btn kiss-btn-red',
|
'class': 'kiss-btn kiss-btn-red',
|
||||||
'click': function() { self.handleServerStop(); }
|
'click': function() { self.handleServerStop(); }
|
||||||
}, 'Stop Server') : E('button', {
|
}, 'Stop Server') : E('button', {
|
||||||
'class': 'kiss-btn kiss-btn-green',
|
'class': 'kiss-btn kiss-btn-green',
|
||||||
'click': function() { self.handleServerStart(); }
|
'click': function() { self.handleServerStart(); }
|
||||||
}, 'Start Server')
|
}, 'Start Server'),
|
||||||
|
E('button', {
|
||||||
|
'class': 'kiss-btn kiss-btn-blue',
|
||||||
|
'click': function() { self.handleDeployTtydAll(); }
|
||||||
|
}, '🖥️ Deploy ttyd to All')
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -284,6 +308,63 @@ return view.extend({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleDeployTtyd: function(target) {
|
||||||
|
var self = this;
|
||||||
|
var targetName = target || 'all nodes';
|
||||||
|
|
||||||
|
ui.showModal('Deploying ttyd...', [
|
||||||
|
E('p', { 'class': 'spinning' }, 'Installing ttyd on ' + targetName + '...')
|
||||||
|
]);
|
||||||
|
|
||||||
|
callDeployTtyd(target || 'all').then(function(response) {
|
||||||
|
ui.hideModal();
|
||||||
|
if (response.success) {
|
||||||
|
ui.showModal('ttyd Deployed', [
|
||||||
|
E('div', { 'style': 'text-align: center;' }, [
|
||||||
|
E('div', { 'style': 'font-size: 48px; margin-bottom: 16px;' }, '🖥️'),
|
||||||
|
E('p', { 'style': 'margin-bottom: 12px;' }, 'ttyd web terminal deployed successfully!'),
|
||||||
|
E('pre', {
|
||||||
|
'style': 'text-align: left; background: var(--kiss-bg); padding: 12px; border-radius: 6px; font-size: 12px; max-height: 200px; overflow-y: auto; color: var(--kiss-muted);'
|
||||||
|
}, response.output || 'Deployment complete')
|
||||||
|
]),
|
||||||
|
E('div', { 'style': 'text-align: right; margin-top: 16px;' }, [
|
||||||
|
E('button', { 'class': 'kiss-btn', 'click': ui.hideModal }, 'Close')
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
ui.addNotification(null, E('p', 'Deployment failed: ' + (response.error || response.output || 'Unknown error')), 'error');
|
||||||
|
}
|
||||||
|
}).catch(function(err) {
|
||||||
|
ui.hideModal();
|
||||||
|
ui.addNotification(null, E('p', 'Deployment error: ' + err.message), 'error');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
handleDeployTtydToNode: function(node) {
|
||||||
|
this.handleDeployTtyd(node.address || node.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleDeployTtydAll: function() {
|
||||||
|
var self = this;
|
||||||
|
ui.showModal('Confirm Deploy All', [
|
||||||
|
E('div', { 'style': 'text-align: center;' }, [
|
||||||
|
E('div', { 'style': 'font-size: 48px; margin-bottom: 16px;' }, '🖥️'),
|
||||||
|
E('p', {}, 'Deploy ttyd web terminal to ALL mesh nodes?'),
|
||||||
|
E('p', { 'style': 'color: var(--kiss-muted); font-size: 12px;' }, 'This will install ttyd and start the service on each node.')
|
||||||
|
]),
|
||||||
|
E('div', { 'style': 'display: flex; gap: 12px; justify-content: center; margin-top: 16px;' }, [
|
||||||
|
E('button', { 'class': 'kiss-btn', 'click': ui.hideModal }, 'Cancel'),
|
||||||
|
E('button', {
|
||||||
|
'class': 'kiss-btn kiss-btn-green',
|
||||||
|
'click': function() {
|
||||||
|
ui.hideModal();
|
||||||
|
self.handleDeployTtyd('all');
|
||||||
|
}
|
||||||
|
}, 'Deploy All')
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
render: function(data) {
|
render: function(data) {
|
||||||
var self = this;
|
var self = this;
|
||||||
var status = data[0] || {};
|
var status = data[0] || {};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user