diff --git a/public/assets/css/landing.css b/public/assets/css/landing.css
new file mode 100644
index 0000000..fb476a2
--- /dev/null
+++ b/public/assets/css/landing.css
@@ -0,0 +1,457 @@
+body.auth-page {
+ display: block;
+ align-items: unset;
+ justify-content: unset;
+ min-height: 100vh;
+ background: var(--bg-primary);
+}
+
+body.auth-page .auth-container {
+ max-width: none;
+ width: 100%;
+ padding: 0;
+}
+
+.landing-page {
+ width: 100%;
+ min-height: 100vh;
+ overflow-x: hidden;
+}
+
+.landing-hero {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ padding: 2rem;
+ background: radial-gradient(ellipse at 50% 30%, rgba(99,102,241,0.08) 0%, transparent 60%),
+ radial-gradient(ellipse at 80% 80%, rgba(34,197,94,0.05) 0%, transparent 50%),
+ var(--bg-primary);
+ text-align: center;
+ overflow: hidden;
+}
+
+.landing-hero::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(1px 1px at 20% 30%, rgba(255,255,255,0.15) 0%, transparent 100%),
+ radial-gradient(1px 1px at 40% 70%, rgba(255,255,255,0.12) 0%, transparent 100%),
+ radial-gradient(1px 1px at 60% 20%, rgba(255,255,255,0.15) 0%, transparent 100%),
+ radial-gradient(1px 1px at 80% 60%, rgba(255,255,255,0.12) 0%, transparent 100%),
+ radial-gradient(1px 1px at 10% 80%, rgba(255,255,255,0.08) 0%, transparent 100%),
+ radial-gradient(1px 1px at 90% 10%, rgba(255,255,255,0.08) 0%, transparent 100%);
+ pointer-events: none;
+}
+
+.floating-elements {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ overflow: hidden;
+}
+
+.floating-elem {
+ position: absolute;
+ font-size: 1.4rem;
+ opacity: 0.08;
+ animation: floatAround 20s infinite ease-in-out;
+}
+
+.floating-elem:nth-child(1) { top: 15%; left: 10%; animation-delay: 0s; font-size: 2rem; }
+.floating-elem:nth-child(2) { top: 25%; right: 12%; animation-delay: -3s; }
+.floating-elem:nth-child(3) { top: 60%; left: 8%; animation-delay: -7s; font-size: 1.8rem; }
+.floating-elem:nth-child(4) { top: 70%; right: 8%; animation-delay: -11s; }
+.floating-elem:nth-child(5) { top: 40%; left: 5%; animation-delay: -5s; font-size: 1.2rem; }
+.floating-elem:nth-child(6) { top: 50%; right: 5%; animation-delay: -9s; font-size: 1.6rem; }
+.floating-elem:nth-child(7) { top: 10%; left: 50%; animation-delay: -13s; font-size: 1rem; }
+.floating-elem:nth-child(8) { top: 85%; left: 50%; animation-delay: -15s; }
+
+@keyframes floatAround {
+ 0%, 100% { transform: translateY(0) rotate(0deg); }
+ 25% { transform: translateY(-30px) rotate(5deg); }
+ 50% { transform: translateY(0) rotate(0deg); }
+ 75% { transform: translateY(20px) rotate(-3deg); }
+}
+
+.hero-icon-wrapper {
+ position: relative;
+ margin-bottom: 2rem;
+ animation: heroIconEnter 1s ease-out;
+}
+
+.hero-server-icon {
+ width: 80px;
+ height: 80px;
+ border-radius: 20px;
+ background: linear-gradient(135deg, rgba(99,102,241,0.2), rgba(99,102,241,0.05));
+ border: 1px solid rgba(99,102,241,0.3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 2.4rem;
+ color: var(--accent);
+ position: relative;
+ margin: 0 auto;
+}
+
+.hero-server-icon::before {
+ content: '';
+ position: absolute;
+ inset: -4px;
+ border-radius: 24px;
+ background: linear-gradient(135deg, var(--accent), var(--purple));
+ opacity: 0.15;
+ z-index: -1;
+ animation: glowPulse 3s infinite ease-in-out;
+}
+
+@keyframes glowPulse {
+ 0%, 100% { opacity: 0.15; transform: scale(1); }
+ 50% { opacity: 0.3; transform: scale(1.05); }
+}
+
+@keyframes heroIconEnter {
+ from { opacity: 0; transform: translateY(-30px) scale(0.8); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+.hero-title {
+ font-size: clamp(2rem, 5vw, 3.2rem);
+ font-weight: 800;
+ line-height: 1.15;
+ margin-bottom: 0.75rem;
+ animation: fadeSlideUp 0.8s ease-out 0.2s both;
+}
+
+.hero-title .gradient-text {
+ background: linear-gradient(135deg, var(--accent), var(--purple));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.hero-subtitle {
+ font-size: clamp(1rem, 2vw, 1.2rem);
+ color: var(--text-muted);
+ max-width: 600px;
+ margin: 0 auto 2rem;
+ line-height: 1.7;
+ animation: fadeSlideUp 0.8s ease-out 0.35s both;
+}
+
+.hero-cta {
+ display: flex;
+ gap: 1rem;
+ justify-content: center;
+ flex-wrap: wrap;
+ animation: fadeSlideUp 0.8s ease-out 0.5s both;
+}
+
+.hero-cta .btn {
+ padding: 0.85rem 2.2rem;
+ font-size: 1rem;
+ border-radius: var(--radius);
+ font-weight: 600;
+ transition: all 0.3s ease;
+ cursor: pointer;
+ border: none;
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.hero-cta .btn-primary-custom {
+ background: linear-gradient(135deg, var(--accent), #818cf8);
+ color: #fff;
+ box-shadow: 0 4px 20px rgba(99,102,241,0.3);
+}
+
+.hero-cta .btn-primary-custom:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 28px rgba(99,102,241,0.4);
+}
+
+.hero-cta .btn-secondary-custom {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+}
+
+.hero-cta .btn-secondary-custom:hover {
+ background: var(--bg-hover);
+ border-color: var(--accent);
+ transform: translateY(-2px);
+}
+
+.hero-terminal {
+ margin-top: 3rem;
+ max-width: 560px;
+ width: 100%;
+ background: rgba(0,0,0,0.4);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ animation: fadeSlideUp 0.8s ease-out 0.65s both;
+}
+
+.terminal-bar {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.65rem 1rem;
+ background: rgba(255,255,255,0.03);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.terminal-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+}
+
+.terminal-dot.red { background: #ff5f57; }
+.terminal-dot.yellow { background: #ffbd2e; }
+.terminal-dot.green { background: #28c840; }
+
+.terminal-title {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ margin-left: 0.5rem;
+ font-family: var(--font-mono);
+}
+
+.terminal-body-landing {
+ padding: 1.25rem 1.25rem 1.5rem;
+ text-align: left;
+ font-family: var(--font-mono);
+ font-size: 0.82rem;
+ line-height: 1.6;
+ color: #c9d1d9;
+}
+
+.terminal-line-landing {
+ opacity: 0;
+ animation: terminalFadeIn 0.3s ease-out forwards;
+}
+
+.terminal-line-landing .prompt { color: var(--success); }
+.terminal-line-landing .cmd { color: #e1e4ed; }
+.terminal-line-landing .output-success { color: var(--success); }
+.terminal-line-landing .output-info { color: var(--info); }
+.terminal-line-landing .output-muted { color: var(--text-muted); }
+.terminal-line-landing .blinking-cursor {
+ display: inline-block;
+ width: 8px;
+ height: 14px;
+ background: var(--accent);
+ margin-left: 2px;
+ animation: blink 1s step-end infinite;
+ vertical-align: text-bottom;
+}
+
+@keyframes blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+
+@keyframes terminalFadeIn {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes fadeSlideUp {
+ from { opacity: 0; transform: translateY(20px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.scroll-indicator {
+ position: absolute;
+ bottom: 2rem;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ animation: fadeSlideUp 1s ease-out 1.5s both;
+}
+
+.scroll-indicator .mouse {
+ width: 24px;
+ height: 38px;
+ border: 2px solid var(--text-muted);
+ border-radius: 12px;
+ position: relative;
+ opacity: 0.5;
+}
+
+.scroll-indicator .mouse::after {
+ content: '';
+ position: absolute;
+ top: 6px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 3px;
+ height: 10px;
+ background: var(--text-muted);
+ border-radius: 2px;
+ animation: scrollWheel 1.5s infinite;
+}
+
+@keyframes scrollWheel {
+ 0% { opacity: 1; transform: translateX(-50%) translateY(0); }
+ 100% { opacity: 0; transform: translateX(-50%) translateY(10px); }
+}
+
+.features-section {
+ padding: 5rem 2rem;
+ max-width: 1100px;
+ margin: 0 auto;
+}
+
+.features-header {
+ text-align: center;
+ margin-bottom: 3.5rem;
+}
+
+.features-header h2 {
+ font-size: 2rem;
+ font-weight: 700;
+ margin-bottom: 0.75rem;
+}
+
+.features-header p {
+ color: var(--text-muted);
+ font-size: 1.05rem;
+ max-width: 500px;
+ margin: 0 auto;
+}
+
+.features-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 1.25rem;
+}
+
+.feature-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ padding: 1.75rem;
+ transition: all 0.4s ease-out;
+}
+
+.feature-card:hover {
+ border-color: var(--accent);
+ transform: translateY(-3px);
+ box-shadow: 0 8px 25px rgba(0,0,0,0.2);
+}
+
+.feature-card.visible:hover {
+ transform: translateY(-3px);
+}
+
+.feature-icon {
+ width: 48px;
+ height: 48px;
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.3rem;
+ margin-bottom: 1.25rem;
+}
+
+.feature-icon.blue { background: rgba(59,130,246,0.15); color: var(--info); }
+.feature-icon.green { background: rgba(34,197,94,0.15); color: var(--success); }
+.feature-icon.purple { background: rgba(139,92,246,0.15); color: var(--purple); }
+.feature-icon.orange { background: rgba(245,158,11,0.15); color: var(--warning); }
+.feature-icon.red { background: rgba(239,68,68,0.15); color: var(--danger); }
+.feature-icon.teal { background: rgba(20,184,166,0.15); color: #14b8a6; }
+
+.feature-card h3 {
+ font-size: 1.05rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+}
+
+.feature-card p {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ line-height: 1.6;
+}
+
+.stats-section {
+ padding: 4rem 2rem 5rem;
+ text-align: center;
+}
+
+.stats-grid {
+ display: flex;
+ justify-content: center;
+ gap: 3rem;
+ flex-wrap: wrap;
+ max-width: 800px;
+ margin: 0 auto;
+}
+
+.stat-item {
+ opacity: 1;
+ transform: translateY(0);
+ transition: all 0.5s ease-out;
+}
+
+.stat-number {
+ font-size: 2.8rem;
+ font-weight: 800;
+ background: linear-gradient(135deg, var(--accent), var(--purple));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.stat-label {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ margin-top: 0.25rem;
+}
+
+.landing-footer {
+ text-align: center;
+ padding: 2rem;
+ border-top: 1px solid var(--border-color);
+ color: var(--text-muted);
+ font-size: 0.82rem;
+}
+
+.landing-footer a {
+ color: var(--text-muted);
+ text-decoration: none;
+}
+
+.landing-footer a:hover {
+ color: var(--accent);
+}
+
+.footer-links {
+ display: flex;
+ justify-content: center;
+ gap: 1.5rem;
+ margin-bottom: 0.75rem;
+}
+
+@media (max-width: 640px) {
+ .hero-title { font-size: 1.8rem; }
+ .features-grid { grid-template-columns: 1fr; }
+ .stats-grid { gap: 2rem; }
+ .stats-grid .stat-item { min-width: 120px; }
+ .scroll-indicator { display: none; }
+ .landing-hero { padding-bottom: 5rem; }
+}
diff --git a/public/assets/css/legal.css b/public/assets/css/legal.css
new file mode 100644
index 0000000..5f1b6ac
--- /dev/null
+++ b/public/assets/css/legal.css
@@ -0,0 +1,19 @@
+body.auth-page {
+ display: block;
+ align-items: unset;
+ justify-content: unset;
+ min-height: 100vh;
+}
+
+body.auth-page .auth-container {
+ max-width: none;
+ width: 100%;
+ padding: 0;
+}
+
+@media (max-width:768px) {
+ .legal-container .legal-card { width: 92% !important; padding: 1.5rem !important; }
+}
+@media (max-width:480px) {
+ .legal-container .legal-card { width: 96% !important; padding: 1rem !important; }
+}
diff --git a/public/assets/js/admin-notifications.js b/public/assets/js/admin-notifications.js
new file mode 100644
index 0000000..7d24e1d
--- /dev/null
+++ b/public/assets/js/admin-notifications.js
@@ -0,0 +1,40 @@
+function showSendModal() {
+ document.getElementById('notificationModal').style.display = 'flex';
+}
+
+function closeModal() {
+ document.getElementById('notificationModal').style.display = 'none';
+}
+
+function sendNotification() {
+ const form = document.getElementById('notificationForm');
+ const formData = new FormData(form);
+ const btn = document.querySelector('#notificationModal .btn-primary');
+ btn.disabled = true;
+ btn.innerHTML = ' Sending...';
+
+ fetch('/admin/notifications/send', {
+ method: 'POST',
+ headers: {
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
+ },
+ body: new URLSearchParams(formData),
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.success) {
+ showToast('success', data.message);
+ setTimeout(() => location.reload(), 1000);
+ } else {
+ showToast('error', data.message);
+ }
+ closeModal();
+ btn.disabled = false;
+ btn.innerHTML = ' Send';
+ })
+ .catch(() => {
+ showToast('error', 'Failed to send notification');
+ btn.disabled = false;
+ btn.innerHTML = ' Send';
+ });
+}
diff --git a/public/assets/js/admin-security.js b/public/assets/js/admin-security.js
new file mode 100644
index 0000000..024a7cc
--- /dev/null
+++ b/public/assets/js/admin-security.js
@@ -0,0 +1,17 @@
+function generateKey() {
+ if (!confirm('WARNING: Generating a new master key will make all existing encrypted credentials UNREADABLE. ' +
+ 'You will need to re-enter all SSH credentials. Continue?')) return;
+
+ fetch('/admin/security/generate-key', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
+ },
+ body: '_csrf_token=' + encodeURIComponent(document.querySelector('meta[name="csrf-token"]')?.content || ''),
+ })
+ .then(r => r.json())
+ .then(data => {
+ showToast(data.success ? 'success' : 'error', data.message);
+ });
+}
diff --git a/public/assets/js/admin-users.js b/public/assets/js/admin-users.js
new file mode 100644
index 0000000..d4358dd
--- /dev/null
+++ b/public/assets/js/admin-users.js
@@ -0,0 +1,76 @@
+function showCreateUserModal() {
+ document.getElementById('modalTitle').textContent = 'Create User';
+ document.getElementById('userId').value = '';
+ document.getElementById('modalUsername').value = '';
+ document.getElementById('modalEmail').value = '';
+ document.getElementById('modalPassword').value = '';
+ document.getElementById('modalPassword').required = true;
+ document.getElementById('passwordHint').style.display = 'block';
+ document.getElementById('modalRole').value = 'admin';
+ document.getElementById('modalActive').checked = true;
+ document.getElementById('saveUserBtn').textContent = 'Create';
+ document.getElementById('userModal').style.display = 'flex';
+}
+
+function editUser(id, username, email, role, isActive) {
+ document.getElementById('modalTitle').textContent = 'Edit User';
+ document.getElementById('userId').value = id;
+ document.getElementById('modalUsername').value = username;
+ document.getElementById('modalEmail').value = email;
+ document.getElementById('modalPassword').value = '';
+ document.getElementById('modalPassword').required = false;
+ document.getElementById('passwordHint').style.display = 'none';
+ document.getElementById('modalRole').value = role;
+ document.getElementById('modalActive').checked = !!isActive;
+ document.getElementById('saveUserBtn').textContent = 'Update';
+ document.getElementById('userModal').style.display = 'flex';
+}
+
+function closeModal() {
+ document.getElementById('userModal').style.display = 'none';
+}
+
+function saveUser() {
+ const id = document.getElementById('userId').value;
+ const formData = new FormData(document.getElementById('userForm'));
+
+ const isEdit = !!id;
+ const url = isEdit ? '/admin/users/' + id + '/update' : '/admin/users/create';
+
+ fetch(url, {
+ method: 'POST',
+ headers: {
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
+ },
+ body: new URLSearchParams(formData),
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.success) {
+ showToast('success', data.message);
+ setTimeout(() => location.reload(), 1000);
+ } else {
+ showToast('error', data.message);
+ }
+ closeModal();
+ })
+ .catch(err => showToast('error', 'Failed to save user'));
+}
+
+function deleteUser(id, username) {
+ if (!confirm('Are you sure you want to delete user "' + username + '"?')) return;
+
+ fetch('/admin/users/' + id + '/delete', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
+ },
+ body: '_csrf_token=' + encodeURIComponent(document.querySelector('meta[name="csrf-token"]')?.content || ''),
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.success) location.reload();
+ else showToast('error', data.message);
+ });
+}
diff --git a/public/assets/js/app.js b/public/assets/js/app.js
old mode 100755
new mode 100644
diff --git a/public/assets/js/audit-log.js b/public/assets/js/audit-log.js
new file mode 100644
index 0000000..07dc364
--- /dev/null
+++ b/public/assets/js/audit-log.js
@@ -0,0 +1,40 @@
+function buildQuery() {
+ const params = [];
+ const fields = [
+ { key: 'action', id: 'f_action' },
+ { key: 'username', id: 'f_username' },
+ { key: 'ip_address', id: 'f_ip_address' },
+ { key: 'entity_type', id: 'f_entity_type' },
+ { key: 'details', id: 'f_details' },
+ { key: 'date_from', id: 'f_date_from' },
+ { key: 'date_to', id: 'f_date_to' },
+ ];
+ fields.forEach(function (f) {
+ const el = document.getElementById(f.id);
+ if (el && el.value) {
+ params.push(encodeURIComponent(f.key) + '=' + encodeURIComponent(el.value));
+ }
+ });
+ return params.length ? '/admin/audit?' + params.join('&') : '/admin/audit';
+}
+
+document.addEventListener('change', function (e) {
+ if (e.target.matches('#f_date_from, #f_date_to, #f_action, #f_entity_type')) {
+ window.location.href = buildQuery();
+ }
+});
+
+document.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' && e.target.matches('#f_username, #f_details, #f_ip_address')) {
+ window.location.href = buildQuery();
+ }
+});
+
+function clearFilters() {
+ window.location.href = '/admin/audit';
+}
+
+function clearField(id) {
+ document.getElementById(id).value = '';
+ window.location.href = buildQuery();
+}
diff --git a/public/assets/js/dashboard-chart.js b/public/assets/js/dashboard-chart.js
new file mode 100644
index 0000000..cef7617
--- /dev/null
+++ b/public/assets/js/dashboard-chart.js
@@ -0,0 +1,136 @@
+let aggregatedChart = null;
+
+function initAggregatedChart(data) {
+ const container = document.getElementById('aggregatedChart').parentNode;
+ const oldCanvas = document.getElementById('aggregatedChart');
+ const newCanvas = document.createElement('canvas');
+ newCanvas.id = 'aggregatedChart';
+ newCanvas.height = 300;
+ container.replaceChild(newCanvas, oldCanvas);
+ const ctx = newCanvas.getContext('2d');
+ const labels = data.map(m => {
+ const d = new Date(m.time_bucket);
+ return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
+ });
+
+ aggregatedChart = new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels: labels,
+ datasets: [
+ {
+ label: 'CPU',
+ data: data.map(m => parseFloat(m.avg_cpu)),
+ borderColor: '#3b82f6',
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
+ fill: true,
+ tension: 0.3,
+ pointRadius: 1,
+ },
+ {
+ label: 'RAM',
+ data: data.map(m => parseFloat(m.avg_ram)),
+ borderColor: '#22c55e',
+ backgroundColor: 'rgba(34, 197, 94, 0.1)',
+ fill: true,
+ tension: 0.3,
+ pointRadius: 1,
+ },
+ {
+ label: 'Disk',
+ data: data.map(m => parseFloat(m.avg_disk)),
+ borderColor: '#8b5cf6',
+ backgroundColor: 'rgba(139, 92, 246, 0.1)',
+ fill: true,
+ tension: 0.3,
+ pointRadius: 1,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: {
+ intersect: false,
+ mode: 'index',
+ },
+ plugins: {
+ legend: {
+ labels: {
+ color: '#9ca3af',
+ font: { family: "'Inter', sans-serif" },
+ boxWidth: 12,
+ padding: 16,
+ },
+ },
+ tooltip: {
+ backgroundColor: '#1a1d2b',
+ borderColor: '#2a2d3d',
+ borderWidth: 1,
+ titleColor: '#e1e4ed',
+ bodyColor: '#9ca3af',
+ padding: 10,
+ cornerRadius: 8,
+ callbacks: {
+ label: function(ctx) {
+ return ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(1) + '%';
+ },
+ },
+ },
+ },
+ scales: {
+ x: {
+ ticks: {
+ color: '#6b7280',
+ maxTicksLimit: 12,
+ font: { size: 11 },
+ },
+ grid: {
+ color: 'rgba(255,255,255,0.04)',
+ },
+ },
+ y: {
+ min: 0,
+ max: 100,
+ ticks: {
+ color: '#6b7280',
+ font: { size: 11 },
+ callback: function(v) { return v + '%'; },
+ },
+ grid: {
+ color: 'rgba(255,255,255,0.04)',
+ },
+ },
+ },
+ },
+ });
+}
+
+function refreshAggregatedChart() {
+ fetch('/dashboard/chart-data')
+ .then(r => r.json())
+ .then(response => {
+ if (response.success && response.data) {
+ const data = response.data;
+ const labels = data.map(m => {
+ const d = new Date(m.time_bucket);
+ return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
+ });
+ if (aggregatedChart) {
+ aggregatedChart.data.labels = labels;
+ aggregatedChart.data.datasets[0].data = data.map(m => parseFloat(m.avg_cpu));
+ aggregatedChart.data.datasets[1].data = data.map(m => parseFloat(m.avg_ram));
+ aggregatedChart.data.datasets[2].data = data.map(m => parseFloat(m.avg_disk));
+ aggregatedChart.update('none');
+ } else if (data.length > 0) {
+ initAggregatedChart(data);
+ }
+ }
+ });
+}
+
+if (aggregatedData.length > 0) {
+ initAggregatedChart(aggregatedData);
+}
+
+setInterval(refreshAggregatedChart, 30000);
diff --git a/public/assets/js/database-manager.js b/public/assets/js/database-manager.js
new file mode 100644
index 0000000..e82c487
--- /dev/null
+++ b/public/assets/js/database-manager.js
@@ -0,0 +1,316 @@
+let currentDb = '';
+let currentTable = '';
+let openedDb = '';
+
+function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; }
+
+function filterTables(inp) {
+ const q = inp.value.toLowerCase();
+ const body = inp.closest('.pma-accordion-body');
+ if (body) {
+ body.querySelectorAll('.pma-table-item').forEach(el => {
+ el.style.display = !q || (el.dataset.tbl || '').includes(q) ? '' : 'none';
+ });
+ }
+}
+
+function filterSidebar(q) {
+ document.querySelectorAll('.pma-accordion-item').forEach(el => {
+ el.style.display = el.textContent.toLowerCase().includes(q.toLowerCase()) ? '' : 'none';
+ });
+}
+
+function toggleDb(db) {
+ const item = document.querySelector(`.pma-accordion-item[data-db="${db.replace(/"/g,'\\"')}"]`);
+ if (!item) return;
+ const body = item.querySelector('.pma-accordion-body');
+ const header = item.querySelector('.pma-accordion-header');
+ const chevron = item.querySelector('.pma-chevron');
+
+ if (item.classList.contains('expanded')) {
+ item.classList.remove('expanded');
+ body.style.maxHeight = '0';
+ return;
+ }
+
+ document.querySelectorAll('.pma-accordion-item.expanded').forEach(el => {
+ el.classList.remove('expanded');
+ el.querySelector('.pma-accordion-body').style.maxHeight = '0';
+ });
+
+ item.classList.add('expanded');
+ currentDb = db;
+
+ if (body.querySelector('.pma-table-item')) {
+ body.style.maxHeight = body.scrollHeight + 'px';
+ return;
+ }
+
+ body.querySelector('.pma-loading-tables').style.display = '';
+ body.style.maxHeight = '60px';
+
+ fetch('/servers/' + serverId + '/database', {
+ method: 'POST',
+ headers: {'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
+ body: 'action=tables&db=' + encodeURIComponent(db) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
+ })
+ .then(r => r.json())
+ .then(data => {
+ body.querySelector('.pma-loading-tables').style.display = 'none';
+ let html = '
';
+ (data.tables || []).forEach(t => {
+ html += ''
+ + ' ' + escHtml(t.name)
+ + ' ' + t.rows + ' rows
';
+ });
+ if (!html) html = 'No tables
';
+ body.innerHTML = html;
+ body.style.maxHeight = body.scrollHeight + 'px';
+ });
+}
+
+function selectTable(db, table) {
+ currentDb = db; currentTable = table;
+ document.getElementById('tabBrowse').style.display = '';
+ document.getElementById('tabStructure').style.display = '';
+ document.querySelectorAll('.pma-tab').forEach(t=>t.classList.remove('active'));
+ document.getElementById('tabStructure').classList.add('active');
+ showPanel('structure');
+ loadStructure(db, table);
+}
+
+function loadStructure(db, table) {
+ const panel = document.getElementById('pmaStructure');
+ panel.innerHTML = ' Loading structure...
';
+ fetch('/servers/' + serverId + '/database', {
+ method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
+ body:'action=structure&db='+encodeURIComponent(db)+'&table='+encodeURIComponent(table)+'&_csrf_token='+encodeURIComponent(getCsrfToken()),
+ }).then(r=>r.json()).then(d=>{
+ if (!d.success||!d.columns) return;
+ let html=''
+ +'
'+escHtml(table)+' '
+ +''+d.columns.length+' columns '
+ +' '
+ +' Browse '
+ +'';
+ html+='# Field Type Collation Null Key Default Extra ';
+ d.columns.forEach((c,i)=>{
+ html+=''+(i+1)+' '+escHtml(c.field)+' '+escHtml(c.type)+' '
+ +''+escHtml(c.collation)+' '+escHtml(c.null)+' '+escHtml(c.key)+' '
+ +''+(c.default??'NULL')+' '+escHtml(c.extra)+' ';
+ });
+ html+='
';
+ panel.innerHTML=html;
+ });
+}
+
+function browseTable(db, table) {
+ currentDb=db; currentTable=table;
+ document.getElementById('tabBrowse').style.display='';
+ document.getElementById('tabStructure').style.display='';
+ document.querySelectorAll('.pma-tab').forEach(t=>t.classList.remove('active'));
+ document.getElementById('tabBrowse').classList.add('active');
+ showPanel('browse');
+ loadBrowse(db,table,1);
+}
+
+function loadBrowse(db,table,page) {
+ const panel=document.getElementById('pmaBrowse');
+
+ const inputs=document.querySelectorAll('.pma-fi');
+ let filters='';
+ if(inputs.length){
+ const p={};
+ inputs.forEach(inp=>{if(inp.value.trim())p[inp.dataset.col]=inp.value.trim()});
+ const s=new URLSearchParams(p).toString();
+ if(s)filters='&'+s;
+ }
+
+ panel.innerHTML=' Loading...
';
+
+ let body='action=browse&db='+encodeURIComponent(db)+'&table='+encodeURIComponent(table)+'&page='+page+filters+'&_csrf_token='+encodeURIComponent(getCsrfToken());
+
+ fetch('/servers/' + serverId + '/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body})
+ .then(r=>r.json()).then(d=>{
+ if(!d.success)return;
+ const tp=Math.ceil(d.total/d.per_page);
+ let html=''
+ +'
'+escHtml(table)+' '
+ +''+d.total+' rows ';
+ html+='';
+ panel.innerHTML=html;
+ });
+}
+
+function clearBrowseFilters(db,table){
+ document.querySelectorAll('.pma-fi').forEach(inp=>inp.value='');
+ loadBrowse(db,table,1);
+}
+
+function loadUsers() {
+ showPanel('users');
+ document.getElementById('tabUsers').style.display='';
+ const panel=document.getElementById('pmaUsers');
+ panel.innerHTML=' Loading users...
';
+
+ fetch('/servers/' + serverId + '/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body:'action=users&_csrf_token='+encodeURIComponent(getCsrfToken())})
+ .then(r=>r.json()).then(d=>{
+ let html=''
+ +'
Database Users '
+ +''+(d.users?.length||0)+' users '
+ +' '
+ +' New User ';
+ html+='User Host Databases Expired Actions ';
+ (d.users||[]).forEach(u=>{
+ const dbList=(d.grants||[]).filter(g=>g.user===u.user&&g.host===u.host).map(g=>g.db).join(', ');
+ html+=''+escHtml(u.user)+' '+escHtml(u.host)+''+(dbList||'- ')+' '+escHtml(u.expired)+' '
+ +''
+ +' '
+ +' '
+ +' ';
+ });
+ html+='
';
+ panel.innerHTML=html;
+ });
+}
+
+function showCreateUser() {
+ const panel=document.getElementById('pmaUsers');
+ let html=''
+ +'
Create User '
+ +' '
+ +' Back '
+ +''
+ +''
+ +'
';
+ ['ALL PRIVILEGES','SELECT','INSERT','UPDATE','DELETE','CREATE','DROP','ALTER','INDEX','CREATE VIEW','SHOW VIEW','CREATE ROUTINE','ALTER ROUTINE','EXECUTE','LOCK TABLES','REFERENCES','EVENT','TRIGGER'].forEach(function(p){
+ var checked = p === 'ALL PRIVILEGES' ? ' checked' : '';
+ html+=' '+p+' ';
+ });
+ html+='
'
+ +''
+ +' Create User '
+ +'Cancel
';
+ panel.innerHTML=html;
+}
+
+function doCreateUser() {
+ const user=document.getElementById('nuUser').value;
+ const pass=document.getElementById('nuPass').value;
+ const host=document.getElementById('nuHost').value||'localhost';
+ if(!user||!pass){showToast('error','Username and password required');return;}
+
+ const privs=[];
+ document.querySelectorAll('.nuPriv:checked').forEach(cb=>privs.push(cb.value));
+ const grantPrivs = privs.includes('ALL PRIVILEGES') ? 'ALL PRIVILEGES' : privs.join(',');
+
+ fetch('/servers/' + serverId + '/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body: 'action=create_user&new_user='+encodeURIComponent(user)+'&new_pass='+encodeURIComponent(pass)+'&new_host='+encodeURIComponent(host)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
+ .then(r=>r.json()).then(d=>{
+ showToast(d.success?'success':'error',d.message);
+ if(d.success){
+ const dbs=[];
+ document.querySelectorAll('.nuDbCheck:checked').forEach(cb=>dbs.push(cb.value));
+ if(dbs.length>0){
+ let cnt=0;
+ dbs.forEach(db=>{
+ fetch('/servers/' + serverId + '/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body:'action=grant&grant_user='+encodeURIComponent(user)+'&grant_db='+encodeURIComponent(db)+'&grant_host='+encodeURIComponent(host)+'&grant_privs='+encodeURIComponent(grantPrivs)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
+ .then(r2=>r2.json()).then(g=>{
+ cnt++;
+ if(cnt===dbs.length)loadUsers();
+ });
+ });
+ }else{loadUsers();}
+ }
+ });
+}
+
+function showEditUser(user, host) {
+ const panel = document.getElementById('pmaUsers');
+ panel.innerHTML=''
+ +'
Change Password '
+ +''+escHtml(user)+' @ '+escHtml(host)+''
+ +' '
+ +' Back '
+ +''
+ +'
New Password
'
+ +'
'
+ +' Save '
+ +'Cancel
';
+}
+
+function doChangePass(user, host) {
+ const pass = document.getElementById('epPass').value;
+ if (!pass) { showToast('error','Password required'); return; }
+ fetch('/servers/' + serverId + '/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
+ body:'action=change_pass&target_user='+encodeURIComponent(user)+'&target_host='+encodeURIComponent(host)+'&new_pass='+encodeURIComponent(pass)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
+ .then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)loadUsers()});
+}
+
+function deleteUser(user, host) {
+ if (!confirm('Delete user \''+user+'\'@\''+host+'\'?\nThis cannot be undone.')) return;
+ fetch('/servers/' + serverId + '/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
+ body:'action=drop_user&target_user='+encodeURIComponent(user)+'&target_host='+encodeURIComponent(host)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
+ .then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)loadUsers()});
+}
+
+function runSql() {
+ const sql=document.getElementById('sqlInput').value; if(!sql)return;
+ const rd=document.getElementById('sqlResult'); const rt=document.getElementById('sqlResultText');
+ rd.style.display='none';
+ fetch('/servers/' + serverId + '/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body:'action=query&query='+encodeURIComponent(sql)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
+ .then(r=>r.json()).then(d=>{if(d.output){rt.textContent=d.output;rd.style.display='block'}else showToast('error',d.message||'No results')});
+}
+
+function showPanel(id) {
+ document.querySelectorAll('.pma-panel').forEach(p=>p.classList.remove('active'));
+ document.getElementById('pma'+id.charAt(0).toUpperCase()+id.slice(1)).classList.add('active');
+}
+
+function switchTab(tab) {
+ document.querySelectorAll('.pma-tab').forEach(t=>t.classList.remove('active'));
+ const map={browse:'pmaBrowse',structure:'pmaStructure',sql:'pmaSql',users:'pmaUsers'};
+ const el=document.getElementById('tab'+tab.charAt(0).toUpperCase()+tab.slice(1));
+ if(el)el.classList.add('active');
+ showPanel(tab);
+ if(tab==='sql'){document.getElementById('pmaWelcome').classList.remove('active');document.getElementById('pmaSql').classList.add('active');}
+}
+
+document.getElementById('sqlInput').addEventListener('keydown',function(e){if((e.ctrlKey||e.metaKey)&&e.key==='Enter')runSql()});
+
+function escHtml(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
diff --git a/public/assets/js/main.js b/public/assets/js/main.js
new file mode 100644
index 0000000..2fdaecf
--- /dev/null
+++ b/public/assets/js/main.js
@@ -0,0 +1,202 @@
+let serverListLoaded = false;
+
+function getApiToken() {
+ return document.querySelector('meta[name="api-token"]')?.getAttribute('content') || '';
+}
+
+function toggleNotifications() {
+ const dd = document.getElementById('notifDropdown');
+ if (dd.classList.contains('open')) {
+ closeNotifications();
+ } else {
+ openNotifications();
+ }
+}
+
+function openNotifications() {
+ const dd = document.getElementById('notifDropdown');
+ dd.classList.add('open');
+ fetchNotifications();
+ document.addEventListener('click', notifOutsideClick);
+}
+
+function closeNotifications() {
+ const dd = document.getElementById('notifDropdown');
+ dd.classList.remove('open');
+ document.removeEventListener('click', notifOutsideClick);
+}
+
+function notifOutsideClick(e) {
+ const wrapper = document.querySelector('.notification-btn-wrapper');
+ if (!wrapper.contains(e.target)) {
+ closeNotifications();
+ }
+}
+
+function fetchNotifications() {
+ const list = document.getElementById('notifList');
+ list.innerHTML = 'Loading notifications...
';
+
+ const token = getApiToken();
+ const headers = { 'Accept': 'application/json' };
+ if (token) headers['Authorization'] = 'Bearer ' + token;
+
+ fetch('/api/notifications?per_page=10', { headers })
+ .then(r => r.json())
+ .then(d => {
+ if (!d.success) {
+ list.innerHTML = 'Could not load notifications.
';
+ return;
+ }
+ renderNotifications(d.data || [], d.unread_count || 0);
+ })
+ .catch(() => {
+ list.innerHTML = 'Failed to load notifications.
';
+ });
+}
+
+function renderNotifications(items, unreadCount) {
+ const list = document.getElementById('notifList');
+
+ if (!items.length) {
+ list.innerHTML = ''
+ + ' '
+ + 'No notifications
';
+ document.getElementById('markAllReadBtn').style.display = 'none';
+ return;
+ }
+
+ let html = '';
+ items.forEach(function(n) {
+ const typeColors = { error: 'danger', warning: 'warning', info: 'info' };
+ const typeColor = typeColors[n.type] || 'info';
+ const icons = { error: 'fa-times-circle', warning: 'fa-exclamation-triangle', info: 'fa-info-circle' };
+ const icon = icons[n.type] || 'fa-info-circle';
+
+ html += ''
+ + '
'
+ + '
'
+ + '
' + escapeHtml(n.title) + '
'
+ + '
' + escapeHtml(n.message) + '
'
+ + '
' + (n.time_ago || '') + '
'
+ + '
'
+ + (n.is_read ? '' : '
')
+ + '
';
+ });
+ list.innerHTML = html;
+
+ document.getElementById('markAllReadBtn').style.display = unreadCount > 0 ? 'inline-flex' : 'none';
+}
+
+function markRead(id, el) {
+ if (el.classList.contains('notif-read')) return;
+
+ const token = getApiToken();
+ const headers = { 'Accept': 'application/json' };
+ if (token) headers['Authorization'] = 'Bearer ' + token;
+
+ fetch('/api/notifications/' + id + '/read', { method: 'POST', headers })
+ .then(r => r.json())
+ .then(d => {
+ if (d.success) {
+ el.classList.remove('notif-unread');
+ el.classList.add('notif-read');
+ const dot = el.querySelector('.notif-dot');
+ if (dot) dot.remove();
+ updateUnreadCount();
+ }
+ })
+ .catch(function(){});
+}
+
+function markAllRead() {
+ const token = getApiToken();
+ const headers = { 'Accept': 'application/json' };
+ if (token) headers['Authorization'] = 'Bearer ' + token;
+
+ fetch('/api/notifications/read-all', { method: 'POST', headers })
+ .then(r => r.json())
+ .then(d => {
+ if (d.success) {
+ document.querySelectorAll('.notif-item').forEach(function(el) {
+ el.classList.remove('notif-unread');
+ el.classList.add('notif-read');
+ const dot = el.querySelector('.notif-dot');
+ if (dot) dot.remove();
+ });
+ document.getElementById('markAllReadBtn').style.display = 'none';
+ updateUnreadCount();
+ }
+ })
+ .catch(function(){});
+}
+
+function updateUnreadCount() {
+ const token = getApiToken();
+ const headers = { 'Accept': 'application/json' };
+ if (token) headers['Authorization'] = 'Bearer ' + token;
+
+ fetch('/api/notifications/unread-count', { headers })
+ .then(r => r.json())
+ .then(d => {
+ const count = d.unread_count || 0;
+ const badge = document.getElementById('notificationBadge');
+ if (count > 0) {
+ badge.textContent = count > 99 ? '99+' : count;
+ badge.style.display = 'inline-flex';
+ } else {
+ badge.style.display = 'none';
+ }
+ })
+ .catch(function(){});
+}
+
+function escapeHtml(str) {
+ const d = document.createElement('div');
+ d.textContent = str;
+ return d.innerHTML;
+}
+
+setInterval(updateUnreadCount, 60000);
+updateUnreadCount();
+
+function toggleServerList(e) {
+ e.preventDefault();
+ const item = e.currentTarget.closest('.nav-item-accordion');
+ const sub = document.getElementById('serverList');
+ const chevron = item.querySelector('.nav-chevron');
+ if (item.classList.contains('expanded')) {
+ item.classList.remove('expanded');
+ sub.style.maxHeight = '0';
+ return;
+ }
+ item.classList.add('expanded');
+ if (!serverListLoaded) {
+ serverListLoaded = true;
+ fetch('/sidebar/servers')
+ .then(r => r.json())
+ .then(d => {
+ if (d.success && d.data) {
+ let html = ' All Servers ';
+ d.data.forEach(s => {
+ const status = s.current_status === 'online' ? 'success' : s.current_status === 'offline' ? 'danger' : 'muted';
+ html += ''
+ + ' '
+ + escapeHtml(s.name) + ' ';
+ });
+ sub.innerHTML = html;
+ } else {
+ sub.innerHTML = 'No servers
';
+ }
+ sub.style.maxHeight = sub.scrollHeight + 'px';
+ })
+ .catch(() => {
+ sub.innerHTML = 'Failed to load
';
+ sub.style.maxHeight = sub.scrollHeight + 'px';
+ });
+ } else {
+ sub.style.maxHeight = sub.scrollHeight + 'px';
+ }
+}
diff --git a/public/assets/js/nginx-manager.js b/public/assets/js/nginx-manager.js
new file mode 100644
index 0000000..d2041ce
--- /dev/null
+++ b/public/assets/js/nginx-manager.js
@@ -0,0 +1,321 @@
+let currentEditFile = null;
+let editorDirty = false;
+let editorUndoStack = [];
+let editorRedoStack = [];
+let editorHistoryIndex = -1;
+
+function getCsrfToken() {
+ return document.querySelector('meta[name="csrf-token"]')?.content || '';
+}
+
+function updateLineNumbers() {
+ const ta = document.getElementById('editorContent');
+ const gutter = document.getElementById('editorGutter');
+ const lines = ta.value.split('\n').length;
+ const numbers = [];
+ for (let i = 1; i <= lines; i++) {
+ numbers.push('' + i + ' ');
+ }
+ gutter.innerHTML = numbers.join('');
+}
+
+function updateCursorPos() {
+ const ta = document.getElementById('editorContent');
+ const before = ta.value.substring(0, ta.selectionStart);
+ const lines = before.split('\n');
+ const line = lines.length;
+ const col = lines[lines.length - 1].length + 1;
+ document.getElementById('editorCursorPos').textContent = 'Ln ' + line + ', Col ' + col;
+}
+
+function highlightNginx(text) {
+ let h = text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/#(.*)$/gm, '')
+ .replace(/\b(server|location|listen|server_name|root|index|try_files|include|fastcgi_pass|proxy_pass|return|rewrite|set|if|error_page|ssl_certificate|ssl_certificate_key|ssl_protocols|ssl_ciphers|add_header|access_log|error_log|keepalive|gzip|proxy_set_header|proxy_redirect|client_max_body_size|autoindex)\b/g, '$1 ')
+ .replace(/\b(\d+)\b/g, '$1 ')
+ .replace(/\b(https?|fastcgi|unix):\/\/[^\s;{]+/g, '$& ')
+ .replace(/'(.*?)'/g, '\'$1\' ')
+ .replace(/(\$\w+)/g, '$1 ')
+ .replace(/\b(\w+\.\w+(?:\.\w+)*)\b/g, function(m) {
+ if (m.includes('com') || m.includes('org') || m.includes('net') || m.includes('io') || m.includes('local')) {
+ return '' + m + ' ';
+ }
+ return m;
+ });
+ return h;
+}
+
+function syncHighlight() {
+ const ta = document.getElementById('editorContent');
+ const code = document.getElementById('editorHighlightCode');
+ code.innerHTML = highlightNginx(ta.value) + '\n';
+}
+
+function pushHistory() {
+ const ta = document.getElementById('editorContent');
+ if (editorHistoryIndex >= 0 && editorUndoStack[editorHistoryIndex] === ta.value) return;
+ editorUndoStack = editorUndoStack.slice(0, editorHistoryIndex + 1);
+ editorUndoStack.push(ta.value);
+ editorRedoStack = [];
+ editorHistoryIndex = editorUndoStack.length - 1;
+ if (editorUndoStack.length > 100) {
+ editorUndoStack.shift();
+ editorHistoryIndex--;
+ }
+}
+
+function undoEditor() {
+ if (editorHistoryIndex <= 0) return;
+ editorRedoStack.push(editorUndoStack[editorHistoryIndex]);
+ editorHistoryIndex--;
+ const ta = document.getElementById('editorContent');
+ ta.value = editorUndoStack[editorHistoryIndex];
+ ta.dispatchEvent(new Event('input'));
+}
+
+function redoEditor() {
+ if (editorRedoStack.length === 0) return;
+ const val = editorRedoStack.pop();
+ editorUndoStack.push(val);
+ editorHistoryIndex++;
+ const ta = document.getElementById('editorContent');
+ ta.value = val;
+ ta.dispatchEvent(new Event('input'));
+}
+
+function nginxAction(action) {
+ const btn = event.target.closest('button');
+ const origText = btn.innerHTML;
+ btn.disabled = true;
+ btn.innerHTML = ' Working...';
+
+ fetch('/servers/' + serverId + '/nginx', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': getCsrfToken(),
+ },
+ body: 'action=' + action + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
+ })
+ .then(r => r.json())
+ .then(data => {
+ showToast(data.success ? 'success' : 'error', data.message);
+ if (data.output) {
+ document.getElementById('nginxResultMessage').textContent = data.message;
+ document.getElementById('nginxResultOutputText').textContent = data.output;
+ document.getElementById('nginxResultOutput').style.display = 'block';
+ document.getElementById('nginxResultModal').style.display = 'flex';
+ }
+ if (data.success && ['restart', 'reload', 'start', 'stop'].includes(action)) {
+ setTimeout(() => location.reload(), 1500);
+ }
+ })
+ .catch(err => showToast('error', 'Request failed: ' + err.message))
+ .finally(() => { btn.disabled = false; btn.innerHTML = origText; });
+}
+
+function editFile(file) {
+ currentEditFile = file;
+ editorUndoStack = [];
+ editorRedoStack = [];
+ editorHistoryIndex = -1;
+ editorDirty = false;
+ document.getElementById('editorFilePath').textContent = file;
+ document.getElementById('editorContent').value = 'Loading...';
+ document.getElementById('editorStatus').textContent = 'Loading...';
+ document.getElementById('editorDirtyIndicator').style.display = 'none';
+ document.getElementById('editorFileSize').textContent = '';
+ document.getElementById('nginxEditorModal').style.display = 'flex';
+ setTimeout(() => document.getElementById('editorContent').focus(), 100);
+
+ fetch('/servers/' + serverId + '/nginx/file?file=' + encodeURIComponent(file))
+ .then(r => r.json())
+ .then(data => {
+ if (data.success) {
+ const ta = document.getElementById('editorContent');
+ ta.value = data.content;
+ document.getElementById('editorFileSize').textContent = (data.content.length / 1024).toFixed(1) + ' KB';
+ document.getElementById('editorStatus').textContent = 'Ready';
+ document.getElementById('editorDirtyIndicator').style.display = 'none';
+ editorDirty = false;
+ updateLineNumbers();
+ syncHighlight();
+ pushHistory();
+ } else {
+ showToast('error', data.message);
+ closeEditor();
+ }
+ })
+ .catch(err => {
+ showToast('error', 'Failed to load file');
+ closeEditor();
+ });
+
+ const ta = document.getElementById('editorContent');
+ ta.oninput = function() {
+ editorDirty = true;
+ document.getElementById('editorStatus').textContent = 'Unsaved changes';
+ document.getElementById('editorDirtyIndicator').style.display = 'inline';
+ updateLineNumbers();
+ syncHighlight();
+ pushHistory();
+ };
+ ta.onscroll = function() {
+ document.getElementById('editorGutter').scrollTop = this.scrollTop;
+ document.getElementById('editorHighlight').scrollTop = this.scrollTop;
+ };
+ ta.onkeydown = function(e) {
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ const start = this.selectionStart;
+ const end = this.selectionEnd;
+ this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
+ this.selectionStart = this.selectionEnd = start + 4;
+ this.dispatchEvent(new Event('input'));
+ }
+ };
+ ta.onmouseup = ta.onkeyup = function() {
+ updateCursorPos();
+ };
+ ta.addEventListener('paste', function() {
+ setTimeout(() => {
+ this.dispatchEvent(new Event('input'));
+ updateCursorPos();
+ }, 10);
+ });
+}
+
+function saveFile() {
+ const content = document.getElementById('editorContent').value;
+ document.getElementById('editorStatus').textContent = 'Saving...';
+
+ fetch('/servers/' + serverId + '/nginx/file', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': getCsrfToken(),
+ },
+ body: 'file=' + encodeURIComponent(currentEditFile) + '&content=' + encodeURIComponent(content) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
+ })
+ .then(r => r.json())
+ .then(data => {
+ showToast(data.success ? 'success' : 'error', data.message);
+ if (data.success) {
+ editorDirty = false;
+ document.getElementById('editorStatus').textContent = 'Saved';
+ document.getElementById('editorDirtyIndicator').style.display = 'none';
+ closeEditor();
+ setTimeout(() => location.reload(), 500);
+ } else {
+ document.getElementById('editorStatus').textContent = 'Save failed';
+ }
+ })
+ .catch(err => {
+ showToast('error', 'Save request failed');
+ document.getElementById('editorStatus').textContent = 'Error';
+ });
+}
+
+function deleteSite(site) {
+ if (!confirm("Permanently delete site '" + site + "'?\nThis will remove both the config file and site directory contents.")) return;
+ if (!confirm("Are you sure? This cannot be undone.")) return;
+
+ fetch('/servers/' + serverId + '/nginx/site', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': getCsrfToken(),
+ },
+ body: '_method=DELETE&site=' + encodeURIComponent(site) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
+ })
+ .then(r => r.json())
+ .then(data => {
+ showToast(data.success ? 'success' : 'error', data.message);
+ if (data.success) setTimeout(() => location.reload(), 500);
+ })
+ .catch(err => showToast('error', 'Request failed: ' + err.message));
+}
+
+function toggleSite(site, enable) {
+ const action = enable ? 'enable' : 'disable';
+ if (!confirm((enable ? 'Enable' : 'Disable') + " site '" + site + "'?")) return;
+
+ fetch('/servers/' + serverId + '/nginx/toggle-site', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': getCsrfToken(),
+ },
+ body: 'site=' + encodeURIComponent(site) + '&enable=' + (enable ? '1' : '0') + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
+ })
+ .then(r => r.json())
+ .then(data => {
+ showToast(data.success ? 'success' : 'error', data.message);
+ if (data.success) setTimeout(() => location.reload(), 500);
+ })
+ .catch(err => showToast('error', 'Request failed: ' + err.message));
+}
+
+function closeEditor() {
+ if (editorDirty && !confirm('Discard unsaved changes?')) return;
+ document.getElementById('nginxEditorModal').style.display = 'none';
+ currentEditFile = null;
+ editorDirty = false;
+}
+
+function closeNginxModal() {
+ document.getElementById('nginxResultModal').style.display = 'none';
+ document.getElementById('nginxResultOutput').style.display = 'none';
+}
+
+function openNewSiteModal() {
+ document.getElementById('newSiteForm').reset();
+ document.getElementById('siteServerName').value = '';
+ document.getElementById('sitePhp').checked = true;
+ document.getElementById('siteEnable').checked = true;
+ document.getElementById('nginxNewSiteModal').style.display = 'flex';
+}
+
+function closeNewSiteModal() {
+ document.getElementById('nginxNewSiteModal').style.display = 'none';
+}
+
+function createSite(e) {
+ e.preventDefault();
+ const btn = document.getElementById('createSiteBtn');
+ btn.disabled = true;
+ btn.innerHTML = ' Creating...';
+
+ const formData = new URLSearchParams(new FormData(document.getElementById('newSiteForm')));
+ formData.append('_csrf_token', getCsrfToken());
+
+ fetch('/servers/' + serverId + '/nginx/site', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': getCsrfToken(),
+ },
+ body: formData.toString(),
+ })
+ .then(r => r.json())
+ .then(data => {
+ showToast(data.success ? 'success' : 'error', data.message);
+ if (data.success) {
+ closeNewSiteModal();
+ setTimeout(() => location.reload(), 800);
+ }
+ })
+ .catch(err => showToast('error', 'Request failed: ' + err.message))
+ .finally(() => { btn.disabled = false; btn.innerHTML = ' Create Site'; });
+}
+
+document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape') { closeEditor(); closeNginxModal(); }
+ if ((e.ctrlKey || e.metaKey) && e.key === 's' && currentEditFile) {
+ e.preventDefault();
+ saveFile();
+ }
+});
diff --git a/public/assets/js/notifications.js b/public/assets/js/notifications.js
new file mode 100644
index 0000000..28c2d6d
--- /dev/null
+++ b/public/assets/js/notifications.js
@@ -0,0 +1,20 @@
+function markAllRead() {
+ const token = document.querySelector('meta[name="api-token"]')?.getAttribute('content') || '';
+ const headers = { 'Accept': 'application/json' };
+ if (token) headers['Authorization'] = 'Bearer ' + token;
+
+ fetch('/api/notifications/read-all', { method: 'POST', headers })
+ .then(r => r.json())
+ .then(d => {
+ if (d.success) {
+ document.querySelectorAll('.notif-item').forEach(function(el) {
+ el.classList.remove('notif-unread');
+ el.classList.add('notif-read');
+ const dot = el.querySelector('.notif-dot');
+ if (dot) dot.remove();
+ });
+ location.reload();
+ }
+ })
+ .catch(function(){});
+}
diff --git a/public/assets/js/server-list.js b/public/assets/js/server-list.js
new file mode 100644
index 0000000..97a05c7
--- /dev/null
+++ b/public/assets/js/server-list.js
@@ -0,0 +1,30 @@
+function deleteServer(id, name) {
+ if (confirm('Are you sure you want to delete server "' + name + '"?\nThis action cannot be undone.')) {
+ const form = document.createElement('form');
+ form.method = 'POST';
+ form.action = '/servers/' + id + '/delete';
+ const csrf = document.createElement('input');
+ csrf.type = 'hidden';
+ csrf.name = '_csrf_token';
+ csrf.value = document.querySelector('meta[name="csrf-token"]')?.content || '';
+ form.appendChild(csrf);
+ document.body.appendChild(form);
+ form.submit();
+ }
+}
+
+document.getElementById('serverSearch')?.addEventListener('input', function(e) {
+ applyFilters();
+});
+
+document.getElementById('statusFilter')?.addEventListener('change', applyFilters);
+document.getElementById('groupFilter')?.addEventListener('change', applyFilters);
+
+function applyFilters() {
+ const search = document.getElementById('serverSearch')?.value || '';
+ const status = document.getElementById('statusFilter')?.value || '';
+ const group = document.getElementById('groupFilter')?.value || '';
+ window.location.href = '/servers?search=' + encodeURIComponent(search) +
+ '&status=' + encodeURIComponent(status) +
+ '&group=' + encodeURIComponent(group);
+}
diff --git a/public/assets/js/server-permissions.js b/public/assets/js/server-permissions.js
new file mode 100644
index 0000000..ef6eda0
--- /dev/null
+++ b/public/assets/js/server-permissions.js
@@ -0,0 +1,40 @@
+function createPermissionRow(type, value, description) {
+ const idx = permIndex++;
+ const container = document.getElementById('permissions-container');
+ const div = document.createElement('div');
+ div.className = 'permission-row';
+ div.dataset.index = idx;
+ div.innerHTML = `
+
+ Sudo (passwordless)
+ Command
+ File Read
+ File Write
+ Custom Command
+
+
+
+
+ `;
+ container.appendChild(div);
+}
+
+function removePermissionRow(btn) {
+ btn.closest('.permission-row').remove();
+}
+
+function addPresetPermission(type, value, description) {
+ createPermissionRow(type, value, description);
+}
+
+document.getElementById('addPermissionBtn').addEventListener('click', function() {
+ createPermissionRow('command', '', '');
+});
+
+function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
diff --git a/public/assets/js/server-processes.js b/public/assets/js/server-processes.js
new file mode 100644
index 0000000..a9637f0
--- /dev/null
+++ b/public/assets/js/server-processes.js
@@ -0,0 +1,62 @@
+let pendingPid = null;
+let pendingBtn = null;
+
+function getCsrfToken() {
+ return document.querySelector('meta[name="csrf-token"]')?.content || '';
+}
+
+function killProcess(pid, btn) {
+ pendingPid = pid;
+ pendingBtn = btn;
+ document.getElementById('killPidDisplay').textContent = pid;
+ document.getElementById('killModal').style.display = 'flex';
+}
+
+function closeKillModal() {
+ pendingPid = null;
+ pendingBtn = null;
+ document.getElementById('killForce').checked = false;
+ document.getElementById('killModal').style.display = 'none';
+}
+
+function confirmKill() {
+ if (!pendingPid) return;
+
+ const signal = document.getElementById('killForce').checked ? 9 : 15;
+ const btn = document.getElementById('confirmKillBtn');
+ const originalText = btn.innerHTML;
+ btn.disabled = true;
+ btn.innerHTML = ' Killing...';
+
+ fetch('/servers/' + serverId + '/processes/kill', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': getCsrfToken(),
+ },
+ body: 'pid=' + pendingPid + '&signal=' + signal + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
+ })
+ .then(r => r.json())
+ .then(data => {
+ showToast(data.success ? 'success' : 'error', data.message);
+ if (data.success && pendingBtn) {
+ const row = pendingBtn.closest('tr');
+ row.style.opacity = '0.4';
+ pendingBtn.disabled = true;
+ pendingBtn.innerHTML = ' Killed';
+ }
+ closeKillModal();
+ })
+ .catch(err => {
+ showToast('error', 'Request failed: ' + err.message);
+ closeKillModal();
+ })
+ .finally(() => {
+ btn.disabled = false;
+ btn.innerHTML = originalText;
+ });
+}
+
+document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape') closeKillModal();
+});
diff --git a/public/assets/js/server-services.js b/public/assets/js/server-services.js
new file mode 100644
index 0000000..a360e3a
--- /dev/null
+++ b/public/assets/js/server-services.js
@@ -0,0 +1,86 @@
+function applyFilters() {
+ const nameQ = document.getElementById('filterName').value.toLowerCase();
+ const statusQ = document.getElementById('filterStatus').value;
+ const filtered = allServices.filter(s => {
+ if (nameQ && !s.name.toLowerCase().includes(nameQ)) return false;
+ if (statusQ && s.status !== statusQ) return false;
+ return true;
+ });
+ renderPage(filtered, 1);
+}
+
+function renderPage(list, page) {
+ const totalPages = Math.ceil(list.length / perPage) || 1;
+ const start = (page - 1) * perPage;
+ const slice = list.slice(start, start + perPage);
+ const tbody = document.getElementById('servicesBody');
+ let html = '';
+ slice.forEach(s => {
+ const isActive = s.status === 'active';
+ const isInactive = s.status === 'inactive';
+ html += '';
+ html += '' + escHtml(s.name) + ' ';
+ html += '' + s.status + ' ';
+ html += '';
+ if (!isActive) html += ' ';
+ if (!isInactive) html += ' ';
+ html += ' ';
+ html += ' ';
+ if (!s.enabled) html += ' ';
+ if (s.enabled) html += ' ';
+ html += ' ';
+ });
+ if (!html) html = 'No services match the filter. ';
+ tbody.innerHTML = html;
+ renderPagination(list, page);
+}
+
+function renderPagination(list, page) {
+ const totalPages = Math.ceil(list.length / perPage) || 1;
+ const container = document.getElementById('servicesPagination');
+ let html = '';
+ for (let p = 1; p <= totalPages && p <= 15; p++) {
+ html += '' + p + ' ';
+ }
+ if (totalPages > 15) html += '... ';
+ container.innerHTML = html + ' ' + list.length + ' services ';
+ container.querySelectorAll('button').forEach(btn => {
+ const p = parseInt(btn.textContent);
+ if (!isNaN(p)) {
+ btn.onclick = function() { renderPage(list, p); };
+ }
+ });
+}
+
+applyFilters();
+
+function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; }
+
+function serviceAction(action, service) {
+ fetch('/servers/' + serverId + '/services', {
+ method:'POST',
+ headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
+ body:'action='+action+'&service='+encodeURIComponent(service)+'&_csrf_token='+encodeURIComponent(getCsrfToken()),
+ })
+ .then(r=>r.json()).then(d=>{
+ showToast(d.success?'success':'error', d.message);
+ if (d.output) {
+ document.getElementById('srTitle').textContent = d.success ? 'Success' : 'Error';
+ document.getElementById('srMessage').textContent = d.message;
+ document.getElementById('srOutputText').textContent = d.output;
+ document.getElementById('srOutput').style.display = 'block';
+ document.getElementById('serviceResultModal').style.display = 'flex';
+ }
+ setTimeout(() => location.reload(), 1500);
+ })
+ .catch(err => showToast('error', 'Request failed'));
+}
+
+function closeSrModal() {
+ document.getElementById('serviceResultModal').style.display = 'none';
+ document.getElementById('srOutput').style.display = 'none';
+}
+
+function escHtml(s) { const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
+
+document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeSrModal(); });
diff --git a/public/assets/js/server-show.js b/public/assets/js/server-show.js
new file mode 100644
index 0000000..7ba0230
--- /dev/null
+++ b/public/assets/js/server-show.js
@@ -0,0 +1,323 @@
+let metricsChart = null;
+
+function initChart(data) {
+ const container = document.getElementById('metricsChart').parentNode;
+ const oldCanvas = document.getElementById('metricsChart');
+ const newCanvas = document.createElement('canvas');
+ newCanvas.id = 'metricsChart';
+ newCanvas.height = 300;
+ container.replaceChild(newCanvas, oldCanvas);
+ const ctx = newCanvas.getContext('2d');
+ const labels = data.map(m => {
+ const d = new Date(m.created_at);
+ return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
+ });
+
+ metricsChart = new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels: labels,
+ datasets: [
+ {
+ label: 'CPU',
+ data: data.map(m => m.cpu_usage),
+ borderColor: '#3b82f6',
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
+ fill: true,
+ tension: 0.3,
+ pointRadius: 1,
+ },
+ {
+ label: 'RAM',
+ data: data.map(m => m.ram_usage),
+ borderColor: '#22c55e',
+ backgroundColor: 'rgba(34, 197, 94, 0.1)',
+ fill: true,
+ tension: 0.3,
+ pointRadius: 1,
+ },
+ {
+ label: 'Disk',
+ data: data.map(m => m.disk_usage),
+ borderColor: '#8b5cf6',
+ backgroundColor: 'rgba(139, 92, 246, 0.1)',
+ fill: true,
+ tension: 0.3,
+ pointRadius: 1,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: {
+ intersect: false,
+ mode: 'index',
+ },
+ plugins: {
+ legend: {
+ labels: {
+ color: '#9ca3af',
+ font: { family: "'Inter', sans-serif" },
+ boxWidth: 12,
+ padding: 16,
+ },
+ },
+ tooltip: {
+ backgroundColor: '#1a1d2b',
+ borderColor: '#2a2d3d',
+ borderWidth: 1,
+ titleColor: '#e1e4ed',
+ bodyColor: '#9ca3af',
+ padding: 10,
+ cornerRadius: 8,
+ callbacks: {
+ label: function(ctx) {
+ return ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(1) + '%';
+ },
+ },
+ },
+ },
+ scales: {
+ x: {
+ ticks: {
+ color: '#6b7280',
+ maxTicksLimit: 12,
+ font: { size: 11 },
+ },
+ grid: {
+ color: 'rgba(255,255,255,0.04)',
+ },
+ },
+ y: {
+ min: 0,
+ max: 100,
+ ticks: {
+ color: '#6b7280',
+ font: { size: 11 },
+ callback: function(v) { return v + '%'; },
+ },
+ grid: {
+ color: 'rgba(255,255,255,0.04)',
+ },
+ },
+ },
+ },
+ });
+}
+
+if (historicalData.length > 0) {
+ initChart(historicalData);
+}
+
+let pendingAgentAction = null;
+
+function showAgentModal(action) {
+ pendingAgentAction = action;
+ const modal = document.getElementById('agentConfirmModal');
+ const title = document.getElementById('agentModalTitle');
+ const msg = document.getElementById('agentModalMessage');
+ const btn = document.getElementById('agentModalConfirmBtn');
+
+ if (action === 'install') {
+ title.textContent = 'Install Monitoring Agent';
+ msg.innerHTML = 'This will install the monitoring agent on serverName via SSH.';
+ btn.textContent = 'Install';
+ btn.className = 'btn btn-primary';
+ } else {
+ title.textContent = 'Uninstall Monitoring Agent';
+ msg.innerHTML = 'This will uninstall the monitoring agent from serverName via SSH. The service will be stopped and all agent files removed.';
+ btn.textContent = 'Uninstall';
+ btn.className = 'btn btn-danger';
+ }
+
+ modal.style.display = 'flex';
+ modal.onclick = function(e) {
+ if (e.target === modal) closeAgentModal();
+ };
+}
+
+function closeAgentModal() {
+ document.getElementById('agentConfirmModal').style.display = 'none';
+ pendingAgentAction = null;
+}
+
+function confirmAgentAction() {
+ if (!pendingAgentAction) return;
+
+ const action = pendingAgentAction;
+ const btn = document.getElementById('agentModalConfirmBtn');
+ btn.disabled = true;
+ btn.textContent = action === 'install' ? 'Installing...' : 'Uninstalling...';
+
+ closeAgentModal();
+
+ fetch('/servers/serverId/agent/' + action, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': getCsrfToken(),
+ },
+ body: '_csrf_token=' + encodeURIComponent(getCsrfToken()),
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.success) {
+ showToast('success', action === 'install' ? 'Agent installed successfully.' : 'Agent uninstalled successfully.');
+ setTimeout(() => location.reload(), 1500);
+ } else {
+ let msg = data.error || (action === 'install' ? 'Installation failed.' : 'Uninstallation failed.');
+ if (data.output) msg += ' Output: ' + data.output.replace(/\n/g, ' | ');
+ showToast('error', msg);
+ }
+ })
+ .catch(err => {
+ showToast('error', 'Request failed: ' + err.message);
+ })
+ .finally(() => {
+ btn.disabled = false;
+ btn.textContent = action === 'install' ? 'Install' : 'Uninstall';
+ });
+}
+
+function copyAgentKey() {
+ const key = document.getElementById('agentKeyFull').value;
+ navigator.clipboard.writeText(key).then(() => {
+ showToast('success', 'Agent key copied to clipboard.');
+ }).catch(() => {
+ showToast('error', 'Failed to copy agent key.');
+ });
+}
+
+function regenerateAgentKey() {
+ if (!confirm('Regenerate agent key? The current key will stop working immediately.')) return;
+
+ fetch('/servers/serverId/agent/regenerate-key', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': getCsrfToken(),
+ },
+ body: '_csrf_token=' + encodeURIComponent(getCsrfToken()),
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.success) {
+ showToast('success', 'Agent key regenerated.');
+ setTimeout(() => location.reload(), 1500);
+ } else {
+ showToast('error', data.error || 'Failed to regenerate key.');
+ }
+ })
+ .catch(err => showToast('error', 'Request failed: ' + err.message));
+}
+
+function getCsrfToken() {
+ return document.querySelector('meta[name="csrf-token"]')?.content || '';
+}
+
+function serverAction(action) {
+ if (action === 'shutdown' || action === 'reboot') {
+ if (!confirm('Are you sure you want to ' + action + ' this server?')) return;
+ }
+
+ fetch('/servers/serverId/' + action, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': getCsrfToken(),
+ },
+ body: '_csrf_token=' + encodeURIComponent(getCsrfToken()),
+ })
+ .then(r => r.json())
+ .then(data => {
+ showToast(data.success ? 'success' : 'error', data.message);
+ })
+ .catch(err => showToast('error', 'Request failed: ' + err.message));
+}
+
+function restartService() {
+ const service = prompt('Enter service name to restart (e.g., nginx, mysql, apache2):');
+ if (!service) return;
+
+ fetch('/servers/serverId/restart-service', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': getCsrfToken(),
+ },
+ body: 'service=' + encodeURIComponent(service) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
+ })
+ .then(r => r.json())
+ .then(data => {
+ showToast(data.success ? 'success' : 'error', data.message);
+ })
+ .catch(err => showToast('error', 'Request failed: ' + err.message));
+}
+
+function refreshMetrics() {
+ fetch('/servers/serverId/metrics')
+ .then(r => r.json())
+ .then(data => {
+ if (data.success && data.data) {
+ const m = data.data;
+ document.getElementById('cpuMetric').textContent = (m.cpu_usage || '-') + '%';
+ document.getElementById('ramMetric').textContent = (m.ram_usage || '-') + '%';
+ document.getElementById('diskMetric').textContent = (m.disk_usage || '-') + '%';
+ document.getElementById('loadMetric').textContent = m.load_average || '-';
+ document.getElementById('uptimeMetric').textContent = m.uptime || '-';
+ if (metricsChart && metricsChart.data.labels.length > 0) {
+ const now = new Date();
+ const label = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0');
+ metricsChart.data.labels.push(label);
+ metricsChart.data.datasets[0].data.push(m.cpu_usage);
+ metricsChart.data.datasets[1].data.push(m.ram_usage);
+ metricsChart.data.datasets[2].data.push(m.disk_usage);
+ if (metricsChart.data.labels.length > 48) {
+ metricsChart.data.labels.shift();
+ metricsChart.data.datasets.forEach(ds => ds.data.shift());
+ }
+ metricsChart.update('none');
+ }
+ }
+ });
+}
+
+setInterval(refreshMetrics, 30000);
+
+function showThresholdModal() {
+ document.getElementById('thresholdModal').style.display = 'flex';
+ document.getElementById('thresholdModal').onclick = function(e) {
+ if (e.target === this) closeThresholdModal();
+ };
+}
+
+function closeThresholdModal() {
+ document.getElementById('thresholdModal').style.display = 'none';
+}
+
+function saveThresholds() {
+ const form = document.getElementById('thresholdForm');
+ const data = new URLSearchParams(new FormData(form));
+ data.append('_csrf_token', getCsrfToken());
+
+ document.querySelector('#thresholdModal .btn-primary').disabled = true;
+
+ fetch('/servers/serverId/thresholds', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': getCsrfToken(),
+ },
+ body: data.toString(),
+ })
+ .then(r => r.json())
+ .then(d => {
+ showToast(d.success ? 'success' : 'error', d.message ?? d.error);
+ if (d.success) closeThresholdModal();
+ })
+ .catch(e => showToast('error', 'Request failed: ' + e.message))
+ .finally(() => {
+ document.querySelector('#thresholdModal .btn-primary').disabled = false;
+ });
+}
diff --git a/public/assets/js/server-ssl.js b/public/assets/js/server-ssl.js
new file mode 100644
index 0000000..9c09d6b
--- /dev/null
+++ b/public/assets/js/server-ssl.js
@@ -0,0 +1,73 @@
+function getCsrfToken() {
+ return document.querySelector('meta[name="csrf-token"]')?.content || '';
+}
+
+function sslAction(action, extraData) {
+ const data = 'action=' + action + '&_csrf_token=' + encodeURIComponent(getCsrfToken());
+ const body = extraData ? data + '&' + extraData : data;
+
+ fetch('/servers/' + serverId + '/ssl', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': getCsrfToken(),
+ },
+ body: body,
+ })
+ .then(r => r.json())
+ .then(data => {
+ showToast(data.success ? 'success' : 'error', data.message);
+ if (data.output) {
+ document.getElementById('sslResultMessage').textContent = data.message || '';
+ document.getElementById('sslResultOutputText').textContent = data.output;
+ document.getElementById('sslResultOutput').style.display = 'block';
+ document.getElementById('sslResultModal').style.display = 'flex';
+ }
+ if (action === 'install' && data.success) {
+ setTimeout(() => location.reload(), 1500);
+ }
+ if (['renew', 'delete'].includes(action) && data.success) {
+ setTimeout(() => location.reload(), 1000);
+ }
+ })
+ .catch(err => showToast('error', 'Request failed: ' + err.message));
+}
+
+function openRequestModal() {
+ document.getElementById('sslRequestForm').reset();
+ document.getElementById('sslRequestModal').style.display = 'flex';
+}
+
+function closeRequestModal() {
+ document.getElementById('sslRequestModal').style.display = 'none';
+}
+
+function requestCert(e) {
+ e.preventDefault();
+ const btn = document.getElementById('requestCertBtn');
+ btn.disabled = true;
+ btn.innerHTML = ' Requesting...';
+
+ const domains = document.getElementById('sslDomains').value;
+ const email = document.getElementById('sslEmail').value;
+
+ sslAction('request', 'domains=' + encodeURIComponent(domains) + '&email=' + encodeURIComponent(email));
+
+ btn.disabled = false;
+ btn.innerHTML = ' Request Certificate';
+ closeRequestModal();
+}
+
+function deleteCert(name) {
+ if (!confirm("Delete certificate '" + name + "'?")) return;
+ sslAction('delete', 'cert_name=' + encodeURIComponent(name));
+}
+
+function closeSslResultModal() {
+ document.getElementById('sslResultModal').style.display = 'none';
+ document.getElementById('sslResultOutput').style.display = 'none';
+}
+
+document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape') { closeRequestModal(); closeSslResultModal(); }
+});
diff --git a/public/assets/js/system-users.js b/public/assets/js/system-users.js
new file mode 100644
index 0000000..3f1e340
--- /dev/null
+++ b/public/assets/js/system-users.js
@@ -0,0 +1,42 @@
+function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; }
+
+function showAddUser() { document.getElementById('addUserModal').style.display = 'flex'; }
+function showAddGroup() { document.getElementById('addGroupModal').style.display = 'flex'; }
+function closeModal(id) { document.getElementById(id).style.display = 'none'; }
+
+function doAddUser() {
+ const data = 'action=add&username=' + encodeURIComponent(document.getElementById('suUser').value)
+ + '&password=' + encodeURIComponent(document.getElementById('suPass').value)
+ + '&groups=' + encodeURIComponent(document.getElementById('suGroups').value)
+ + '&create_home=' + (document.getElementById('suHome').checked ? '1' : '0')
+ + '&_csrf_token=' + encodeURIComponent(getCsrfToken());
+ fetch('/servers/' + serverId + '/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:data})
+ .then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
+ closeModal('addUserModal');
+}
+
+function doAddGroup() {
+ const data = 'action=addgroup&group=' + encodeURIComponent(document.getElementById('sgGroup').value) + '&_csrf_token=' + encodeURIComponent(getCsrfToken());
+ fetch('/servers/' + serverId + '/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:data})
+ .then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
+ closeModal('addGroupModal');
+}
+
+function lockUser(u) {
+ if(!confirm('Lock user \''+u+'\'?'))return;
+ fetch('/servers/' + serverId + '/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:'action=lock&username='+encodeURIComponent(u)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
+ .then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
+}
+
+function unlockUser(u) {
+ fetch('/servers/' + serverId + '/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:'action=unlock&username='+encodeURIComponent(u)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
+ .then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
+}
+
+function deleteUser(u) {
+ if(!confirm('Delete user \''+u+'\'?\nThis cannot be undone.'))return;
+ fetch('/servers/' + serverId + '/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:'action=delete&username='+encodeURIComponent(u)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
+ .then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
+}
+
+document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeModal('addUserModal');closeModal('addGroupModal')}});
diff --git a/public/assets/js/team-index.js b/public/assets/js/team-index.js
new file mode 100644
index 0000000..187d570
--- /dev/null
+++ b/public/assets/js/team-index.js
@@ -0,0 +1,59 @@
+const allCards = document.querySelectorAll('.team-card');
+
+function filterTeams(query) {
+ const q = query.toLowerCase().trim();
+ const grid = document.getElementById('teamsGrid');
+ const empty = document.getElementById('emptyState');
+ const clearBtn = document.getElementById('searchClear');
+ let visible = 0;
+
+ clearBtn.style.display = q ? 'block' : 'none';
+
+ if (grid) {
+ allCards.forEach(function(card) {
+ const name = card.dataset.name || '';
+ const desc = card.dataset.desc || '';
+ const match = !q || name.includes(q) || desc.includes(q);
+ card.style.display = match ? '' : 'none';
+ if (match) visible++;
+ });
+
+ if (visible === 0) {
+ if (!empty) {
+ const container = document.getElementById('teamsContainer');
+ const div = document.createElement('div');
+ div.className = 'empty-state';
+ div.id = 'emptyState';
+ div.innerHTML = 'No teams match your search.
';
+ container.appendChild(div);
+ } else {
+ empty.querySelector('p').textContent = 'No teams match your search.';
+ empty.style.display = '';
+ }
+ } else {
+ if (empty) empty.style.display = 'none';
+ }
+ }
+
+ const url = q ? '?search=' + encodeURIComponent(query) : window.location.pathname;
+ window.history.replaceState({}, '', url);
+}
+
+document.getElementById('teamSearch')?.addEventListener('input', function() {
+ filterTeams(this.value);
+});
+
+document.addEventListener('DOMContentLoaded', function() {
+ const searchInput = document.getElementById('teamSearch');
+ if (searchInput && searchInput.value) {
+ filterTeams(searchInput.value);
+ }
+});
+
+function deleteTeam(id, name) {
+ if (confirm('Delete team "' + name + '"?\nThis will remove access for all members.')) {
+ const form = document.getElementById('deleteForm');
+ form.action = '/teams/' + id + '/delete';
+ form.submit();
+ }
+}
diff --git a/public/assets/js/team-show.js b/public/assets/js/team-show.js
new file mode 100644
index 0000000..c4b679e
--- /dev/null
+++ b/public/assets/js/team-show.js
@@ -0,0 +1,187 @@
+function getCsrfToken() {
+ return document.querySelector('meta[name="csrf-token"]')?.content || '';
+}
+
+function editTeam() {
+ const name = prompt('Team name:', teamName);
+ if (!name) return;
+ const desc = prompt('Description:', teamDescription);
+
+ const form = new FormData();
+ form.append('name', name);
+ form.append('description', desc || '');
+ form.append('_csrf_token', getCsrfToken());
+
+ fetch('/teams/' + teamId + '/update', {
+ method: 'POST',
+ headers: { 'X-CSRF-Token': getCsrfToken() },
+ body: form,
+ })
+ .then(r => r.json())
+ .then(res => {
+ if (res.success) { showToast('success', res.message); location.reload(); }
+ else { showToast('error', res.message); }
+ });
+}
+
+function deleteTeam() {
+ if (!confirm('Delete this team? Members will lose access to assigned servers.')) return;
+ document.getElementById('postForm').action = '/teams/' + teamId + '/delete';
+ document.getElementById('postForm').submit();
+}
+
+let searchTimeout = null;
+let selectedUserId = null;
+
+document.getElementById('addUserInput').addEventListener('input', function() {
+ clearTimeout(searchTimeout);
+ const q = this.value.trim();
+ selectedUserId = null;
+
+ if (q.length < 2) {
+ document.getElementById('userDropdown').classList.remove('active');
+ return;
+ }
+
+ searchTimeout = setTimeout(function() {
+ fetch('/teams/search-users?q=' + encodeURIComponent(q), {
+ headers: { 'X-CSRF-Token': getCsrfToken() }
+ })
+ .then(r => r.json())
+ .then(data => {
+ const dropdown = document.getElementById('userDropdown');
+ dropdown.innerHTML = '';
+ if (data.users && data.users.length > 0) {
+ data.users.forEach(function(u) {
+ const item = document.createElement('div');
+ item.className = 'autocomplete-item';
+ item.innerHTML = '' + escapeHtml(u.username) + ' ' + escapeHtml(u.email) + ' ';
+ item.dataset.id = u.id;
+ item.dataset.username = u.username;
+ item.addEventListener('click', function() {
+ selectUser(u.id, u.username);
+ });
+ dropdown.appendChild(item);
+ });
+ dropdown.classList.add('active');
+ } else {
+ dropdown.innerHTML = 'No users found
';
+ dropdown.classList.add('active');
+ }
+ })
+ .catch(function() {
+ document.getElementById('userDropdown').classList.remove('active');
+ });
+ }, 300);
+});
+
+document.addEventListener('click', function(e) {
+ if (!e.target.closest('.autocomplete')) {
+ document.getElementById('userDropdown').classList.remove('active');
+ }
+});
+
+function selectUser(id, username) {
+ selectedUserId = id;
+ document.getElementById('addUserInput').value = username;
+ document.getElementById('userDropdown').classList.remove('active');
+
+ const form = new FormData();
+ form.append('user_id', id);
+ form.append('_csrf_token', getCsrfToken());
+
+ fetch('/teams/' + teamId + '/add-user', {
+ method: 'POST',
+ headers: { 'X-CSRF-Token': getCsrfToken() },
+ body: form,
+ })
+ .then(r => r.json())
+ .then(res => {
+ if (res.success) {
+ showToast('success', username + ' added to team');
+ location.reload();
+ } else {
+ showToast('error', res.message);
+ document.getElementById('addUserInput').value = '';
+ }
+ });
+}
+
+function removeMember(userId, username) {
+ if (!confirm('Remove "' + username + '" from this team?')) return;
+
+ const form = new FormData();
+ form.append('user_id', userId);
+ form.append('_csrf_token', getCsrfToken());
+
+ fetch('/teams/' + teamId + '/remove-user', {
+ method: 'POST',
+ headers: { 'X-CSRF-Token': getCsrfToken() },
+ body: form,
+ })
+ .then(r => r.json())
+ .then(res => {
+ if (res.success) { showToast('success', res.message); location.reload(); }
+ else { showToast('error', res.message); }
+ });
+}
+
+function addServer(e) {
+ e.preventDefault();
+ const serverId = document.getElementById('addServerId').value;
+ const role = document.getElementById('addServerRole').value;
+ if (!serverId) return;
+
+ const form = new FormData();
+ form.append('server_id', serverId);
+ form.append('role', role);
+ form.append('_csrf_token', getCsrfToken());
+
+ fetch('/teams/' + teamId + '/add-server', {
+ method: 'POST',
+ headers: { 'X-CSRF-Token': getCsrfToken() },
+ body: form,
+ })
+ .then(r => r.json())
+ .then(res => {
+ if (res.success) { showToast('success', res.message); location.reload(); }
+ else { showToast('error', res.message); }
+ });
+}
+
+function removeServer(serverId, name) {
+ if (!confirm('Remove "' + name + '" from this team?')) return;
+
+ const form = new FormData();
+ form.append('server_id', serverId);
+ form.append('_csrf_token', getCsrfToken());
+
+ fetch('/teams/' + teamId + '/remove-server', {
+ method: 'POST',
+ headers: { 'X-CSRF-Token': getCsrfToken() },
+ body: form,
+ })
+ .then(r => r.json())
+ .then(res => {
+ if (res.success) { showToast('success', res.message); location.reload(); }
+ else { showToast('error', res.message); }
+ });
+}
+
+function updateServerRole(serverId, role) {
+ const form = new FormData();
+ form.append('server_id', serverId);
+ form.append('role', role);
+ form.append('_csrf_token', getCsrfToken());
+
+ fetch('/teams/' + teamId + '/update-server-role', {
+ method: 'POST',
+ headers: { 'X-CSRF-Token': getCsrfToken() },
+ body: form,
+ })
+ .then(r => r.json())
+ .then(res => {
+ if (res.success) { showToast('success', 'Role updated to ' + role); }
+ else { showToast('error', res.message); }
+ });
+}
diff --git a/public/assets/js/terminal.js b/public/assets/js/terminal.js
new file mode 100644
index 0000000..bbcd415
--- /dev/null
+++ b/public/assets/js/terminal.js
@@ -0,0 +1,110 @@
+function getCsrfToken() {
+ return document.querySelector('meta[name="csrf-token"]')?.content || '';
+}
+
+const terminalInput = document.getElementById('terminalInput');
+const terminalOutput = document.getElementById('terminalOutput');
+const connectionStatus = document.getElementById('connectionStatus');
+
+terminalInput.addEventListener('keydown', function(e) {
+ if (e.key === 'Enter') {
+ executeCommand();
+ }
+});
+
+function executeCommand() {
+ const command = terminalInput.value.trim();
+ if (!command) return;
+
+ appendTerminalLine(command, 'command');
+
+ terminalInput.value = '';
+ terminalInput.disabled = true;
+ document.getElementById('executeBtn').disabled = true;
+
+ updateStatus('executing', 'Executing...');
+
+ fetch('/terminal/' + serverId + '/execute', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': getCsrfToken(),
+ },
+ body: 'command=' + encodeURIComponent(command) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
+ })
+ .then(r => r.json())
+ .then(data => {
+ terminalInput.disabled = false;
+ document.getElementById('executeBtn').disabled = false;
+ terminalInput.focus();
+
+ if (data.success) {
+ if (data.output) {
+ appendTerminalLine(data.output, 'output');
+ }
+ if (data.stderr) {
+ appendTerminalLine(data.stderr, 'error');
+ }
+ updateStatus('connected', 'Connected');
+ } else {
+ appendTerminalLine(data.message || 'Command failed', 'error');
+ updateStatus('error', 'Error');
+ }
+
+ const terminalBody = document.getElementById('terminalOutput');
+ terminalBody.scrollTop = terminalBody.scrollHeight;
+ })
+ .catch(err => {
+ terminalInput.disabled = false;
+ document.getElementById('executeBtn').disabled = false;
+ appendTerminalLine('Connection error: ' + err.message, 'error');
+ updateStatus('error', 'Error');
+ });
+}
+
+function appendTerminalLine(text, type) {
+ const line = document.createElement('div');
+ line.className = 'terminal-line terminal-' + type;
+ if (type === 'command') {
+ line.innerHTML = '$ ' + escapeHtml(text);
+ } else {
+ line.innerHTML = '' + escapeHtml(text) + ' ';
+ }
+ terminalOutput.appendChild(line);
+ terminalOutput.scrollTop = terminalOutput.scrollHeight;
+}
+
+function runQuickCmd(cmd) {
+ terminalInput.value = cmd;
+ executeCommand();
+}
+
+function clearTerminal() {
+ terminalOutput.innerHTML = '';
+}
+
+function clearHistory() {
+ if (!confirm('Clear all command history for this server?')) return;
+
+ fetch('/terminal/' + serverId + '/clear-history', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': getCsrfToken(),
+ },
+ body: '_csrf_token=' + encodeURIComponent(getCsrfToken()),
+ })
+ .then(r => r.json())
+ .then(data => {
+ showToast(data.success ? 'success' : 'error', data.message);
+ if (data.success) location.reload();
+ });
+}
+
+function updateStatus(status, text) {
+ const dot = connectionStatus.querySelector('.status-dot');
+ dot.className = 'status-dot ' + status;
+ connectionStatus.lastChild.textContent = ' ' + text;
+}
+
+terminalInput.focus();
diff --git a/public/index.php b/public/index.php
index 6c70432..447db00 100755
--- a/public/index.php
+++ b/public/index.php
@@ -23,12 +23,9 @@ try {
$app->run();
} catch (\Throwable $e) {
http_response_code(500);
- if (($_ENV['APP_DEBUG'] ?? false) === 'true') {
- echo 'Server Error ';
- echo '' . htmlspecialchars($e->getMessage()) . ' ';
- echo '' . $e->getTraceAsString() . ' ';
- } else {
- echo '500 Internal Server Error ';
- echo 'An unexpected error occurred. Please check the logs.
';
+ if (isset($app)) {
+ $app->getView()->error(500);
}
+ echo '500 Internal Server Error ';
+ echo 'An unexpected error occurred.
';
}
diff --git a/src/Middleware/AuthMiddleware.php b/src/Middleware/AuthMiddleware.php
index a5955a4..8038b04 100755
--- a/src/Middleware/AuthMiddleware.php
+++ b/src/Middleware/AuthMiddleware.php
@@ -29,7 +29,7 @@ class AuthMiddleware
if (!headers_sent()) {
header("Location: {$url}");
} else {
- echo '';
+ echo ' ';
}
exit;
}
diff --git a/src/Middleware/RateLimitMiddleware.php b/src/Middleware/RateLimitMiddleware.php
index 699b4cd..206c3b7 100755
--- a/src/Middleware/RateLimitMiddleware.php
+++ b/src/Middleware/RateLimitMiddleware.php
@@ -75,210 +75,12 @@ class RateLimitMiddleware
http_response_code(429);
header("Retry-After: {$retryAfter}");
- $retryMinutes = (int) ceil($retryAfter / 60);
- $retryAfterMs = $retryAfter * 1000;
- $requestUri = htmlspecialchars($_SERVER['REQUEST_URI'] ?? '/', ENT_QUOTES, 'UTF-8');
-
- echo <<
-
-
-
-
-429 Too Many Requests — ServerManager
-
-
-
-
-
-
-
-
429
-
Too Many Requests
-
You have made too many requests in a short period. Please wait before trying again.
-
-
Retry in
-
{$retryMinutes}:00
-
-
-
Try Again
-
-
Go to Dashboard
-
-
-
-
-HTML;
+ echo App::getInstance()->getView()->renderView('errors.429', [
+ 'retryAfter' => $retryAfter,
+ 'retryMinutes' => (int) ceil($retryAfter / 60),
+ 'retryAfterMs' => $retryAfter * 1000,
+ 'requestUri' => htmlspecialchars($_SERVER['REQUEST_URI'] ?? '/', ENT_QUOTES, 'UTF-8'),
+ ]);
exit;
}
diff --git a/views/admin/audit.php b/views/admin/audit.php
index 5450c7b..6f3016c 100755
--- a/views/admin/audit.php
+++ b/views/admin/audit.php
@@ -187,45 +187,4 @@ function selected(string $value, string $compare): string {
-
+
diff --git a/views/admin/notifications.php b/views/admin/notifications.php
index 4dd5581..86185f4 100644
--- a/views/admin/notifications.php
+++ b/views/admin/notifications.php
@@ -139,45 +139,4 @@ $data = $notifications['data'] ?? [];
-
+
diff --git a/views/admin/security.php b/views/admin/security.php
index 8e1dea8..76576f3 100755
--- a/views/admin/security.php
+++ b/views/admin/security.php
@@ -102,22 +102,4 @@
-
+
diff --git a/views/admin/users.php b/views/admin/users.php
index d2cf2a3..16d2319 100755
--- a/views/admin/users.php
+++ b/views/admin/users.php
@@ -121,81 +121,4 @@
-
+
diff --git a/views/dashboard/index.php b/views/dashboard/index.php
index 6c6deb6..03bc55b 100755
--- a/views/dashboard/index.php
+++ b/views/dashboard/index.php
@@ -164,144 +164,6 @@ $aggregatedMetrics = $aggregatedMetrics ?? [];
-
+
+
diff --git a/views/errors/429.php b/views/errors/429.php
new file mode 100644
index 0000000..8938bd7
--- /dev/null
+++ b/views/errors/429.php
@@ -0,0 +1,203 @@
+
+
+
+
+
+
+429 Too Many Requests — ServerManager
+
+
+
+
+
+
+
+
429
+
Too Many Requests
+
You have made too many requests in a short period. Please wait before trying again.
+
+
Retry in
+
= $retryMinutes ?>:00
+
+
+
Try Again
+
+
Go to Dashboard
+
+
+
+
diff --git a/views/layouts/main.php b/views/layouts/main.php
index e0036d3..4345d52 100755
--- a/views/layouts/main.php
+++ b/views/layouts/main.php
@@ -1,216 +1,154 @@
+
-
+
-
-
- = htmlspecialchars($title ?? 'ServerManager') ?>
-
-
+ = htmlspecialchars($title) ?> — ServerManager
+
+
+
+
+
+
+
-
+
+
+
diff --git a/views/servers/processes.php b/views/servers/processes.php
old mode 100755
new mode 100644
index 780e739..3427074
--- a/views/servers/processes.php
+++ b/views/servers/processes.php
@@ -101,66 +101,6 @@ $processList = $processList ?? [];
+
diff --git a/views/servers/services.php b/views/servers/services.php
index db65f75..49eef86 100644
--- a/views/servers/services.php
+++ b/views/servers/services.php
@@ -49,92 +49,7 @@ $totalPages = max(1, ceil(count($services) / $perPage));
+
+
diff --git a/views/servers/show.php b/views/servers/show.php
old mode 100755
new mode 100644
index 4272181..c117b63
--- a/views/servers/show.php
+++ b/views/servers/show.php
@@ -370,330 +370,9 @@ $canManage = $server_role === 'owner' || $server_role === 'manager';
+
+
diff --git a/views/servers/ssl.php b/views/servers/ssl.php
index 332cc61..5263b70 100644
--- a/views/servers/ssl.php
+++ b/views/servers/ssl.php
@@ -189,77 +189,6 @@ $certs = $ssl['certificates'] ?? [];
+
diff --git a/views/servers/system_info.php b/views/servers/system_info.php
old mode 100755
new mode 100644
diff --git a/views/servers/system_users.php b/views/servers/system_users.php
index 2fe4518..f956dc2 100644
--- a/views/servers/system_users.php
+++ b/views/servers/system_users.php
@@ -121,46 +121,6 @@ $lastLogins = $sysusers['last_logins'] ?? '';
+
diff --git a/views/teams/index.php b/views/teams/index.php
index 79162a6..f509bda 100644
--- a/views/teams/index.php
+++ b/views/teams/index.php
@@ -71,66 +71,4 @@
-
+
diff --git a/views/teams/show.php b/views/teams/show.php
index d60cead..ccab00f 100644
--- a/views/teams/show.php
+++ b/views/teams/show.php
@@ -146,200 +146,8 @@ $availableServers = $availableServers ?? [];
+
diff --git a/views/terminal/index.php b/views/terminal/index.php
index 4da11d1..87249ad 100755
--- a/views/terminal/index.php
+++ b/views/terminal/index.php
@@ -41,7 +41,7 @@ $commandHistory = $commandHistory ?? [];
\___ \ / _ \ '__\ \ / / _ \ '__| | |\/| |/ _` | '_ \ / _` |/ _` |/ _ \ '__|
___) | __/ | \ V / __/ | | | | | (_| | | | | (_| | (_| | __/ |
|____/ \___|_| \_/ \___|_| |_| |_|\__,_|_| |_|\__,_|\__, |\___|_|
- |___/
+ |___/
Connected to = htmlspecialchars($server['name']) ?>
Type commands below to execute them on the remote server.
@@ -149,121 +149,5 @@ $commandHistory = $commandHistory ?? [];
+