## New Features - secubox-app-yggdrasil-discovery: Mesh peer discovery via gossip protocol - yggctl CLI: status, self, peers, announce, discover, bootstrap - Auto-peering with trust verification (master-link fingerprint) - Daemon for periodic announcements ## Bug Fixes - tor-shield: Fix opkg downloads failing when Tor active - DNS over Tor disabled by default - Auto-exclude public DNS servers from iptables rules - Excluded domains bypass list (openwrt.org, pool.ntp.org, etc.) - haproxy: Fix portal 503 "End of Internet" error - Corrected malformed vhost backend configuration - Regenerated HAProxy config from UCI - luci-app-nextcloud: Fix users list showing empty - RPC expect clause was extracting array, render expected object ## Updated - Bonus feed: All IPKs rebuilt - Documentation: HISTORY.md, WIP.md, TODO.md updated Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
980 lines
31 KiB
JavaScript
980 lines
31 KiB
JavaScript
'use strict';
|
||
'require view';
|
||
'require dom';
|
||
'require ui';
|
||
'require rpc';
|
||
'require poll';
|
||
'require secubox/kiss-theme';
|
||
|
||
// ============================================================================
|
||
// RPC Declarations
|
||
// ============================================================================
|
||
|
||
var callStatus = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'status',
|
||
expect: {}
|
||
});
|
||
|
||
var callGetConfig = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'get_config',
|
||
expect: {}
|
||
});
|
||
|
||
var callInstall = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'install',
|
||
expect: {}
|
||
});
|
||
|
||
var callStart = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'start',
|
||
expect: {}
|
||
});
|
||
|
||
var callStop = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'stop',
|
||
expect: {}
|
||
});
|
||
|
||
var callRestart = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'restart',
|
||
expect: {}
|
||
});
|
||
|
||
var callUpdate = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'update',
|
||
expect: {}
|
||
});
|
||
|
||
var callBackup = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'backup',
|
||
params: ['name'],
|
||
expect: {}
|
||
});
|
||
|
||
var callRestore = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'restore',
|
||
params: ['name'],
|
||
expect: {}
|
||
});
|
||
|
||
var callListBackups = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'list_backups',
|
||
expect: {}
|
||
});
|
||
|
||
var callSSLEnable = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'ssl_enable',
|
||
params: ['domain'],
|
||
expect: {}
|
||
});
|
||
|
||
var callSSLDisable = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'ssl_disable',
|
||
expect: {}
|
||
});
|
||
|
||
var callLogs = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'logs',
|
||
expect: {}
|
||
});
|
||
|
||
var callGetStorage = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'get_storage',
|
||
expect: {}
|
||
});
|
||
|
||
var callDeleteBackup = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'delete_backup',
|
||
params: ['name'],
|
||
expect: {}
|
||
});
|
||
|
||
var callUninstall = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'uninstall',
|
||
expect: {}
|
||
});
|
||
|
||
var callListUsers = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'list_users',
|
||
expect: {}
|
||
});
|
||
|
||
var callResetPassword = rpc.declare({
|
||
object: 'luci.nextcloud',
|
||
method: 'reset_password',
|
||
params: ['uid', 'password'],
|
||
expect: {}
|
||
});
|
||
|
||
// ============================================================================
|
||
// Helpers
|
||
// ============================================================================
|
||
|
||
function fmtDate(timestamp) {
|
||
if (!timestamp) return '-';
|
||
var d = new Date(timestamp * 1000);
|
||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString().slice(0, 5);
|
||
}
|
||
|
||
function fmtRelative(timestamp) {
|
||
if (!timestamp) return '-';
|
||
var d = new Date(timestamp * 1000);
|
||
var now = new Date();
|
||
var diff = Math.floor((now - d) / 1000);
|
||
if (diff < 60) return diff + 's ago';
|
||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||
return Math.floor(diff / 86400) + 'd ago';
|
||
}
|
||
|
||
// ============================================================================
|
||
// Main View
|
||
// ============================================================================
|
||
|
||
return view.extend({
|
||
status: {},
|
||
config: {},
|
||
backups: [],
|
||
users: [],
|
||
storage: {},
|
||
currentTab: 'overview',
|
||
|
||
load: function() {
|
||
return Promise.all([
|
||
callStatus(),
|
||
callGetConfig(),
|
||
callListBackups().catch(function() { return { backups: [] }; }),
|
||
callListUsers().catch(function() { return { users: [] }; }),
|
||
callGetStorage().catch(function() { return {}; })
|
||
]);
|
||
},
|
||
|
||
render: function(data) {
|
||
var self = this;
|
||
this.status = data[0] || {};
|
||
this.config = data[1] || {};
|
||
this.backups = (data[2] || {}).backups || [];
|
||
this.users = (data[3] || {}).users || [];
|
||
this.storage = data[4] || {};
|
||
|
||
// Not installed - show install view
|
||
if (!this.status.installed) {
|
||
return this.renderInstallView();
|
||
}
|
||
|
||
// Tab navigation
|
||
var tabs = [
|
||
{ id: 'overview', label: 'Overview', icon: '🎛️' },
|
||
{ id: 'users', label: 'Users', icon: '👥' },
|
||
{ id: 'storage', label: 'Storage', icon: '💿' },
|
||
{ id: 'backups', label: 'Backups', icon: '💾' },
|
||
{ id: 'ssl', label: 'SSL', icon: '🔒' },
|
||
{ id: 'logs', label: 'Logs', icon: '📜' }
|
||
];
|
||
|
||
var content = [
|
||
// Header
|
||
E('div', { 'style': 'display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;' }, [
|
||
E('div', {}, [
|
||
E('h1', { 'style': 'font-size:28px;font-weight:700;margin:0;display:flex;align-items:center;gap:12px;' }, [
|
||
'☁️ Nextcloud'
|
||
]),
|
||
E('p', { 'style': 'color:var(--kiss-muted);margin:6px 0 0;' }, 'Self-hosted file sync and collaboration platform')
|
||
]),
|
||
E('div', { 'style': 'display:flex;gap:8px;' }, [
|
||
KissTheme.badge(this.status.version || 'N/A', 'blue'),
|
||
KissTheme.badge(this.status.running ? 'Running' : 'Stopped',
|
||
this.status.running ? 'green' : 'red')
|
||
])
|
||
]),
|
||
|
||
// Tab Navigation
|
||
E('div', { 'class': 'kiss-tabs', 'style': 'margin-bottom:20px;' },
|
||
tabs.map(function(tab) {
|
||
return E('button', {
|
||
'class': 'kiss-tab' + (self.currentTab === tab.id ? ' kiss-tab-active' : ''),
|
||
'data-tab': tab.id,
|
||
'click': function() { self.switchTab(tab.id); }
|
||
}, [tab.icon + ' ' + tab.label]);
|
||
})
|
||
),
|
||
|
||
// Tab Content
|
||
E('div', { 'id': 'tab-content' }, this.renderTabContent())
|
||
];
|
||
|
||
poll.add(L.bind(this.refresh, this), 10);
|
||
return KissTheme.wrap(content, 'admin/secubox/services/nextcloud');
|
||
},
|
||
|
||
switchTab: function(tabId) {
|
||
this.currentTab = tabId;
|
||
var tabContent = document.getElementById('tab-content');
|
||
if (tabContent) {
|
||
dom.content(tabContent, this.renderTabContent());
|
||
}
|
||
document.querySelectorAll('.kiss-tab').forEach(function(btn) {
|
||
btn.classList.toggle('kiss-tab-active', btn.dataset.tab === tabId);
|
||
});
|
||
},
|
||
|
||
renderTabContent: function() {
|
||
switch (this.currentTab) {
|
||
case 'users': return this.renderUsersTab();
|
||
case 'storage': return this.renderStorageTab();
|
||
case 'backups': return this.renderBackupsTab();
|
||
case 'ssl': return this.renderSSLTab();
|
||
case 'logs': return this.renderLogsTab();
|
||
default: return this.renderOverviewTab();
|
||
}
|
||
},
|
||
|
||
// ========================================================================
|
||
// Install View
|
||
// ========================================================================
|
||
|
||
renderInstallView: function() {
|
||
var self = this;
|
||
|
||
var content = [
|
||
E('div', { 'style': 'text-align:center;padding:60px 20px;' }, [
|
||
E('div', { 'style': 'font-size:80px;margin-bottom:24px;' }, '☁️'),
|
||
E('h1', { 'style': 'font-size:32px;margin:0 0 12px;' }, 'Nextcloud'),
|
||
E('p', { 'style': 'color:var(--kiss-muted);font-size:18px;max-width:500px;margin:0 auto 32px;' },
|
||
'Self-hosted productivity platform with file sync, calendar, contacts, and more.'),
|
||
|
||
E('div', { 'style': 'display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:16px;max-width:600px;margin:0 auto 40px;' }, [
|
||
this.featureCard('📁', 'File Sync'),
|
||
this.featureCard('📅', 'Calendar'),
|
||
this.featureCard('👥', 'Contacts'),
|
||
this.featureCard('📝', 'Documents'),
|
||
this.featureCard('📷', 'Photos'),
|
||
this.featureCard('🔐', 'E2E Encryption')
|
||
]),
|
||
|
||
E('div', { 'style': 'background:var(--kiss-bg2);border-radius:12px;padding:20px;max-width:500px;margin:0 auto 32px;text-align:left;' }, [
|
||
E('div', { 'style': 'font-weight:600;margin-bottom:12px;' }, '📦 What will be installed:'),
|
||
E('ul', { 'style': 'margin:0;padding-left:20px;color:var(--kiss-muted);font-size:14px;' }, [
|
||
E('li', {}, 'Debian 12 LXC container'),
|
||
E('li', {}, 'Nextcloud with PHP 8.2'),
|
||
E('li', {}, 'MariaDB database'),
|
||
E('li', {}, 'Redis caching'),
|
||
E('li', {}, 'Nginx web server')
|
||
])
|
||
]),
|
||
|
||
E('button', {
|
||
'class': 'kiss-btn kiss-btn-green',
|
||
'style': 'font-size:18px;padding:16px 48px;',
|
||
'click': function() { self.handleInstall(); }
|
||
}, '🚀 Install Nextcloud')
|
||
])
|
||
];
|
||
|
||
return KissTheme.wrap(content, 'admin/secubox/services/nextcloud');
|
||
},
|
||
|
||
featureCard: function(icon, label) {
|
||
return E('div', { 'style': 'background:var(--kiss-bg2);border-radius:10px;padding:16px;text-align:center;' }, [
|
||
E('div', { 'style': 'font-size:28px;margin-bottom:8px;' }, icon),
|
||
E('div', { 'style': 'font-size:13px;font-weight:500;' }, label)
|
||
]);
|
||
},
|
||
|
||
// ========================================================================
|
||
// Overview Tab
|
||
// ========================================================================
|
||
|
||
renderOverviewTab: function() {
|
||
var self = this;
|
||
var s = this.status;
|
||
|
||
return E('div', {}, [
|
||
// Stats Grid
|
||
E('div', { 'class': 'kiss-grid kiss-grid-4', 'style': 'margin-bottom:24px;' }, [
|
||
KissTheme.stat(s.running ? 'Online' : 'Offline', 'Status', s.running ? 'var(--kiss-green)' : 'var(--kiss-red)'),
|
||
KissTheme.stat(s.version || 'N/A', 'Version', 'var(--kiss-blue)'),
|
||
KissTheme.stat(s.user_count || 0, 'Users', 'var(--kiss-purple)'),
|
||
KissTheme.stat(s.disk_used || '0B', 'Storage', 'var(--kiss-cyan)')
|
||
]),
|
||
|
||
// Quick Actions
|
||
KissTheme.card([
|
||
E('span', {}, '⚡ Quick Actions')
|
||
], E('div', { 'style': 'display:flex;gap:12px;flex-wrap:wrap;' }, [
|
||
E('button', {
|
||
'class': 'kiss-btn ' + (s.running ? 'kiss-btn-red' : 'kiss-btn-green'),
|
||
'click': function() { self.handleToggle(); }
|
||
}, s.running ? ['⏹️ ', 'Stop'] : ['▶️ ', 'Start']),
|
||
E('button', {
|
||
'class': 'kiss-btn',
|
||
'click': function() { self.handleRestart(); },
|
||
'disabled': !s.running
|
||
}, ['🔄 ', 'Restart']),
|
||
s.web_accessible ? E('a', {
|
||
'href': s.web_url,
|
||
'target': '_blank',
|
||
'class': 'kiss-btn kiss-btn-blue'
|
||
}, ['🌐 ', 'Open Nextcloud']) : null,
|
||
E('button', {
|
||
'class': 'kiss-btn',
|
||
'click': function() { self.handleUpdate(); }
|
||
}, ['⬆️ ', 'Update']),
|
||
E('button', {
|
||
'class': 'kiss-btn',
|
||
'click': function() { self.handleQuickBackup(); }
|
||
}, ['💾 ', 'Backup Now'])
|
||
].filter(Boolean))),
|
||
|
||
// Service Info
|
||
KissTheme.card([
|
||
E('span', {}, 'ℹ️ Service Information')
|
||
], E('div', { 'class': 'kiss-grid kiss-grid-2', 'style': 'gap:16px;' }, [
|
||
E('div', { 'style': 'display:flex;flex-direction:column;gap:12px;' }, [
|
||
this.infoRow('Container', s.container_name || 'nextcloud'),
|
||
this.infoRow('HTTP Port', s.http_port || 8080),
|
||
this.infoRow('Domain', s.domain || 'cloud.local'),
|
||
this.infoRow('Data Path', s.data_path || '/srv/nextcloud')
|
||
]),
|
||
E('div', { 'style': 'display:flex;flex-direction:column;gap:12px;' }, [
|
||
this.infoRow('SSL', s.ssl_enabled ? '✅ Enabled' : '❌ Disabled'),
|
||
this.infoRow('SSL Domain', s.ssl_domain || '-'),
|
||
this.infoRow('Web Accessible', s.web_accessible ? '✅ Yes' : '❌ No'),
|
||
this.infoRow('Enabled', s.enabled ? '✅ Yes' : '❌ No')
|
||
])
|
||
])),
|
||
|
||
// Web Access Card
|
||
s.running && s.web_url ? KissTheme.card([
|
||
E('span', {}, '🌐 Web Interface')
|
||
], E('div', { 'style': 'display:flex;align-items:center;gap:16px;' }, [
|
||
E('div', { 'style': 'flex:1;' }, [
|
||
E('div', { 'style': 'font-size:14px;color:var(--kiss-muted);margin-bottom:4px;' }, 'Access your Nextcloud at:'),
|
||
E('div', { 'style': 'font-family:monospace;font-size:16px;color:var(--kiss-cyan);' }, s.web_url)
|
||
]),
|
||
E('a', {
|
||
'href': s.web_url,
|
||
'target': '_blank',
|
||
'class': 'kiss-btn kiss-btn-green'
|
||
}, ['🔗 ', 'Open'])
|
||
])) : null
|
||
].filter(Boolean));
|
||
},
|
||
|
||
infoRow: function(label, value) {
|
||
return E('div', { 'style': 'display:flex;justify-content:space-between;padding:8px;background:var(--kiss-bg2);border-radius:6px;' }, [
|
||
E('span', { 'style': 'color:var(--kiss-muted);' }, label),
|
||
E('span', { 'style': 'font-weight:500;' }, String(value))
|
||
]);
|
||
},
|
||
|
||
// ========================================================================
|
||
// Users Tab
|
||
// ========================================================================
|
||
|
||
renderUsersTab: function() {
|
||
var self = this;
|
||
|
||
return E('div', {}, [
|
||
// Users List
|
||
KissTheme.card([
|
||
E('span', {}, '👥 Nextcloud Users'),
|
||
E('span', { 'style': 'margin-left:auto;font-size:12px;color:var(--kiss-muted);' }, this.users.length + ' users')
|
||
], E('div', { 'id': 'users-list' }, this.renderUsersList()))
|
||
]);
|
||
},
|
||
|
||
renderUsersList: function() {
|
||
var self = this;
|
||
|
||
if (!this.users.length) {
|
||
return E('div', { 'style': 'text-align:center;padding:40px;color:var(--kiss-muted);' }, [
|
||
E('div', { 'style': 'font-size:48px;margin-bottom:12px;' }, '👤'),
|
||
E('div', { 'style': 'font-size:16px;' }, 'No users found'),
|
||
E('div', { 'style': 'font-size:12px;margin-top:8px;' }, 'Container may not be running')
|
||
]);
|
||
}
|
||
|
||
return E('table', { 'class': 'kiss-table' }, [
|
||
E('thead', {}, E('tr', {}, [
|
||
E('th', {}, 'User ID'),
|
||
E('th', {}, 'Display Name'),
|
||
E('th', { 'style': 'width:120px;' }, 'Actions')
|
||
])),
|
||
E('tbody', {}, this.users.map(function(u) {
|
||
return E('tr', {}, [
|
||
E('td', { 'style': 'font-family:monospace;' }, u.uid || u),
|
||
E('td', {}, u.displayname || '-'),
|
||
E('td', {}, [
|
||
E('button', {
|
||
'class': 'kiss-btn',
|
||
'style': 'padding:4px 10px;font-size:11px;',
|
||
'title': 'Reset Password',
|
||
'data-uid': u.uid || u,
|
||
'click': function(ev) { self.showResetPasswordModal(ev.currentTarget.dataset.uid); }
|
||
}, '🔑')
|
||
])
|
||
]);
|
||
}))
|
||
]);
|
||
},
|
||
|
||
showResetPasswordModal: function(uid) {
|
||
var self = this;
|
||
var passwordInput, confirmInput;
|
||
|
||
ui.showModal('Reset Password - ' + uid, [
|
||
E('div', { 'style': 'padding:16px;' }, [
|
||
E('p', { 'style': 'margin-bottom:16px;color:var(--kiss-muted);' },
|
||
'Enter new password for user: ' + uid),
|
||
E('div', { 'style': 'margin-bottom:12px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:12px;color:var(--kiss-muted);margin-bottom:4px;' }, 'New Password'),
|
||
passwordInput = E('input', {
|
||
'type': 'password',
|
||
'style': 'width:100%;padding:10px;background:var(--kiss-bg2);border:1px solid var(--kiss-line);border-radius:6px;color:var(--kiss-text);'
|
||
})
|
||
]),
|
||
E('div', { 'style': 'margin-bottom:16px;' }, [
|
||
E('label', { 'style': 'display:block;font-size:12px;color:var(--kiss-muted);margin-bottom:4px;' }, 'Confirm Password'),
|
||
confirmInput = E('input', {
|
||
'type': 'password',
|
||
'style': 'width:100%;padding:10px;background:var(--kiss-bg2);border:1px solid var(--kiss-line);border-radius:6px;color:var(--kiss-text);'
|
||
})
|
||
]),
|
||
E('div', { 'style': 'display:flex;gap:8px;justify-content:flex-end;' }, [
|
||
E('button', {
|
||
'class': 'kiss-btn',
|
||
'click': ui.hideModal
|
||
}, 'Cancel'),
|
||
E('button', {
|
||
'class': 'kiss-btn kiss-btn-green',
|
||
'click': function() {
|
||
var password = passwordInput.value;
|
||
var confirm = confirmInput.value;
|
||
if (!password) {
|
||
ui.addNotification(null, E('p', 'Password required'), 'error');
|
||
return;
|
||
}
|
||
if (password !== confirm) {
|
||
ui.addNotification(null, E('p', 'Passwords do not match'), 'error');
|
||
return;
|
||
}
|
||
ui.hideModal();
|
||
self.handleResetPassword(uid, password);
|
||
}
|
||
}, 'Reset Password')
|
||
])
|
||
])
|
||
]);
|
||
},
|
||
|
||
handleResetPassword: function(uid, password) {
|
||
var self = this;
|
||
ui.showModal('Resetting Password', [
|
||
E('p', { 'class': 'spinning' }, 'Resetting password for ' + uid + '...')
|
||
]);
|
||
|
||
callResetPassword(uid, password).then(function(r) {
|
||
ui.hideModal();
|
||
if (r.success) {
|
||
ui.addNotification(null, E('p', 'Password reset for ' + uid), 'info');
|
||
} else {
|
||
ui.addNotification(null, E('p', 'Failed: ' + (r.error || 'Unknown error')), 'error');
|
||
}
|
||
}).catch(function(e) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', 'Error: ' + e.message), 'error');
|
||
});
|
||
},
|
||
|
||
// ========================================================================
|
||
// Storage Tab
|
||
// ========================================================================
|
||
|
||
renderStorageTab: function() {
|
||
var self = this;
|
||
var st = this.storage;
|
||
|
||
return E('div', {}, [
|
||
// Storage Stats
|
||
E('div', { 'class': 'kiss-grid kiss-grid-4', 'style': 'margin-bottom:24px;' }, [
|
||
KissTheme.stat(st.data_size || '0', 'User Data', 'var(--kiss-cyan)'),
|
||
KissTheme.stat(st.backup_size || '0', 'Backups', 'var(--kiss-purple)'),
|
||
KissTheme.stat(st.disk_free || '0', 'Disk Free', 'var(--kiss-green)'),
|
||
KissTheme.stat((st.disk_used_percent || 0) + '%', 'Disk Used', st.disk_used_percent > 80 ? 'var(--kiss-red)' : 'var(--kiss-blue)')
|
||
]),
|
||
|
||
// Disk Usage Bar
|
||
KissTheme.card([
|
||
E('span', {}, '💿 Disk Usage')
|
||
], E('div', {}, [
|
||
E('div', { 'style': 'display:flex;justify-content:space-between;margin-bottom:8px;font-size:13px;' }, [
|
||
E('span', {}, 'Used: ' + (st.disk_used_percent || 0) + '%'),
|
||
E('span', { 'style': 'color:var(--kiss-muted);' }, st.disk_free + ' free of ' + st.disk_total)
|
||
]),
|
||
E('div', { 'style': 'height:24px;background:var(--kiss-bg2);border-radius:12px;overflow:hidden;' }, [
|
||
E('div', {
|
||
'style': 'height:100%;width:' + (st.disk_used_percent || 0) + '%;background:linear-gradient(90deg, var(--kiss-cyan), var(--kiss-blue));transition:width 0.3s;'
|
||
})
|
||
])
|
||
])),
|
||
|
||
// Storage Details
|
||
KissTheme.card([
|
||
E('span', {}, '📊 Storage Breakdown')
|
||
], E('div', { 'style': 'display:flex;flex-direction:column;gap:12px;' }, [
|
||
this.storageRow('📁 User Data', st.data_size || '0', 'var(--kiss-cyan)'),
|
||
this.storageRow('💾 Backups', st.backup_size || '0', 'var(--kiss-purple)'),
|
||
this.storageRow('📦 Total Nextcloud', st.total_size || '0', 'var(--kiss-blue)')
|
||
])),
|
||
|
||
// Data Path Info
|
||
KissTheme.card([
|
||
E('span', {}, 'ℹ️ Data Location')
|
||
], E('div', { 'style': 'font-family:monospace;padding:12px;background:var(--kiss-bg2);border-radius:6px;' },
|
||
this.status.data_path || '/srv/nextcloud'
|
||
))
|
||
]);
|
||
},
|
||
|
||
storageRow: function(label, size, color) {
|
||
return E('div', { 'style': 'display:flex;justify-content:space-between;align-items:center;padding:10px;background:var(--kiss-bg2);border-radius:6px;' }, [
|
||
E('span', { 'style': 'display:flex;align-items:center;gap:8px;' }, label),
|
||
E('span', { 'style': 'font-weight:600;color:' + color + ';' }, size)
|
||
]);
|
||
},
|
||
|
||
// ========================================================================
|
||
// Backups Tab
|
||
// ========================================================================
|
||
|
||
renderBackupsTab: function() {
|
||
var self = this;
|
||
|
||
return E('div', {}, [
|
||
// Create Backup
|
||
KissTheme.card([
|
||
E('span', {}, '➕ Create Backup')
|
||
], E('div', { 'style': 'display:flex;gap:12px;align-items:center;' }, [
|
||
E('input', {
|
||
'id': 'backup-name',
|
||
'type': 'text',
|
||
'placeholder': 'Backup name (optional)',
|
||
'style': 'flex:1;padding:10px;background:var(--kiss-bg2);border:1px solid var(--kiss-line);border-radius:6px;color:var(--kiss-text);'
|
||
}),
|
||
E('button', {
|
||
'class': 'kiss-btn kiss-btn-green',
|
||
'click': function() {
|
||
var name = document.getElementById('backup-name')?.value || '';
|
||
self.handleBackup(name);
|
||
}
|
||
}, ['💾 ', 'Create Backup'])
|
||
])),
|
||
|
||
// Backup List
|
||
KissTheme.card([
|
||
E('span', {}, '📦 Available Backups'),
|
||
E('span', { 'style': 'margin-left:auto;font-size:12px;color:var(--kiss-muted);' }, this.backups.length + ' backups')
|
||
], E('div', { 'id': 'backups-list' }, this.renderBackupsList()))
|
||
]);
|
||
},
|
||
|
||
renderBackupsList: function() {
|
||
var self = this;
|
||
|
||
if (!this.backups.length) {
|
||
return E('div', { 'style': 'text-align:center;padding:40px;color:var(--kiss-muted);' }, [
|
||
E('div', { 'style': 'font-size:48px;margin-bottom:12px;' }, '💾'),
|
||
E('div', { 'style': 'font-size:16px;' }, 'No backups yet'),
|
||
E('div', { 'style': 'font-size:12px;margin-top:8px;' }, 'Create a backup to protect your data')
|
||
]);
|
||
}
|
||
|
||
return E('table', { 'class': 'kiss-table' }, [
|
||
E('thead', {}, E('tr', {}, [
|
||
E('th', {}, 'Name'),
|
||
E('th', {}, 'Size'),
|
||
E('th', {}, 'Date'),
|
||
E('th', { 'style': 'width:150px;' }, 'Actions')
|
||
])),
|
||
E('tbody', {}, this.backups.map(function(b) {
|
||
return E('tr', {}, [
|
||
E('td', { 'style': 'font-family:monospace;' }, b.name),
|
||
E('td', {}, b.size || '-'),
|
||
E('td', {}, fmtRelative(b.timestamp)),
|
||
E('td', { 'style': 'display:flex;gap:6px;' }, [
|
||
E('button', {
|
||
'class': 'kiss-btn kiss-btn-blue',
|
||
'style': 'padding:4px 10px;font-size:11px;',
|
||
'data-name': b.name,
|
||
'click': function(ev) { self.handleRestore(ev.currentTarget.dataset.name); }
|
||
}, '⬇️ Restore'),
|
||
E('button', {
|
||
'class': 'kiss-btn kiss-btn-red',
|
||
'style': 'padding:4px 10px;font-size:11px;',
|
||
'data-name': b.name,
|
||
'click': function(ev) { self.handleDeleteBackup(ev.currentTarget.dataset.name); }
|
||
}, '🗑️')
|
||
])
|
||
]);
|
||
}))
|
||
]);
|
||
},
|
||
|
||
// ========================================================================
|
||
// SSL Tab
|
||
// ========================================================================
|
||
|
||
renderSSLTab: function() {
|
||
var self = this;
|
||
var s = this.status;
|
||
|
||
return E('div', {}, [
|
||
// SSL Status
|
||
KissTheme.card([
|
||
E('span', {}, '🔒 SSL Status')
|
||
], E('div', { 'style': 'display:flex;align-items:center;gap:16px;' }, [
|
||
E('div', { 'style': 'font-size:48px;' }, s.ssl_enabled ? '🔐' : '🔓'),
|
||
E('div', { 'style': 'flex:1;' }, [
|
||
E('div', { 'style': 'font-size:20px;font-weight:600;' }, s.ssl_enabled ? 'SSL Enabled' : 'SSL Disabled'),
|
||
s.ssl_enabled && s.ssl_domain ? E('div', { 'style': 'color:var(--kiss-muted);margin-top:4px;' }, 'Domain: ' + s.ssl_domain) : null
|
||
]),
|
||
s.ssl_enabled ? E('button', {
|
||
'class': 'kiss-btn kiss-btn-red',
|
||
'click': function() { self.handleSSLDisable(); }
|
||
}, '🔓 Disable SSL') : null
|
||
].filter(Boolean))),
|
||
|
||
// Enable SSL Form
|
||
!s.ssl_enabled ? KissTheme.card([
|
||
E('span', {}, '🌐 Enable SSL via HAProxy')
|
||
], E('div', {}, [
|
||
E('p', { 'style': 'color:var(--kiss-muted);margin-bottom:16px;' },
|
||
'Configure HTTPS access with automatic Let\'s Encrypt certificates via HAProxy.'),
|
||
E('div', { 'style': 'display:flex;gap:12px;align-items:center;' }, [
|
||
E('input', {
|
||
'id': 'ssl-domain',
|
||
'type': 'text',
|
||
'placeholder': 'cloud.example.com',
|
||
'style': 'flex:1;padding:12px;background:var(--kiss-bg2);border:1px solid var(--kiss-line);border-radius:6px;color:var(--kiss-text);font-size:14px;'
|
||
}),
|
||
E('button', {
|
||
'class': 'kiss-btn kiss-btn-green',
|
||
'click': function() {
|
||
var domain = document.getElementById('ssl-domain')?.value;
|
||
if (domain) self.handleSSLEnable(domain);
|
||
else ui.addNotification(null, E('p', 'Enter a domain name'), 'warning');
|
||
}
|
||
}, ['🔐 ', 'Enable SSL'])
|
||
]),
|
||
E('div', { 'style': 'margin-top:16px;padding:12px;background:var(--kiss-bg2);border-radius:6px;' }, [
|
||
E('div', { 'style': 'font-weight:600;margin-bottom:8px;color:var(--kiss-yellow);' }, '⚠️ Prerequisites:'),
|
||
E('ul', { 'style': 'margin:0;padding-left:20px;color:var(--kiss-muted);font-size:13px;' }, [
|
||
E('li', {}, 'Domain must point to this server\'s public IP'),
|
||
E('li', {}, 'Port 80 and 443 must be accessible'),
|
||
E('li', {}, 'HAProxy must be installed and running')
|
||
])
|
||
])
|
||
])) : null
|
||
].filter(Boolean));
|
||
},
|
||
|
||
// ========================================================================
|
||
// Logs Tab
|
||
// ========================================================================
|
||
|
||
renderLogsTab: function() {
|
||
var self = this;
|
||
|
||
return E('div', {}, [
|
||
KissTheme.card([
|
||
E('span', {}, '📜 Installation/Operation Logs'),
|
||
E('button', {
|
||
'class': 'kiss-btn',
|
||
'style': 'margin-left:auto;padding:4px 10px;font-size:11px;',
|
||
'click': function() { self.refreshLogs(); }
|
||
}, '🔄 Refresh')
|
||
], E('pre', {
|
||
'id': 'logs-content',
|
||
'style': 'background:#0a0a0a;color:#0f0;padding:16px;border-radius:8px;font-size:11px;height:400px;overflow-y:auto;margin:0;font-family:monospace;white-space:pre-wrap;'
|
||
}, '(Loading logs...)'))
|
||
]);
|
||
},
|
||
|
||
// ========================================================================
|
||
// Action Handlers
|
||
// ========================================================================
|
||
|
||
handleInstall: function() {
|
||
var self = this;
|
||
ui.showModal('Installing Nextcloud', [
|
||
E('div', { 'style': 'text-align:center;padding:20px;' }, [
|
||
E('div', { 'class': 'spinning', 'style': 'font-size:48px;' }, '⏳'),
|
||
E('p', { 'style': 'margin-top:16px;' }, 'Installing Nextcloud LXC container...'),
|
||
E('p', { 'style': 'color:var(--kiss-muted);font-size:13px;' }, 'This may take several minutes. Please wait.')
|
||
])
|
||
]);
|
||
|
||
callInstall().then(function(r) {
|
||
ui.hideModal();
|
||
if (r.success) {
|
||
ui.addNotification(null, E('p', r.message || 'Installation started'), 'info');
|
||
} else {
|
||
ui.addNotification(null, E('p', 'Failed: ' + (r.error || 'Unknown error')), 'error');
|
||
}
|
||
}).catch(function(e) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', 'Error: ' + e.message), 'error');
|
||
});
|
||
},
|
||
|
||
handleToggle: function() {
|
||
var self = this;
|
||
var running = this.status.running;
|
||
|
||
ui.showModal(running ? 'Stopping...' : 'Starting...', [
|
||
E('p', { 'class': 'spinning' }, (running ? 'Stopping' : 'Starting') + ' Nextcloud...')
|
||
]);
|
||
|
||
var fn = running ? callStop : callStart;
|
||
fn().then(function(r) {
|
||
ui.hideModal();
|
||
if (r.success) {
|
||
ui.addNotification(null, E('p', running ? 'Nextcloud stopped' : 'Nextcloud started'), 'info');
|
||
self.refresh();
|
||
} else {
|
||
ui.addNotification(null, E('p', 'Failed: ' + (r.error || 'Unknown')), 'error');
|
||
}
|
||
}).catch(function(e) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', 'Error: ' + e.message), 'error');
|
||
});
|
||
},
|
||
|
||
handleRestart: function() {
|
||
var self = this;
|
||
ui.showModal('Restarting...', [
|
||
E('p', { 'class': 'spinning' }, 'Restarting Nextcloud...')
|
||
]);
|
||
|
||
callRestart().then(function(r) {
|
||
ui.hideModal();
|
||
if (r.success) {
|
||
ui.addNotification(null, E('p', 'Nextcloud restarted'), 'info');
|
||
self.refresh();
|
||
} else {
|
||
ui.addNotification(null, E('p', 'Failed: ' + (r.error || 'Unknown')), 'error');
|
||
}
|
||
}).catch(function(e) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', 'Error: ' + e.message), 'error');
|
||
});
|
||
},
|
||
|
||
handleUpdate: function() {
|
||
var self = this;
|
||
ui.showModal('Updating Nextcloud', [
|
||
E('div', { 'style': 'text-align:center;padding:20px;' }, [
|
||
E('div', { 'class': 'spinning', 'style': 'font-size:48px;' }, '⬆️'),
|
||
E('p', { 'style': 'margin-top:16px;' }, 'Updating Nextcloud...'),
|
||
E('p', { 'style': 'color:var(--kiss-muted);font-size:13px;' }, 'This may take a few minutes.')
|
||
])
|
||
]);
|
||
|
||
callUpdate().then(function(r) {
|
||
ui.hideModal();
|
||
if (r.success) {
|
||
ui.addNotification(null, E('p', r.message || 'Update started'), 'info');
|
||
} else {
|
||
ui.addNotification(null, E('p', 'Failed: ' + (r.error || 'Unknown')), 'error');
|
||
}
|
||
}).catch(function(e) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', 'Error: ' + e.message), 'error');
|
||
});
|
||
},
|
||
|
||
handleQuickBackup: function() {
|
||
this.handleBackup('');
|
||
},
|
||
|
||
handleBackup: function(name) {
|
||
var self = this;
|
||
ui.showModal('Creating Backup', [
|
||
E('p', { 'class': 'spinning' }, 'Creating backup...')
|
||
]);
|
||
|
||
callBackup(name || null).then(function(r) {
|
||
ui.hideModal();
|
||
if (r.success) {
|
||
ui.addNotification(null, E('p', 'Backup created: ' + (r.backup_name || 'done')), 'info');
|
||
self.refreshBackups();
|
||
} else {
|
||
ui.addNotification(null, E('p', 'Failed: ' + (r.error || 'Unknown')), 'error');
|
||
}
|
||
}).catch(function(e) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', 'Error: ' + e.message), 'error');
|
||
});
|
||
},
|
||
|
||
handleRestore: function(name) {
|
||
var self = this;
|
||
if (!confirm('Restore from backup "' + name + '"? This will stop Nextcloud and may take several minutes.')) {
|
||
return;
|
||
}
|
||
|
||
ui.showModal('Restoring Backup', [
|
||
E('div', { 'style': 'text-align:center;padding:20px;' }, [
|
||
E('div', { 'class': 'spinning', 'style': 'font-size:48px;' }, '⬇️'),
|
||
E('p', { 'style': 'margin-top:16px;' }, 'Restoring from ' + name + '...'),
|
||
E('p', { 'style': 'color:var(--kiss-muted);font-size:13px;' }, 'This may take several minutes.')
|
||
])
|
||
]);
|
||
|
||
callRestore(name).then(function(r) {
|
||
ui.hideModal();
|
||
if (r.success) {
|
||
ui.addNotification(null, E('p', r.message || 'Restore started'), 'info');
|
||
self.refresh();
|
||
} else {
|
||
ui.addNotification(null, E('p', 'Failed: ' + (r.error || 'Unknown')), 'error');
|
||
}
|
||
}).catch(function(e) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', 'Error: ' + e.message), 'error');
|
||
});
|
||
},
|
||
|
||
handleSSLEnable: function(domain) {
|
||
var self = this;
|
||
ui.showModal('Enabling SSL', [
|
||
E('p', { 'class': 'spinning' }, 'Configuring SSL for ' + domain + '...')
|
||
]);
|
||
|
||
callSSLEnable(domain).then(function(r) {
|
||
ui.hideModal();
|
||
if (r.success) {
|
||
ui.addNotification(null, E('p', r.message || 'SSL enabled'), 'info');
|
||
self.refresh();
|
||
} else {
|
||
ui.addNotification(null, E('p', 'Failed: ' + (r.error || 'Unknown')), 'error');
|
||
}
|
||
}).catch(function(e) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', 'Error: ' + e.message), 'error');
|
||
});
|
||
},
|
||
|
||
handleSSLDisable: function() {
|
||
var self = this;
|
||
if (!confirm('Disable SSL? HTTPS access will no longer work.')) {
|
||
return;
|
||
}
|
||
|
||
callSSLDisable().then(function(r) {
|
||
if (r.success) {
|
||
ui.addNotification(null, E('p', 'SSL disabled'), 'info');
|
||
self.refresh();
|
||
} else {
|
||
ui.addNotification(null, E('p', 'Failed: ' + (r.error || 'Unknown')), 'error');
|
||
}
|
||
});
|
||
},
|
||
|
||
handleDeleteBackup: function(name) {
|
||
var self = this;
|
||
if (!confirm('Delete backup "' + name + '"? This cannot be undone.')) {
|
||
return;
|
||
}
|
||
|
||
ui.showModal('Deleting Backup', [
|
||
E('p', { 'class': 'spinning' }, 'Deleting ' + name + '...')
|
||
]);
|
||
|
||
callDeleteBackup(name).then(function(r) {
|
||
ui.hideModal();
|
||
if (r.success) {
|
||
ui.addNotification(null, E('p', 'Backup deleted: ' + name), 'info');
|
||
self.refreshBackups();
|
||
} else {
|
||
ui.addNotification(null, E('p', 'Failed: ' + (r.error || 'Unknown')), 'error');
|
||
}
|
||
}).catch(function(e) {
|
||
ui.hideModal();
|
||
ui.addNotification(null, E('p', 'Error: ' + e.message), 'error');
|
||
});
|
||
},
|
||
|
||
refreshBackups: function() {
|
||
var self = this;
|
||
callListBackups().then(function(data) {
|
||
self.backups = (data || {}).backups || [];
|
||
var container = document.getElementById('backups-list');
|
||
if (container) dom.content(container, self.renderBackupsList());
|
||
});
|
||
},
|
||
|
||
refreshLogs: function() {
|
||
callLogs().then(function(data) {
|
||
var logs = (data.logs || '').replace(/\|/g, '\n');
|
||
var el = document.getElementById('logs-content');
|
||
if (el) {
|
||
el.textContent = logs || '(No logs available)';
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
});
|
||
},
|
||
|
||
// ========================================================================
|
||
// Refresh
|
||
// ========================================================================
|
||
|
||
refresh: function() {
|
||
var self = this;
|
||
return Promise.all([
|
||
callStatus(),
|
||
callListBackups().catch(function() { return { backups: [] }; }),
|
||
callListUsers().catch(function() { return { users: [] }; }),
|
||
callGetStorage().catch(function() { return {}; })
|
||
]).then(function(data) {
|
||
self.status = data[0] || {};
|
||
self.backups = (data[1] || {}).backups || [];
|
||
self.users = (data[2] || {}).users || [];
|
||
self.storage = data[3] || {};
|
||
|
||
// Update tab content
|
||
var tabContent = document.getElementById('tab-content');
|
||
if (tabContent) {
|
||
dom.content(tabContent, self.renderTabContent());
|
||
}
|
||
|
||
// Refresh logs if on logs tab
|
||
if (self.currentTab === 'logs') {
|
||
self.refreshLogs();
|
||
}
|
||
});
|
||
},
|
||
|
||
handleSaveApply: null,
|
||
handleSave: null,
|
||
handleReset: null
|
||
});
|