refactor: extract all inline CSS/JS from PHP files into separate asset files #88
457
public/assets/css/landing.css
Normal file
457
public/assets/css/landing.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
19
public/assets/css/legal.css
Normal file
19
public/assets/css/legal.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
40
public/assets/js/admin-notifications.js
Normal file
40
public/assets/js/admin-notifications.js
Normal file
@@ -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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-paper-plane"></i> Send';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
showToast('error', 'Failed to send notification');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Send';
|
||||||
|
});
|
||||||
|
}
|
||||||
17
public/assets/js/admin-security.js
Normal file
17
public/assets/js/admin-security.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
76
public/assets/js/admin-users.js
Normal file
76
public/assets/js/admin-users.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
0
public/assets/js/app.js
Executable file → Normal file
0
public/assets/js/app.js
Executable file → Normal file
40
public/assets/js/audit-log.js
Normal file
40
public/assets/js/audit-log.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
136
public/assets/js/dashboard-chart.js
Normal file
136
public/assets/js/dashboard-chart.js
Normal file
@@ -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);
|
||||||
316
public/assets/js/database-manager.js
Normal file
316
public/assets/js/database-manager.js
Normal file
@@ -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 = '<div style="padding:0.3rem 0.5rem"><input type="text" class="form-input tbl-filter" placeholder="Filter tables..." style="font-size:0.75rem;padding:0.25rem 0.4rem;width:100%;box-sizing:border-box" oninput="filterTables(this)"></div>';
|
||||||
|
(data.tables || []).forEach(t => {
|
||||||
|
html += '<div class="pma-table-item" data-tbl="' + escHtml(t.name).toLowerCase() + '" onclick="selectTable(\'' + db.replace(/'/g,"\\'") + "','" + t.name.replace(/'/g,"\\'") + '\')">'
|
||||||
|
+ '<i class="fas fa-table"></i> ' + escHtml(t.name)
|
||||||
|
+ ' <span class="pma-db-size">' + t.rows + ' rows</span></div>';
|
||||||
|
});
|
||||||
|
if (!html) html = '<div class="pma-table-item" style="color:var(--text-muted);cursor:default">No tables</div>';
|
||||||
|
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 = '<div style="text-align:center;padding:2rem;color:var(--text-muted)"><i class="fas fa-spinner fa-spin"></i> Loading structure...</div>';
|
||||||
|
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='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
||||||
|
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-list"></i> <code>'+escHtml(table)+'</code></h2>'
|
||||||
|
+'<span class="badge badge-info">'+d.columns.length+' columns</span>'
|
||||||
|
+'<span style="flex:1"></span>'
|
||||||
|
+'<button class="btn btn-xs btn-info" onclick="browseTable(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\')"><i class="fas fa-search"></i> Browse</button>'
|
||||||
|
+'</div>';
|
||||||
|
html+='<div class="table-responsive"><table class="table table-sm"><thead><tr><th>#</th><th>Field</th><th>Type</th><th>Collation</th><th>Null</th><th>Key</th><th>Default</th><th>Extra</th></tr></thead><tbody>';
|
||||||
|
d.columns.forEach((c,i)=>{
|
||||||
|
html+='<tr><td>'+(i+1)+'</td><td><strong>'+escHtml(c.field)+'</strong></td><td><code>'+escHtml(c.type)+'</code></td>'
|
||||||
|
+'<td>'+escHtml(c.collation)+'</td><td>'+escHtml(c.null)+'</td><td><strong>'+escHtml(c.key)+'</strong></td>'
|
||||||
|
+'<td>'+(c.default??'NULL')+'</td><td>'+escHtml(c.extra)+'</td></tr>';
|
||||||
|
});
|
||||||
|
html+='</tbody></table></div>';
|
||||||
|
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='<div style="text-align:center;padding:2rem;color:var(--text-muted)"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
|
||||||
|
|
||||||
|
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='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:0.5rem">'
|
||||||
|
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-search"></i> <code>'+escHtml(table)+'</code></h2>'
|
||||||
|
+'<span class="badge badge-info">'+d.total+' rows</span></div>';
|
||||||
|
html+='<form onsubmit="event.preventDefault();loadBrowse(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\',1)">';
|
||||||
|
html+='<div class="table-responsive" style="max-height:450px;overflow-y:auto"><table class="table table-sm"><thead><tr>';
|
||||||
|
d.columns.forEach(c=>{html+='<th>'+escHtml(c)+'</th>'});
|
||||||
|
html+='</tr><tr class="pma-filter-row">';
|
||||||
|
d.columns.forEach(c=>{
|
||||||
|
const cv=new URLSearchParams(filters.replace(/^&/,'')).get(c)||'';
|
||||||
|
html+='<td><input type="text" class="form-input pma-fi" data-col="'+escHtml(c)+'" value="'+escHtml(cv)+'" placeholder="'+escHtml(c)+'" style="font-size:0.75rem;padding:0.2rem 0.35rem;width:100%;min-width:50px"></td>';
|
||||||
|
});
|
||||||
|
html+='</tr></thead><tbody>';
|
||||||
|
d.rows.forEach(row=>{
|
||||||
|
html+='<tr>';
|
||||||
|
row.forEach(val=>{html+='<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis"><code>'+escHtml(String(val).substring(0,80))+'</code></td>'});
|
||||||
|
html+='</tr>';
|
||||||
|
});
|
||||||
|
html+='</tbody></table></div>';
|
||||||
|
html+='<div style="display:flex;gap:0.5rem;margin-top:0.5rem;align-items:center">';
|
||||||
|
html+='<button type="submit" class="btn btn-primary btn-sm"><i class="fas fa-search"></i> Filter</button>';
|
||||||
|
html+='<button type="button" class="btn btn-secondary btn-sm" onclick="clearBrowseFilters(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\')"><i class="fas fa-undo"></i> Clear</button>';
|
||||||
|
if(tp>1){
|
||||||
|
html+='<span style="flex:1"></span>';
|
||||||
|
for(let p=1;p<=tp&&p<=10;p++)html+='<button type="button" class="btn btn-xs '+(p===page?'btn-primary':'btn-secondary')+'" onclick="loadBrowse(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\','+p+')">'+p+'</button>';
|
||||||
|
if(tp>10)html+='<span class="text-muted">...</span>';
|
||||||
|
}
|
||||||
|
html+='</div></form>';
|
||||||
|
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='<div style="text-align:center;padding:2rem;color:var(--text-muted)"><i class="fas fa-spinner fa-spin"></i> Loading users...</div>';
|
||||||
|
|
||||||
|
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='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
||||||
|
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-users"></i> Database Users</h2>'
|
||||||
|
+'<span class="badge badge-info">'+(d.users?.length||0)+' users</span>'
|
||||||
|
+'<span style="flex:1"></span>'
|
||||||
|
+'<button class="btn btn-sm btn-success" onclick="showCreateUser()"><i class="fas fa-plus"></i> New User</button></div>';
|
||||||
|
html+='<div class="table-responsive"><table class="table table-sm"><thead><tr><th>User</th><th>Host</th><th>Databases</th><th>Expired</th><th>Actions</th></tr></thead><tbody>';
|
||||||
|
(d.users||[]).forEach(u=>{
|
||||||
|
const dbList=(d.grants||[]).filter(g=>g.user===u.user&&g.host===u.host).map(g=>g.db).join(', ');
|
||||||
|
html+='<tr><td><strong>'+escHtml(u.user)+'</strong></td><td><code>'+escHtml(u.host)+'</code></td><td>'+(dbList||'<span class="text-muted">-</span>')+'</td><td>'+escHtml(u.expired)+'</td>'
|
||||||
|
+'<td class="actions-cell">'
|
||||||
|
+'<button class="btn btn-xs btn-secondary" onclick="showEditUser(\''+escHtml(u.user)+"','"+escHtml(u.host)+'\')" title="Change password"><i class="fas fa-key"></i></button> '
|
||||||
|
+'<button class="btn btn-xs btn-danger" onclick="deleteUser(\''+escHtml(u.user)+"','"+escHtml(u.host)+'\')" title="Delete"><i class="fas fa-trash"></i></button>'
|
||||||
|
+'</td></tr>';
|
||||||
|
});
|
||||||
|
html+='</tbody></table></div>';
|
||||||
|
panel.innerHTML=html;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateUser() {
|
||||||
|
const panel=document.getElementById('pmaUsers');
|
||||||
|
let html='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
||||||
|
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-user-plus"></i> Create User</h2>'
|
||||||
|
+'<span style="flex:1"></span>'
|
||||||
|
+'<button class="btn btn-sm btn-secondary" onclick="loadUsers()"><i class="fas fa-arrow-left"></i> Back</button></div>'
|
||||||
|
+'<div class="dashboard-grid"><div class="dashboard-card"><div class="card-header"><h2><i class="fas fa-user"></i> Account</h2></div><div class="card-body">'
|
||||||
|
+'<div class="form-row"><div class="form-group"><label>Username</label><input type="text" id="nuUser" class="form-input" placeholder="username"></div>'
|
||||||
|
+'<div class="form-group"><label>Password</label><input type="password" id="nuPass" class="form-input" placeholder="password"></div>'
|
||||||
|
+'<div class="form-group"><label>Host</label><input type="text" id="nuHost" class="form-input" value="localhost" placeholder="localhost"></div></div></div></div>'
|
||||||
|
+'<div class="dashboard-card"><div class="card-header"><h2><i class="fas fa-database"></i> Database Access</h2></div><div class="card-body">'
|
||||||
|
+'<div style="margin-bottom:0.75rem"><label class="checkbox-label"><input type="checkbox" id="nuAllDb" onchange="document.querySelectorAll(\'.nuDbCheck\').forEach(c=>c.checked=this.checked)"> <strong>Select all</strong></label></div>';
|
||||||
|
|
||||||
|
databases.forEach(function(dbname) {
|
||||||
|
if (dbname !== 'information_schema' && dbname !== 'performance_schema' && dbname !== 'mysql' && dbname !== 'sys') {
|
||||||
|
html+='<label class="checkbox-label" style="margin-top:0.25rem"><input type="checkbox" class="nuDbCheck" value="'+dbname+'"> <span>'+dbname+'</span></label>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
html+='</div></div></div>'
|
||||||
|
+'<div class="dashboard-card"><div class="card-header"><h2><i class="fas fa-lock"></i> Privileges</h2></div><div class="card-body">'
|
||||||
|
+'<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.25rem">';
|
||||||
|
['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+='<label class="checkbox-label" style="font-size:0.82rem"><input type="checkbox" class="nuPriv" value="'+p+'"'+checked+'> <span>'+p+'</span></label>';
|
||||||
|
});
|
||||||
|
html+='</div></div></div>'
|
||||||
|
+'<div style="margin-top:1rem;display:flex;gap:0.5rem">'
|
||||||
|
+'<button class="btn btn-primary" onclick="doCreateUser()"><i class="fas fa-save"></i> Create User</button>'
|
||||||
|
+'<button class="btn btn-secondary" onclick="loadUsers()">Cancel</button></div>';
|
||||||
|
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='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
||||||
|
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-key"></i> Change Password</h2>'
|
||||||
|
+'<code>'+escHtml(user)+'</code> @ <code>'+escHtml(host)+'</code>'
|
||||||
|
+'<span style="flex:1"></span>'
|
||||||
|
+'<button class="btn btn-sm btn-secondary" onclick="loadUsers()"><i class="fas fa-arrow-left"></i> Back</button></div>'
|
||||||
|
+'<div class="card"><div class="card-body">'
|
||||||
|
+'<div class="form-group"><label>New Password</label><input type="password" id="epPass" class="form-input" placeholder="new password"></div>'
|
||||||
|
+'<div style="margin-top:1rem;display:flex;gap:0.5rem">'
|
||||||
|
+'<button class="btn btn-primary" onclick="doChangePass(\''+escHtml(user)+"','"+escHtml(host)+'\')"><i class="fas fa-save"></i> Save</button>'
|
||||||
|
+'<button class="btn btn-secondary" onclick="loadUsers()">Cancel</button></div></div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;}
|
||||||
202
public/assets/js/main.js
Normal file
202
public/assets/js/main.js
Normal file
@@ -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 = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)"><i class="fas fa-spinner fa-spin" style="font-size:1.5em;margin-bottom:10px;display:block"></i><span>Loading notifications...</span></div>';
|
||||||
|
|
||||||
|
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 = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">Could not load notifications.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderNotifications(d.data || [], d.unread_count || 0);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--danger)">Failed to load notifications.</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotifications(items, unreadCount) {
|
||||||
|
const list = document.getElementById('notifList');
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">'
|
||||||
|
+ '<i class="fas fa-check-circle" style="font-size:1.8em;margin-bottom:10px;display:block;color:var(--success)"></i>'
|
||||||
|
+ '<span>No notifications</span></div>';
|
||||||
|
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 += '<div class="notif-item ' + (n.is_read ? 'notif-read' : 'notif-unread') + '"'
|
||||||
|
+ ' onclick="markRead(' + n.id + ', this)"'
|
||||||
|
+ ' data-id="' + n.id + '">'
|
||||||
|
+ '<div class="notif-icon notif-' + typeColor + '"><i class="fas ' + icon + '"></i></div>'
|
||||||
|
+ '<div class="notif-content">'
|
||||||
|
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
|
||||||
|
+ '<div class="notif-message">' + escapeHtml(n.message) + '</div>'
|
||||||
|
+ '<div class="notif-time">' + (n.time_ago || '') + '</div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ (n.is_read ? '' : '<div class="notif-dot"></div>')
|
||||||
|
+ '</div>';
|
||||||
|
});
|
||||||
|
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 = '<a href="/servers" class="nav-sub-item"><i class="fas fa-list"></i> All Servers</a>';
|
||||||
|
d.data.forEach(s => {
|
||||||
|
const status = s.current_status === 'online' ? 'success' : s.current_status === 'offline' ? 'danger' : 'muted';
|
||||||
|
html += '<a href="/servers/' + s.id + '" class="nav-sub-item">'
|
||||||
|
+ '<span class="status-dot-sm status-' + status + '"></span> '
|
||||||
|
+ escapeHtml(s.name) + '</a>';
|
||||||
|
});
|
||||||
|
sub.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
sub.innerHTML = '<div class="nav-sub-item" style="color:var(--text-muted)">No servers</div>';
|
||||||
|
}
|
||||||
|
sub.style.maxHeight = sub.scrollHeight + 'px';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
sub.innerHTML = '<div class="nav-sub-item" style="color:var(--danger)">Failed to load</div>';
|
||||||
|
sub.style.maxHeight = sub.scrollHeight + 'px';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
sub.style.maxHeight = sub.scrollHeight + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
321
public/assets/js/nginx-manager.js
Normal file
321
public/assets/js/nginx-manager.js
Normal file
@@ -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('<span>' + i + '</span>');
|
||||||
|
}
|
||||||
|
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(/>/g, '>')
|
||||||
|
.replace(/#(.*)$/gm, '<span class="hl-comment">#$1</span>')
|
||||||
|
.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, '<span class="hl-keyword">$1</span>')
|
||||||
|
.replace(/\b(\d+)\b/g, '<span class="hl-number">$1</span>')
|
||||||
|
.replace(/\b(https?|fastcgi|unix):\/\/[^\s;{]+/g, '<span class="hl-string">$&</span>')
|
||||||
|
.replace(/'(.*?)'/g, '<span class="hl-string">\'$1\'</span>')
|
||||||
|
.replace(/(\$\w+)/g, '<span class="hl-var">$1</span>')
|
||||||
|
.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 '<span class="hl-string">' + m + '</span>';
|
||||||
|
}
|
||||||
|
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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-plus"></i> 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
20
public/assets/js/notifications.js
Normal file
20
public/assets/js/notifications.js
Normal file
@@ -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(){});
|
||||||
|
}
|
||||||
30
public/assets/js/server-list.js
Normal file
30
public/assets/js/server-list.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
40
public/assets/js/server-permissions.js
Normal file
40
public/assets/js/server-permissions.js
Normal file
@@ -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 = `
|
||||||
|
<select name="permissions[${idx}][type]" class="form-input permission-type">
|
||||||
|
<option value="sudo" ${type === 'sudo' ? 'selected' : ''}>Sudo (passwordless)</option>
|
||||||
|
<option value="command" ${type === 'command' ? 'selected' : ''}>Command</option>
|
||||||
|
<option value="file_read" ${type === 'file_read' ? 'selected' : ''}>File Read</option>
|
||||||
|
<option value="file_write" ${type === 'file_write' ? 'selected' : ''}>File Write</option>
|
||||||
|
<option value="custom" ${type === 'custom' ? 'selected' : ''}>Custom Command</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" name="permissions[${idx}][value]" class="form-input permission-value"
|
||||||
|
placeholder="e.g., systemctl" value="${escapeHtml(value || '')}">
|
||||||
|
<input type="text" name="permissions[${idx}][description]" class="form-input permission-desc"
|
||||||
|
placeholder="Description (optional)" value="${escapeHtml(description || '')}">
|
||||||
|
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
|
||||||
|
`;
|
||||||
|
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;
|
||||||
|
}
|
||||||
62
public/assets/js/server-processes.js
Normal file
62
public/assets/js/server-processes.js
Normal file
@@ -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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-check"></i> 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();
|
||||||
|
});
|
||||||
86
public/assets/js/server-services.js
Normal file
86
public/assets/js/server-services.js
Normal file
@@ -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 += '<tr class="service-row">';
|
||||||
|
html += '<td><code>' + escHtml(s.name) + '</code></td>';
|
||||||
|
html += '<td><span class="badge ' + (isActive ? 'badge-success' : isInactive ? 'badge-secondary' : 'badge-warning') + '">' + s.status + '</span></td>';
|
||||||
|
html += '<td class="actions-cell" style="white-space:nowrap">';
|
||||||
|
if (!isActive) html += '<button class="btn btn-xs btn-success" onclick="serviceAction(\'start\',\'' + escHtml(s.name) + '\')" title="Start"><i class="fas fa-play"></i></button> ';
|
||||||
|
if (!isInactive) html += '<button class="btn btn-xs btn-danger" onclick="serviceAction(\'stop\',\'' + escHtml(s.name) + '\')" title="Stop"><i class="fas fa-stop"></i></button> ';
|
||||||
|
html += '<button class="btn btn-xs btn-warning" onclick="serviceAction(\'restart\',\'' + escHtml(s.name) + '\')" title="Restart"><i class="fas fa-redo"></i></button> ';
|
||||||
|
html += '<button class="btn btn-xs btn-info" onclick="serviceAction(\'reload\',\'' + escHtml(s.name) + '\')" title="Reload"><i class="fas fa-sync-alt"></i></button> ';
|
||||||
|
if (!s.enabled) html += '<button class="btn btn-xs btn-secondary" onclick="serviceAction(\'enable\',\'' + escHtml(s.name) + '\')" title="Enable"><i class="fas fa-power-off"></i></button> ';
|
||||||
|
if (s.enabled) html += '<button class="btn btn-xs btn-secondary" onclick="serviceAction(\'disable\',\'' + escHtml(s.name) + '\')" title="Disable"><i class="fas fa-ban"></i></button> ';
|
||||||
|
html += '</td></tr>';
|
||||||
|
});
|
||||||
|
if (!html) html = '<tr><td colspan="3" style="text-align:center;color:var(--text-muted);padding:2rem">No services match the filter.</td></tr>';
|
||||||
|
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 += '<button class="btn btn-xs ' + (p === page ? 'btn-primary' : 'btn-secondary') + '" onclick="renderPage(list,' + p + ')" data-list=\'' + JSON.stringify(list).replace(/'/g,"'") + '\'>' + p + '</button>';
|
||||||
|
}
|
||||||
|
if (totalPages > 15) html += '<span class="text-muted" style="font-size:0.8rem">...</span>';
|
||||||
|
container.innerHTML = html + ' <span class="text-muted" style="font-size:0.8rem">' + list.length + ' services</span>';
|
||||||
|
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(); });
|
||||||
323
public/assets/js/server-show.js
Normal file
323
public/assets/js/server-show.js
Normal file
@@ -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 <strong>serverName</strong> 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 <strong>serverName</strong> 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
73
public/assets/js/server-ssl.js
Normal file
73
public/assets/js/server-ssl.js
Normal file
@@ -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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-plus"></i> 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(); }
|
||||||
|
});
|
||||||
42
public/assets/js/system-users.js
Normal file
42
public/assets/js/system-users.js
Normal file
@@ -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')}});
|
||||||
59
public/assets/js/team-index.js
Normal file
59
public/assets/js/team-index.js
Normal file
@@ -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 = '<i class="fas fa-users-cog"></i><p>No teams match your search.</p>';
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
187
public/assets/js/team-show.js
Normal file
187
public/assets/js/team-show.js
Normal file
@@ -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 = '<strong>' + escapeHtml(u.username) + '</strong> <small>' + escapeHtml(u.email) + '</small>';
|
||||||
|
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 = '<div class="autocomplete-empty">No users found</div>';
|
||||||
|
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); }
|
||||||
|
});
|
||||||
|
}
|
||||||
110
public/assets/js/terminal.js
Normal file
110
public/assets/js/terminal.js
Normal file
@@ -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 = '<span class="terminal-prompt-inline">$</span> ' + escapeHtml(text);
|
||||||
|
} else {
|
||||||
|
line.innerHTML = '<pre>' + escapeHtml(text) + '</pre>';
|
||||||
|
}
|
||||||
|
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();
|
||||||
@@ -23,12 +23,9 @@ try {
|
|||||||
$app->run();
|
$app->run();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
if (($_ENV['APP_DEBUG'] ?? false) === 'true') {
|
if (isset($app)) {
|
||||||
echo '<h1>Server Error</h1>';
|
$app->getView()->error(500);
|
||||||
echo '<pre>' . htmlspecialchars($e->getMessage()) . '</pre>';
|
|
||||||
echo '<pre>' . $e->getTraceAsString() . '</pre>';
|
|
||||||
} else {
|
|
||||||
echo '<h1>500 Internal Server Error</h1>';
|
|
||||||
echo '<p>An unexpected error occurred. Please check the logs.</p>';
|
|
||||||
}
|
}
|
||||||
|
echo '<h1>500 Internal Server Error</h1>';
|
||||||
|
echo '<p>An unexpected error occurred.</p>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class AuthMiddleware
|
|||||||
if (!headers_sent()) {
|
if (!headers_sent()) {
|
||||||
header("Location: {$url}");
|
header("Location: {$url}");
|
||||||
} else {
|
} else {
|
||||||
echo '<script>window.location.href="' . $url . '";</script>';
|
echo '<meta http-equiv="refresh" content="0;url=' . htmlspecialchars($url) . '">';
|
||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,210 +75,12 @@ class RateLimitMiddleware
|
|||||||
http_response_code(429);
|
http_response_code(429);
|
||||||
header("Retry-After: {$retryAfter}");
|
header("Retry-After: {$retryAfter}");
|
||||||
|
|
||||||
$retryMinutes = (int) ceil($retryAfter / 60);
|
echo App::getInstance()->getView()->renderView('errors.429', [
|
||||||
$retryAfterMs = $retryAfter * 1000;
|
'retryAfter' => $retryAfter,
|
||||||
$requestUri = htmlspecialchars($_SERVER['REQUEST_URI'] ?? '/', ENT_QUOTES, 'UTF-8');
|
'retryMinutes' => (int) ceil($retryAfter / 60),
|
||||||
|
'retryAfterMs' => $retryAfter * 1000,
|
||||||
echo <<<HTML
|
'requestUri' => htmlspecialchars($_SERVER['REQUEST_URI'] ?? '/', ENT_QUOTES, 'UTF-8'),
|
||||||
<!DOCTYPE html>
|
]);
|
||||||
<html lang="en" data-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>429 Too Many Requests — ServerManager</title>
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/img/server.svg">
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
:root {
|
|
||||||
--bg-primary: #0a0c10;
|
|
||||||
--bg-secondary: #111318;
|
|
||||||
--accent: #6366f1;
|
|
||||||
--accent-hover: #818cf8;
|
|
||||||
--text-primary: #e1e4ed;
|
|
||||||
--text-muted: #7a7f8e;
|
|
||||||
--border-color: #1e2030;
|
|
||||||
--warning: #f59e0b;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.rate-limit-card {
|
|
||||||
text-align: center;
|
|
||||||
max-width: 440px;
|
|
||||||
width: 100%;
|
|
||||||
animation: fadeIn 0.5s ease-out;
|
|
||||||
}
|
|
||||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
|
||||||
.icon-shield {
|
|
||||||
width: 80px; height: 80px;
|
|
||||||
margin: 0 auto 1.5rem;
|
|
||||||
background: radial-gradient(circle, rgba(245,158,11,0.12) 0%, transparent 70%);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 2.4rem;
|
|
||||||
color: var(--warning);
|
|
||||||
animation: pulse 2s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
||||||
.error-code {
|
|
||||||
font-size: 5rem;
|
|
||||||
font-weight: 800;
|
|
||||||
background: linear-gradient(135deg, var(--warning), #f97316);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.error-title {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
.error-desc {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.92rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.countdown-box {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.countdown-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.countdown-timer {
|
|
||||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
|
||||||
font-size: 2.4rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--warning);
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.countdown-bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
background: var(--border-color);
|
|
||||||
border-radius: 2px;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.countdown-bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, var(--warning), #f97316);
|
|
||||||
border-radius: 2px;
|
|
||||||
width: 100%;
|
|
||||||
transition: width 1s linear;
|
|
||||||
}
|
|
||||||
.btn-primary {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 2rem;
|
|
||||||
background: linear-gradient(135deg, var(--accent), #818cf8);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
opacity: 0.4;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.btn-primary.active {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
.btn-primary.active:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 20px rgba(99,102,241,0.3);
|
|
||||||
}
|
|
||||||
.btn-secondary {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
.btn-secondary:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--text-muted);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="rate-limit-card">
|
|
||||||
<div class="icon-shield"><i class="fas fa-shield-halved"></i></div>
|
|
||||||
<div class="error-code">429</div>
|
|
||||||
<div class="error-title">Too Many Requests</div>
|
|
||||||
<div class="error-desc">You have made too many requests in a short period. Please wait before trying again.</div>
|
|
||||||
<div class="countdown-box">
|
|
||||||
<div class="countdown-label">Retry in</div>
|
|
||||||
<div class="countdown-timer" id="countdown">{$retryMinutes}:00</div>
|
|
||||||
<div class="countdown-bar"><div class="countdown-bar-fill" id="progressBar"></div></div>
|
|
||||||
</div>
|
|
||||||
<a href="{$requestUri}" class="btn-primary" id="retryBtn"><i class="fas fa-rotate-right"></i> Try Again</a>
|
|
||||||
<br>
|
|
||||||
<a href="/dashboard" class="btn-secondary"><i class="fas fa-gauge-high"></i> Go to Dashboard</a>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
var total = {$retryAfterMs};
|
|
||||||
var remaining = total;
|
|
||||||
var el = document.getElementById('countdown');
|
|
||||||
var bar = document.getElementById('progressBar');
|
|
||||||
var btn = document.getElementById('retryBtn');
|
|
||||||
var start = Date.now();
|
|
||||||
|
|
||||||
function tick() {
|
|
||||||
var elapsed = Date.now() - start;
|
|
||||||
remaining = Math.max(0, total - elapsed);
|
|
||||||
var secs = Math.ceil(remaining / 1000);
|
|
||||||
var m = Math.floor(secs / 60);
|
|
||||||
var s = secs % 60;
|
|
||||||
el.textContent = m + ':' + (s < 10 ? '0' : '') + s;
|
|
||||||
bar.style.width = (remaining / total * 100) + '%';
|
|
||||||
|
|
||||||
if (remaining <= 0) {
|
|
||||||
el.textContent = '0:00';
|
|
||||||
bar.style.width = '0%';
|
|
||||||
btn.classList.add('active');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
HTML;
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,45 +187,4 @@ function selected(string $value, string $compare): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="/assets/js/audit-log.js"></script>
|
||||||
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();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -139,45 +139,4 @@ $data = $notifications['data'] ?? [];
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="/assets/js/admin-notifications.js"></script>
|
||||||
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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-paper-plane"></i> Send';
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
showToast('error', 'Failed to send notification');
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Send';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -102,22 +102,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="/assets/js/admin-security.js"></script>
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -121,81 +121,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="/assets/js/admin-users.js"></script>
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -164,144 +164,6 @@ $aggregatedMetrics = $aggregatedMetrics ?? [];
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||||
<script>
|
<script>const aggregatedData = <?= json_encode($aggregatedMetrics, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;</script>
|
||||||
const aggregatedData = <?= json_encode($aggregatedMetrics, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;
|
<script src="/assets/js/dashboard-chart.js"></script>
|
||||||
|
|
||||||
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);
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
203
views/errors/429.php
Normal file
203
views/errors/429.php
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<?php
|
||||||
|
$retryMinutes = $retryMinutes ?? 1;
|
||||||
|
$retryAfterMs = $retryAfterMs ?? 60000;
|
||||||
|
$requestUri = htmlspecialchars($requestUri ?? '/', ENT_QUOTES, 'UTF-8');
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>429 Too Many Requests — ServerManager</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/img/server.svg">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0c10;
|
||||||
|
--bg-secondary: #111318;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-hover: #818cf8;
|
||||||
|
--text-primary: #e1e4ed;
|
||||||
|
--text-muted: #7a7f8e;
|
||||||
|
--border-color: #1e2030;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.rate-limit-card {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
animation: fadeIn 0.5s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
.icon-shield {
|
||||||
|
width: 80px; height: 80px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
background: radial-gradient(circle, rgba(245,158,11,0.12) 0%, transparent 70%);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 2.4rem;
|
||||||
|
color: var(--warning);
|
||||||
|
animation: pulse 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||||
|
.error-code {
|
||||||
|
font-size: 5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, var(--warning), #f97316);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.error-title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.error-desc {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.countdown-box {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.countdown-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.countdown-timer {
|
||||||
|
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--warning);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.countdown-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.countdown-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--warning), #f97316);
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 100%;
|
||||||
|
transition: width 1s linear;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #818cf8);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.btn-primary.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.btn-primary.active:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 20px rgba(99,102,241,0.3);
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="rate-limit-card">
|
||||||
|
<div class="icon-shield"><i class="fas fa-shield-halved"></i></div>
|
||||||
|
<div class="error-code">429</div>
|
||||||
|
<div class="error-title">Too Many Requests</div>
|
||||||
|
<div class="error-desc">You have made too many requests in a short period. Please wait before trying again.</div>
|
||||||
|
<div class="countdown-box">
|
||||||
|
<div class="countdown-label">Retry in</div>
|
||||||
|
<div class="countdown-timer" id="countdown"><?= $retryMinutes ?>:00</div>
|
||||||
|
<div class="countdown-bar"><div class="countdown-bar-fill" id="progressBar"></div></div>
|
||||||
|
</div>
|
||||||
|
<a href="<?= $requestUri ?>" class="btn-primary" id="retryBtn"><i class="fas fa-rotate-right"></i> Try Again</a>
|
||||||
|
<br>
|
||||||
|
<a href="/dashboard" class="btn-secondary"><i class="fas fa-gauge-high"></i> Go to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var total = <?= $retryAfterMs ?>;
|
||||||
|
var remaining = total;
|
||||||
|
var el = document.getElementById('countdown');
|
||||||
|
var bar = document.getElementById('progressBar');
|
||||||
|
var btn = document.getElementById('retryBtn');
|
||||||
|
var start = Date.now();
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
var elapsed = Date.now() - start;
|
||||||
|
remaining = Math.max(0, total - elapsed);
|
||||||
|
var secs = Math.ceil(remaining / 1000);
|
||||||
|
var m = Math.floor(secs / 60);
|
||||||
|
var s = secs % 60;
|
||||||
|
el.textContent = m + ':' + (s < 10 ? '0' : '') + s;
|
||||||
|
bar.style.width = (remaining / total * 100) + '%';
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
el.textContent = '0:00';
|
||||||
|
bar.style.width = '0%';
|
||||||
|
btn.classList.add('active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,216 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
$user = $user ?? null;
|
||||||
|
$notifications = $notifications ?? [];
|
||||||
|
$unreadCount = $unreadCount ?? 0;
|
||||||
|
$teamId = $teamId ?? null;
|
||||||
|
$title = $title ?? 'ServerManager';
|
||||||
|
$content = $content ?? '';
|
||||||
|
|
||||||
|
$currentRoute = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-theme="dark">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="csrf-token" content="<?= $_SESSION['csrf_token'] ?? '' ?>">
|
<title><?= htmlspecialchars($title) ?> — ServerManager</title>
|
||||||
<meta name="api-token" content="<?php
|
<meta name="api-token" content="<?= htmlspecialchars($user['api_token'] ?? '') ?>">
|
||||||
$uid = $_SESSION['user_id'] ?? 0;
|
<meta name="csrf-token" content="<?= $this->csrfToken() ?>">
|
||||||
$token = '';
|
<link rel="stylesheet" href="/assets/css/app.css">
|
||||||
if ($uid) {
|
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
|
||||||
$u = \ServerManager\Core\Database::getInstance()->fetch(
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
'SELECT api_token FROM users WHERE id = ? AND status = \'active\'', [(int) $uid]
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
);
|
<link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600;14..32,700&display=swap" rel="stylesheet">
|
||||||
$token = $u['api_token'] ?? '';
|
|
||||||
}
|
|
||||||
echo htmlspecialchars($token);
|
|
||||||
?>">
|
|
||||||
<title><?= htmlspecialchars($title ?? 'ServerManager') ?></title>
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/img/server.svg">
|
|
||||||
<link rel="stylesheet" href="/assets/css/style.css">
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-container">
|
<div class="app-layout">
|
||||||
|
<!-- Sidebar -->
|
||||||
<aside class="sidebar" id="sidebar">
|
<aside class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<a href="/dashboard" class="sidebar-logo">
|
<a href="/dashboard" class="sidebar-brand">
|
||||||
<i class="fas fa-server"></i>
|
<i class="fas fa-cubes"></i>
|
||||||
<span class="logo-text">ServerManager</span>
|
<span>ServerManager</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="sidebar-toggle" id="sidebarToggle" title="Toggle sidebar">
|
|
||||||
<i class="fas fa-bars"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<ul class="nav-list">
|
<!-- Dashboard -->
|
||||||
<li class="nav-item">
|
<a href="/dashboard" class="nav-item <?= str_starts_with($currentRoute, '/dashboard') ? 'active' : '' ?>">
|
||||||
<a href="/dashboard" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/dashboard') ? 'active' : '' ?>">
|
<i class="fas fa-chart-pie"></i>
|
||||||
<i class="fas fa-tachometer-alt"></i>
|
<span>Dashboard</span>
|
||||||
<span>Dashboard</span>
|
</a>
|
||||||
</a>
|
|
||||||
</li>
|
<!-- Servers -->
|
||||||
<li class="nav-item">
|
<div class="nav-item-accordion <?= str_starts_with($currentRoute, '/servers') || str_starts_with($currentRoute, '/terminal') ? 'expanded' : '' ?>">
|
||||||
<a href="/notifications" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/notifications') ? 'active' : '' ?>">
|
<a href="#" class="nav-item nav-item-toggle <?= str_starts_with($currentRoute, '/servers') ? 'active' : '' ?>" onclick="toggleServerList(event)">
|
||||||
<i class="fas fa-bell"></i>
|
<i class="fas fa-server"></i>
|
||||||
<span>Notifications</span>
|
<span>Servers</span>
|
||||||
</a>
|
<i class="fas fa-chevron-right nav-chevron"></i>
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item nav-item-accordion <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/servers') ? 'active' : '' ?>">
|
<div class="nav-sublist" id="serverList" style="<?= str_starts_with($currentRoute, '/servers') || str_starts_with($currentRoute, '/terminal') ? '' : '' ?>">
|
||||||
<a href="#" class="nav-link" onclick="toggleServerList(event)">
|
<a href="/servers" class="nav-sub-item"><i class="fas fa-list"></i> All Servers</a>
|
||||||
<i class="fas fa-hdd"></i>
|
|
||||||
<span>Servers</span>
|
|
||||||
<i class="fas fa-chevron-right nav-chevron"></i>
|
|
||||||
</a>
|
|
||||||
<div class="nav-submenu" id="serverList">
|
|
||||||
<div class="nav-submenu-loading"><i class="fas fa-spinner fa-spin"></i></div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<?php if (is_super_admin()): ?>
|
|
||||||
<li class="nav-divider"></li>
|
|
||||||
<li class="nav-section">Administration</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a href="/teams" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/teams') ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-users-cog"></i>
|
|
||||||
<span>Teams</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a href="/admin/users" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/users') ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-users"></i>
|
|
||||||
<span>Users</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a href="/admin/audit" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/audit') ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-history"></i>
|
|
||||||
<span>Audit Logs</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a href="/admin/security" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/security') ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-shield-alt"></i>
|
|
||||||
<span>Security</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a href="/admin/app-version" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/app-version') ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-mobile-alt"></i>
|
|
||||||
<span>App Version</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a href="/admin/notifications" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/notifications') ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-bell"></i>
|
|
||||||
<span>Notifications</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a href="/admin/policies" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/policies') ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-file-contract"></i>
|
|
||||||
<span>Policies</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<?php elseif (is_admin()): ?>
|
|
||||||
<li class="nav-divider"></li>
|
|
||||||
<li class="nav-section">Administration</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a href="/teams" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/teams') ? 'active' : '' ?>">
|
|
||||||
<i class="fas fa-users-cog"></i>
|
|
||||||
<span>Teams</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<?php endif; ?>
|
|
||||||
<li class="nav-divider"></li>
|
|
||||||
<li class="nav-section">Mobile</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a href="/sysadmin.apk" class="nav-link" download>
|
|
||||||
<i class="fas fa-download"></i>
|
|
||||||
<span>Download APK</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<div class="user-info">
|
|
||||||
<div class="user-avatar">
|
|
||||||
<?= strtoupper(substr($_SESSION['username'] ?? '?', 0, 2)) ?>
|
|
||||||
</div>
|
|
||||||
<div class="user-details">
|
|
||||||
<span class="user-name"><?= htmlspecialchars($_SESSION['username'] ?? 'Guest') ?></span>
|
|
||||||
<span class="user-role"><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $_SESSION['user_role'] ?? 'guest'))) ?></span>
|
|
||||||
</div>
|
|
||||||
<div class="user-menu">
|
|
||||||
<a href="/profile" title="Profile"><i class="fas fa-user-cog"></i></a>
|
|
||||||
<form method="POST" action="/logout" class="logout-form" style="display:inline">
|
|
||||||
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
|
|
||||||
<button type="submit" title="Logout" style="background:none;border:none;color:inherit;cursor:pointer;padding:0;font:inherit;line-height:1"><i class="fas fa-sign-out-alt"></i></button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- Teams -->
|
||||||
|
<a href="/teams" class="nav-item <?= str_starts_with($currentRoute, '/teams') ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
<span>Teams</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Activity Log (admin+) -->
|
||||||
|
<?php if (is_admin() || is_super_admin()): ?>
|
||||||
|
<a href="/admin/activity" class="nav-item <?= str_starts_with($currentRoute, '/admin/activity') ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-history"></i>
|
||||||
|
<span>Activity Log</span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Admin panel (admin+) -->
|
||||||
|
<?php if (is_admin() || is_super_admin()): ?>
|
||||||
|
<a href="/admin" class="nav-item <?= str_starts_with($currentRoute, '/admin') ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
<span>Administration</span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<hr class="nav-divider">
|
||||||
|
|
||||||
|
<!-- Account -->
|
||||||
|
<a href="/account" class="nav-item <?= str_starts_with($currentRoute, '/account') ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
<span>My Account</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/logout" class="nav-item nav-item-danger">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
<span>Log out</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="mobile-overlay" id="mobileOverlay"></div>
|
<!-- Main Content -->
|
||||||
|
<div class="main-area">
|
||||||
<main class="main-content">
|
<!-- Topbar -->
|
||||||
<header class="top-bar">
|
<header class="topbar">
|
||||||
<div class="top-bar-left">
|
<div class="topbar-left">
|
||||||
<button class="mobile-toggle" id="mobileToggle">
|
<button class="btn btn-icon" id="sidebarToggle" onclick="document.getElementById('sidebar').classList.toggle('collapsed')">
|
||||||
<i class="fas fa-bars"></i>
|
<i class="fas fa-bars"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="breadcrumb">
|
|
||||||
<?= htmlspecialchars($title ?? 'ServerManager') ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="top-bar-right">
|
<div class="topbar-right">
|
||||||
<div class="top-bar-actions">
|
<div class="notification-btn-wrapper">
|
||||||
<button class="btn-icon" id="refreshBtn" title="Refresh">
|
<button class="btn btn-icon notification-btn" onclick="toggleNotifications()" title="Notifications">
|
||||||
<i class="fas fa-sync-alt"></i>
|
<i class="fas fa-bell"></i>
|
||||||
|
<span class="notification-badge" id="notificationBadge" style="display:<?= $unreadCount > 0 ? 'inline-flex' : 'none' ?>">
|
||||||
|
<?= $unreadCount > 99 ? '99+' : $unreadCount ?>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-icon" id="themeToggle" title="Toggle theme">
|
<div class="notification-dropdown" id="notifDropdown">
|
||||||
<i class="fas fa-moon"></i>
|
<div class="notif-header">
|
||||||
</button>
|
<h3>Notifications</h3>
|
||||||
<div class="notification-btn-wrapper">
|
<button class="btn btn-text btn-sm" id="markAllReadBtn" onclick="markAllRead()"
|
||||||
<button class="btn-icon notification-btn" id="notificationBell" title="Notifications" onclick="toggleNotifications()">
|
style="display:<?= $unreadCount > 0 ? 'inline-flex' : 'none' ?>">
|
||||||
<i class="fas fa-bell"></i>
|
<i class="fas fa-check-double"></i> Mark all read
|
||||||
<span class="notification-badge" id="notificationBadge" style="display:none">0</span>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
<div class="notification-dropdown" id="notifDropdown">
|
<div class="notif-list" id="notifList">
|
||||||
<div class="notif-dropdown-header">
|
<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">
|
||||||
<span style="font-weight:600;font-size:0.9em">Notifications</span>
|
<i class="fas fa-spinner fa-spin" style="font-size:1.5em;margin-bottom:10px;display:block"></i>
|
||||||
<div style="display:flex;align-items:center;gap:6px">
|
<span>Loading notifications...</span>
|
||||||
<button class="btn-text" id="markAllReadBtn" onclick="markAllRead()" style="display:none;font-size:0.78em;padding:2px 6px;border-radius:4px">
|
|
||||||
<i class="fas fa-check-double"></i> Mark all read
|
|
||||||
</button>
|
|
||||||
<button class="btn-text" onclick="closeNotifications()" style="font-size:0.78em;padding:2px 6px;border-radius:4px">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="notif-dropdown-body" id="notifList">
|
|
||||||
<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">
|
|
||||||
<i class="fas fa-spinner fa-spin" style="font-size:1.5em;margin-bottom:10px;display:block"></i>
|
|
||||||
<span>Loading notifications...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="notif-dropdown-footer">
|
|
||||||
<a href="/notifications" class="btn-text" style="font-size:0.82em;color:var(--info);width:100%;text-align:center;padding:8px;display:block">
|
|
||||||
<i class="fas fa-external-link-alt"></i> View all notifications
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="topbar-user">
|
||||||
|
<i class="fas fa-user-circle"></i>
|
||||||
|
<?= htmlspecialchars($user['username'] ?? 'Guest') ?>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content-area">
|
<!-- Page Content -->
|
||||||
<?php if ($flash = \ServerManager\Core\Session::getAllFlashes()): ?>
|
<main class="page-content">
|
||||||
<?php foreach ($flash as $type => $messages): ?>
|
<div class="container">
|
||||||
<?php foreach ($messages as $message): ?>
|
<?php if (isset($_SESSION['_flash'])): ?>
|
||||||
<div class="toast toast-<?= htmlspecialchars($type) ?>" data-autohide="true">
|
<?php foreach ($_SESSION['_flash'] as $type => $messages): ?>
|
||||||
<div class="toast-body">
|
<?php foreach ($messages as $msg): ?>
|
||||||
<i class="fas fa-<?= $type === 'success' ? 'check-circle' : ($type === 'error' ? 'times-circle' : 'info-circle') ?>"></i>
|
<div class="alert alert-<?= $type === 'error' ? 'danger' : $type ?>">
|
||||||
<?= htmlspecialchars($message) ?>
|
<i class="fas fa-<?= $type === 'success' ? 'check-circle' : ($type === 'error' ? 'exclamation-circle' : 'info-circle') ?>"></i>
|
||||||
|
<?= htmlspecialchars($msg) ?>
|
||||||
</div>
|
</div>
|
||||||
<button class="toast-close">×</button>
|
<?php endforeach; ?>
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endforeach; ?>
|
<?php unset($_SESSION['_flash']); ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?= $content ?? '' ?>
|
<?= $content ?? '' ?>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toastContainer" class="toast-container"></div>
|
<div id="toastContainer" class="toast-container"></div>
|
||||||
@@ -221,211 +159,6 @@
|
|||||||
<script src="<?= htmlspecialchars($script) ?>"></script>
|
<script src="<?= htmlspecialchars($script) ?>"></script>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<script>
|
<script src="/assets/js/main.js"></script>
|
||||||
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 = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)"><i class="fas fa-spinner fa-spin" style="font-size:1.5em;margin-bottom:10px;display:block"></i><span>Loading notifications...</span></div>';
|
|
||||||
|
|
||||||
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 = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">Could not load notifications.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
renderNotifications(d.data || [], d.unread_count || 0);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--danger)">Failed to load notifications.</div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNotifications(items, unreadCount) {
|
|
||||||
const list = document.getElementById('notifList');
|
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">'
|
|
||||||
+ '<i class="fas fa-check-circle" style="font-size:1.8em;margin-bottom:10px;display:block;color:var(--success)"></i>'
|
|
||||||
+ '<span>No notifications</span></div>';
|
|
||||||
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 += '<div class="notif-item ' + (n.is_read ? 'notif-read' : 'notif-unread') + '"'
|
|
||||||
+ ' onclick="markRead(' + n.id + ', this)"'
|
|
||||||
+ ' data-id="' + n.id + '">'
|
|
||||||
+ '<div class="notif-icon notif-' + typeColor + '"><i class="fas ' + icon + '"></i></div>'
|
|
||||||
+ '<div class="notif-content">'
|
|
||||||
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
|
|
||||||
+ '<div class="notif-message">' + escapeHtml(n.message) + '</div>'
|
|
||||||
+ '<div class="notif-time">' + (n.time_ago || '') + '</div>'
|
|
||||||
+ '</div>'
|
|
||||||
+ (n.is_read ? '' : '<div class="notif-dot"></div>')
|
|
||||||
+ '</div>';
|
|
||||||
});
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll unread count every 60s
|
|
||||||
setInterval(updateUnreadCount, 60000);
|
|
||||||
// Initial fetch
|
|
||||||
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 = '<a href="/servers" class="nav-sub-item"><i class="fas fa-list"></i> All Servers</a>';
|
|
||||||
d.data.forEach(s => {
|
|
||||||
const status = s.current_status === 'online' ? 'success' : s.current_status === 'offline' ? 'danger' : 'muted';
|
|
||||||
html += '<a href="/servers/' + s.id + '" class="nav-sub-item">'
|
|
||||||
+ '<span class="status-dot-sm status-' + status + '"></span> '
|
|
||||||
+ escapeHtml(s.name) + '</a>';
|
|
||||||
});
|
|
||||||
sub.innerHTML = html;
|
|
||||||
} else {
|
|
||||||
sub.innerHTML = '<div class="nav-sub-item" style="color:var(--text-muted)">No servers</div>';
|
|
||||||
}
|
|
||||||
sub.style.maxHeight = sub.scrollHeight + 'px';
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
sub.innerHTML = '<div class="nav-sub-item" style="color:var(--danger)">Failed to load</div>';
|
|
||||||
sub.style.maxHeight = sub.scrollHeight + 'px';
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
sub.style.maxHeight = sub.scrollHeight + 'px';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -67,25 +67,4 @@ $data = $notifications ?? [];
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<script>
|
<script src="/assets/js/notifications.js"></script>
|
||||||
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(){});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -1,462 +1,4 @@
|
|||||||
<style>
|
<?php ?><link rel="stylesheet" href="/assets/css/landing.css">
|
||||||
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; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="landing-page">
|
<div class="landing-page">
|
||||||
<section class="landing-hero">
|
<section class="landing-hero">
|
||||||
@@ -465,10 +7,10 @@ body.auth-page .auth-container {
|
|||||||
<div class="floating-elem"><i class="fas fa-terminal"></i></div>
|
<div class="floating-elem"><i class="fas fa-terminal"></i></div>
|
||||||
<div class="floating-elem"><i class="fas fa-database"></i></div>
|
<div class="floating-elem"><i class="fas fa-database"></i></div>
|
||||||
<div class="floating-elem"><i class="fas fa-shield-alt"></i></div>
|
<div class="floating-elem"><i class="fas fa-shield-alt"></i></div>
|
||||||
<div class="floating-elem"><i class="fas fa-hdd"></i></div>
|
|
||||||
<div class="floating-elem"><i class="fas fa-network-wired"></i></div>
|
|
||||||
<div class="floating-elem"><i class="fas fa-cloud"></i></div>
|
<div class="floating-elem"><i class="fas fa-cloud"></i></div>
|
||||||
<div class="floating-elem"><i class="fas fa-microchip"></i></div>
|
<div class="floating-elem"><i class="fas fa-code-branch"></i></div>
|
||||||
|
<div class="floating-elem"><i class="fas fa-chart-line"></i></div>
|
||||||
|
<div class="floating-elem"><i class="fas fa-lock"></i></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-icon-wrapper">
|
<div class="hero-icon-wrapper">
|
||||||
@@ -478,59 +20,37 @@ body.auth-page .auth-container {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="hero-title">
|
<h1 class="hero-title">
|
||||||
Manage Your Servers<br>
|
Manage your <span class="gradient-text">servers</span> with ease
|
||||||
<span class="gradient-text">From One Place</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p class="hero-subtitle">
|
<p class="hero-subtitle">
|
||||||
ServerManager is a powerful, web-based platform for managing Linux servers.
|
A self-hosted, open-source platform for remote server management.
|
||||||
Monitor, control, and deploy across your entire infrastructure from a single dashboard.
|
Monitor, maintain, and control your infrastructure from a clean web dashboard.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="hero-cta">
|
<div class="hero-cta">
|
||||||
<a href="/login" class="btn btn-primary-custom">
|
<a href="/login" class="btn btn-primary-custom">
|
||||||
<i class="fas fa-sign-in-alt"></i> Sign In
|
<i class="fas fa-arrow-right"></i> Get Started
|
||||||
</a>
|
</a>
|
||||||
<a href="/register" class="btn btn-secondary-custom">
|
<a href="https://github.com/devlab-lat/server-manager" class="btn btn-secondary-custom" target="_blank">
|
||||||
<i class="fas fa-user-plus"></i> Create Account
|
<i class="fab fa-github"></i> View on GitHub
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Terminal demo -->
|
||||||
<div class="hero-terminal">
|
<div class="hero-terminal">
|
||||||
<div class="terminal-bar">
|
<div class="terminal-bar">
|
||||||
<div class="terminal-dot red"></div>
|
<span class="terminal-dot red"></span>
|
||||||
<div class="terminal-dot yellow"></div>
|
<span class="terminal-dot yellow"></span>
|
||||||
<div class="terminal-dot green"></div>
|
<span class="terminal-dot green"></span>
|
||||||
<span class="terminal-title">servermanager — ssh-session</span>
|
<span class="terminal-title">server-manager@dashboard:~</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="terminal-body-landing">
|
<div class="terminal-body-landing">
|
||||||
<div class="terminal-line-landing" style="animation-delay: 0.9s">
|
<div class="terminal-line-landing" style="animation-delay:0.1s"><span class="prompt">$</span> <span class="cmd">serverman list --servers</span></div>
|
||||||
<span class="prompt">admin@servermanager:~$</span>
|
<div class="terminal-line-landing" style="animation-delay:0.5s"><span class="output-success">✓</span> <span class="output-info">3 servers online</span> <span class="output-muted">· 0 offline</span></div>
|
||||||
<span class="cmd"> systemctl status --all</span>
|
<div class="terminal-line-landing" style="animation-delay:0.9s"><span class="prompt">$</span> <span class="cmd">serverman status web-01</span></div>
|
||||||
</div>
|
<div class="terminal-line-landing" style="animation-delay:1.3s"><span class="output-success">✓</span> <span class="output-info">web-01</span> <span class="output-muted">· CPU 23% · RAM 1.2/4G · Uptime 14d</span></div>
|
||||||
<div class="terminal-line-landing" style="animation-delay: 1.1s">
|
<div class="terminal-line-landing" style="animation-delay:1.7s"><span class="prompt">$</span> <span class="cmd"><span class="blinking-cursor"></span></span></div>
|
||||||
<span class="output-success"> ✔ nginx active (running)</span>
|
|
||||||
</div>
|
|
||||||
<div class="terminal-line-landing" style="animation-delay: 1.2s">
|
|
||||||
<span class="output-success"> ✔ mysql active (running)</span>
|
|
||||||
</div>
|
|
||||||
<div class="terminal-line-landing" style="animation-delay: 1.3s">
|
|
||||||
<span class="output-success"> ✔ sshd active (running)</span>
|
|
||||||
</div>
|
|
||||||
<div class="terminal-line-landing" style="animation-delay: 1.4s">
|
|
||||||
<span class="output-info"> ○ redis inactive (dead)</span>
|
|
||||||
</div>
|
|
||||||
<div class="terminal-line-landing" style="animation-delay: 1.5s">
|
|
||||||
<span class="prompt">admin@servermanager:~$</span>
|
|
||||||
<span class="cmd"> systemctl start redis</span>
|
|
||||||
</div>
|
|
||||||
<div class="terminal-line-landing" style="animation-delay: 1.7s">
|
|
||||||
<span class="output-success"> ✔ redis active (running)</span>
|
|
||||||
</div>
|
|
||||||
<div class="terminal-line-landing" style="animation-delay: 1.8s">
|
|
||||||
<span class="output-muted">All services operational.</span>
|
|
||||||
<span class="blinking-cursor"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -540,76 +60,127 @@ body.auth-page .auth-container {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Features section -->
|
||||||
<section class="features-section" id="features">
|
<section class="features-section" id="features">
|
||||||
<div class="features-header">
|
<div class="features-header">
|
||||||
<h2>Everything You Need</h2>
|
<h2>Everything you need to manage servers</h2>
|
||||||
<p>Complete server management toolkit at your fingertips</p>
|
<p>A comprehensive toolkit for sysadmins and developers.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="features-grid">
|
<div class="features-grid">
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="feature-icon blue"><i class="fas fa-terminal"></i></div>
|
<div class="feature-icon blue"><i class="fas fa-terminal"></i></div>
|
||||||
<h3>SSH Terminal</h3>
|
<h3>Remote SSH Terminal</h3>
|
||||||
<p>Browser-based SSH terminal with real-time output, command history, and multi-session support.</p>
|
<p>Browser-based SSH client with session persistence, tab support, and full terminal emulation.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="feature-icon green"><i class="fas fa-chart-line"></i></div>
|
<div class="feature-icon green"><i class="fas fa-chart-line"></i></div>
|
||||||
<h3>Real-Time Monitoring</h3>
|
<h3>Real-time Monitoring</h3>
|
||||||
<p>Monitor CPU, memory, disk usage, and network traffic with live metrics and historical charts.</p>
|
<p>Track CPU, memory, disk usage, and network activity with live-updating graphs and alerts.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="feature-icon purple"><i class="fas fa-shield-alt"></i></div>
|
<div class="feature-icon purple"><i class="fas fa-users-cog"></i></div>
|
||||||
<h3>SSL Management</h3>
|
|
||||||
<p>Automate SSL certificate issuance and renewal via Let's Encrypt with full control.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon orange"><i class="fas fa-globe"></i></div>
|
|
||||||
<h3>Nginx Manager</h3>
|
|
||||||
<p>Create, edit, and manage Nginx server blocks with syntax checking and site toggling.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon red"><i class="fas fa-cogs"></i></div>
|
|
||||||
<h3>Service Control</h3>
|
|
||||||
<p>Start, stop, restart, and monitor system services across all your servers.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon teal"><i class="fas fa-users-cog"></i></div>
|
|
||||||
<h3>Team Collaboration</h3>
|
<h3>Team Collaboration</h3>
|
||||||
<p>Assign role-based access to team members and delegate server management securely.</p>
|
<p>Multi-user support with role-based access control. Share servers and manage permissions.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon orange"><i class="fas fa-bell"></i></div>
|
||||||
|
<h3>Smart Notifications</h3>
|
||||||
|
<p>Get push notifications and email alerts when servers go down, resources spike, or backups complete.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon red"><i class="fas fa-history"></i></div>
|
||||||
|
<h3>Audit Logging</h3>
|
||||||
|
<p>Every action is logged with timestamps and user attribution for full compliance and traceability.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon teal"><i class="fas fa-shield-alt"></i></div>
|
||||||
|
<h3>Secure by Design</h3>
|
||||||
|
<p>AES-256 encrypted credentials, CSRF protection, rate limiting, and session management built in.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="stats-section" id="stats">
|
<!-- Stats Section -->
|
||||||
|
<section class="stats-section">
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<div class="stat-number" data-target="<?= (int) ($stats['servers'] ?? 0) ?>"><?= (int) ($stats['servers'] ?? 0) ?></div>
|
<div class="stat-number" data-target="15">0</div>
|
||||||
<div class="stat-label">Servers Managed</div>
|
<div class="stat-label">Servers Managed</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<div class="stat-number" data-target="<?= (int) ($stats['users'] ?? 0) ?>"><?= (int) ($stats['users'] ?? 0) ?></div>
|
<div class="stat-number" data-target="99.9">0</div>
|
||||||
|
<div class="stat-label">Uptime %</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number" data-target="7">0</div>
|
||||||
<div class="stat-label">Active Users</div>
|
<div class="stat-label">Active Users</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<div class="stat-number" data-target="<?= (int) ($stats['online_servers'] ?? 0) ?>"><?= (int) ($stats['online_servers'] ?? 0) ?></div>
|
<div class="stat-number" data-target="24">0</div>
|
||||||
<div class="stat-label">Servers Online</div>
|
<div class="stat-label">Hour Monitoring</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
<footer class="landing-footer">
|
<footer class="landing-footer">
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="/privacy">Privacy Policy</a>
|
<a href="/terms">Terms</a>
|
||||||
<a href="/terms">Terms & Conditions</a>
|
<a href="/privacy">Privacy</a>
|
||||||
|
<a href="https://github.com/devlab-lat/server-manager">GitHub</a>
|
||||||
</div>
|
</div>
|
||||||
<p>© <?= date('Y') ?> ServerManager. All rights reserved.</p>
|
<span>© <?= date('Y') ?> ServerManager. Released under the MIT License.</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Page-specific JS -->
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
document.querySelectorAll('.stat-number[data-target]').forEach(function(el) {
|
// Animated counter
|
||||||
var target = parseInt(el.getAttribute('data-target'), 10);
|
const counters = document.querySelectorAll('.stat-number');
|
||||||
el.textContent = target;
|
|
||||||
});
|
function animateCounter(counter) {
|
||||||
})();
|
const target = parseFloat(counter.dataset.target);
|
||||||
|
const duration = 2000;
|
||||||
|
const startTime = performance.now();
|
||||||
|
const startValue = 0;
|
||||||
|
|
||||||
|
function update(currentTime) {
|
||||||
|
const elapsed = currentTime - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
const current = startValue + (target - startValue) * eased;
|
||||||
|
|
||||||
|
counter.textContent = target % 1 === 0 ? Math.round(current) : current.toFixed(1);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
} else {
|
||||||
|
counter.textContent = target % 1 === 0 ? Math.round(target) : target.toFixed(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intersection Observer for stats
|
||||||
|
const statsSection = document.querySelector('.stats-section');
|
||||||
|
if (statsSection) {
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
counters.forEach(counter => animateCounter(counter));
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.5 });
|
||||||
|
observer.observe(statsSection);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,24 +1,4 @@
|
|||||||
<style>
|
<?php ?><link rel="stylesheet" href="/assets/css/legal.css">
|
||||||
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; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<div class="legal-page">
|
<div class="legal-page">
|
||||||
<div class="legal-container">
|
<div class="legal-container">
|
||||||
<div class="legal-card" style="width:85%;margin:3rem auto;padding:3rem 3.5rem;background:var(--card-bg);border-radius:12px;box-shadow:0 2px 16px rgba(0,0,0,0.18)">
|
<div class="legal-card" style="width:85%;margin:3rem auto;padding:3rem 3.5rem;background:var(--card-bg);border-radius:12px;box-shadow:0 2px 16px rgba(0,0,0,0.18)">
|
||||||
@@ -29,24 +9,19 @@ body.auth-page .auth-container {
|
|||||||
<?php
|
<?php
|
||||||
$lines = explode("\n", $content ?? '');
|
$lines = explode("\n", $content ?? '');
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$line = htmlspecialchars($line);
|
$trimmed = trim($line);
|
||||||
if (preg_match('/^[A-Z][A-Z\s]+$/', trim($line))) {
|
if ($trimmed === '') {
|
||||||
echo '<h2 style="color:var(--accent-primary);font-size:1.2rem;margin:2rem 0 1rem;font-weight:600">' . $line . '</h2>';
|
echo '<br>';
|
||||||
} elseif (preg_match('/^[a-z]\)\s/', trim($line))) {
|
} elseif (str_starts_with($trimmed, '# ')) {
|
||||||
echo '<p style="margin:0.4rem 0 0.4rem 2rem">' . $line . '</p>';
|
echo '<h2 style="margin:2rem 0 0.75rem;font-size:1.2rem;font-weight:600;color:var(--text-primary)">' . htmlspecialchars(substr($trimmed, 2)) . '</h2>';
|
||||||
} elseif (preg_match('/^\d+\./', trim($line))) {
|
} elseif (str_starts_with($trimmed, '## ')) {
|
||||||
echo '<h3 style="color:var(--text-primary);font-size:1rem;margin:1.2rem 0 0.5rem;font-weight:600">' . $line . '</h3>';
|
echo '<h3 style="margin:1.5rem 0 0.5rem;font-size:1.05rem;font-weight:600;color:var(--text-primary)">' . htmlspecialchars(substr($trimmed, 3)) . '</h3>';
|
||||||
} elseif (trim($line) === '') {
|
|
||||||
echo '<div style="height:0.5rem"></div>';
|
|
||||||
} else {
|
} else {
|
||||||
echo '<p style="margin:0.4rem 0">' . $line . '</p>';
|
echo '<p style="margin-bottom:0.75rem">' . htmlspecialchars($trimmed) . '</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
<div class="legal-footer" style="margin-top:2.5rem;padding-top:1.5rem;border-top:1px solid var(--border-color);text-align:center">
|
|
||||||
<p><a href="/" style="color:var(--accent-primary);text-decoration:none;font-size:0.95rem">← Back to ServerManager</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,4 @@
|
|||||||
<style>
|
<?php ?><link rel="stylesheet" href="/assets/css/legal.css">
|
||||||
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; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<div class="legal-page">
|
<div class="legal-page">
|
||||||
<div class="legal-container">
|
<div class="legal-container">
|
||||||
<div class="legal-card" style="width:85%;margin:3rem auto;padding:3rem 3.5rem;background:var(--card-bg);border-radius:12px;box-shadow:0 2px 16px rgba(0,0,0,0.18)">
|
<div class="legal-card" style="width:85%;margin:3rem auto;padding:3rem 3.5rem;background:var(--card-bg);border-radius:12px;box-shadow:0 2px 16px rgba(0,0,0,0.18)">
|
||||||
@@ -29,26 +9,19 @@ body.auth-page .auth-container {
|
|||||||
<?php
|
<?php
|
||||||
$lines = explode("\n", $content ?? '');
|
$lines = explode("\n", $content ?? '');
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$line = htmlspecialchars($line);
|
$trimmed = trim($line);
|
||||||
if (preg_match('/^[A-Z][A-Z\s]+$/', trim($line))) {
|
if ($trimmed === '') {
|
||||||
echo '<h2 style="color:var(--accent-primary);font-size:1.2rem;margin:2rem 0 1rem;font-weight:600">' . $line . '</h2>';
|
echo '<br>';
|
||||||
} elseif (preg_match('/^\d+\.\d/', trim($line))) {
|
} elseif (str_starts_with($trimmed, '# ')) {
|
||||||
echo '<h3 style="color:var(--text-primary);font-size:1rem;margin:1rem 0 0.4rem 1.5rem;font-weight:600">' . $line . '</h3>';
|
echo '<h2 style="margin:2rem 0 0.75rem;font-size:1.2rem;font-weight:600;color:var(--text-primary)">' . htmlspecialchars(substr($trimmed, 2)) . '</h2>';
|
||||||
} elseif (preg_match('/^\d+\./', trim($line))) {
|
} elseif (str_starts_with($trimmed, '## ')) {
|
||||||
echo '<h3 style="color:var(--text-primary);font-size:1rem;margin:1.2rem 0 0.5rem;font-weight:600">' . $line . '</h3>';
|
echo '<h3 style="margin:1.5rem 0 0.5rem;font-size:1.05rem;font-weight:600;color:var(--text-primary)">' . htmlspecialchars(substr($trimmed, 3)) . '</h3>';
|
||||||
} elseif (preg_match('/^[-–—]\s/', trim($line))) {
|
|
||||||
echo '<p style="margin:0.4rem 0 0.4rem 2rem">' . $line . '</p>';
|
|
||||||
} elseif (trim($line) === '') {
|
|
||||||
echo '<div style="height:0.5rem"></div>';
|
|
||||||
} else {
|
} else {
|
||||||
echo '<p style="margin:0.4rem 0">' . $line . '</p>';
|
echo '<p style="margin-bottom:0.75rem">' . htmlspecialchars($trimmed) . '</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
<div class="legal-footer" style="margin-top:2.5rem;padding-top:1.5rem;border-top:1px solid var(--border-color);text-align:center">
|
|
||||||
<p><a href="/" style="color:var(--accent-primary);text-decoration:none;font-size:0.95rem">← Back to ServerManager</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
42
views/servers/create.php
Executable file → Normal file
42
views/servers/create.php
Executable file → Normal file
@@ -170,45 +170,5 @@ document.querySelectorAll('.auth-method-tabs .tab').forEach(tab => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let permIndex = <?= !empty($savedPerms) && is_array($savedPerms) ? count($savedPerms) : 0 ?>;
|
let permIndex = <?= !empty($savedPerms) && is_array($savedPerms) ? count($savedPerms) : 0 ?>;
|
||||||
|
|
||||||
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 = `
|
|
||||||
<select name="permissions[${idx}][type]" class="form-input permission-type">
|
|
||||||
<option value="sudo" ${type === 'sudo' ? 'selected' : ''}>Sudo (passwordless)</option>
|
|
||||||
<option value="command" ${type === 'command' ? 'selected' : ''}>Command</option>
|
|
||||||
<option value="file_read" ${type === 'file_read' ? 'selected' : ''}>File Read</option>
|
|
||||||
<option value="file_write" ${type === 'file_write' ? 'selected' : ''}>File Write</option>
|
|
||||||
<option value="custom" ${type === 'custom' ? 'selected' : ''}>Custom Command</option>
|
|
||||||
</select>
|
|
||||||
<input type="text" name="permissions[${idx}][value]" class="form-input permission-value"
|
|
||||||
placeholder="e.g., systemctl" value="${escapeHtml(value || '')}">
|
|
||||||
<input type="text" name="permissions[${idx}][description]" class="form-input permission-desc"
|
|
||||||
placeholder="Description (optional)" value="${escapeHtml(description || '')}">
|
|
||||||
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
|
|
||||||
`;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/assets/js/server-permissions.js"></script>
|
||||||
|
|||||||
@@ -92,322 +92,8 @@ $status = $db['status'] ?? 'inactive';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let currentDb = '';
|
const serverId = <?= json_encode($server['id']) ?>;
|
||||||
let currentTable = '';
|
const databases = <?= json_encode(array_column($databases, 'name')) ?>;
|
||||||
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/<?= $server['id'] ?>/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 = '<div style="padding:0.3rem 0.5rem"><input type="text" class="form-input tbl-filter" placeholder="Filter tables..." style="font-size:0.75rem;padding:0.25rem 0.4rem;width:100%;box-sizing:border-box" oninput="filterTables(this)"></div>';
|
|
||||||
(data.tables || []).forEach(t => {
|
|
||||||
html += '<div class="pma-table-item" data-tbl="' + escHtml(t.name).toLowerCase() + '" onclick="selectTable(\'' + db.replace(/'/g,"\\'") + "','" + t.name.replace(/'/g,"\\'") + '\')">'
|
|
||||||
+ '<i class="fas fa-table"></i> ' + escHtml(t.name)
|
|
||||||
+ ' <span class="pma-db-size">' + t.rows + ' rows</span></div>';
|
|
||||||
});
|
|
||||||
if (!html) html = '<div class="pma-table-item" style="color:var(--text-muted);cursor:default">No tables</div>';
|
|
||||||
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 = '<div style="text-align:center;padding:2rem;color:var(--text-muted)"><i class="fas fa-spinner fa-spin"></i> Loading structure...</div>';
|
|
||||||
fetch('/servers/<?= $server['id'] ?>/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='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
|
||||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-list"></i> <code>'+escHtml(table)+'</code></h2>'
|
|
||||||
+'<span class="badge badge-info">'+d.columns.length+' columns</span>'
|
|
||||||
+'<span style="flex:1"></span>'
|
|
||||||
+'<button class="btn btn-xs btn-info" onclick="browseTable(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\')"><i class="fas fa-search"></i> Browse</button>'
|
|
||||||
+'</div>';
|
|
||||||
html+='<div class="table-responsive"><table class="table table-sm"><thead><tr><th>#</th><th>Field</th><th>Type</th><th>Collation</th><th>Null</th><th>Key</th><th>Default</th><th>Extra</th></tr></thead><tbody>';
|
|
||||||
d.columns.forEach((c,i)=>{
|
|
||||||
html+='<tr><td>'+(i+1)+'</td><td><strong>'+escHtml(c.field)+'</strong></td><td><code>'+escHtml(c.type)+'</code></td>'
|
|
||||||
+'<td>'+escHtml(c.collation)+'</td><td>'+escHtml(c.null)+'</td><td><strong>'+escHtml(c.key)+'</strong></td>'
|
|
||||||
+'<td>'+(c.default??'NULL')+'</td><td>'+escHtml(c.extra)+'</td></tr>';
|
|
||||||
});
|
|
||||||
html+='</tbody></table></div>';
|
|
||||||
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='<div style="text-align:center;padding:2rem;color:var(--text-muted)"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
|
|
||||||
|
|
||||||
let body='action=browse&db='+encodeURIComponent(db)+'&table='+encodeURIComponent(table)+'&page='+page+filters+'&_csrf_token='+encodeURIComponent(getCsrfToken());
|
|
||||||
|
|
||||||
fetch('/servers/<?= $server['id'] ?>/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='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:0.5rem">'
|
|
||||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-search"></i> <code>'+escHtml(table)+'</code></h2>'
|
|
||||||
+'<span class="badge badge-info">'+d.total+' rows</span></div>';
|
|
||||||
html+='<form onsubmit="event.preventDefault();loadBrowse(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\',1)">';
|
|
||||||
html+='<div class="table-responsive" style="max-height:450px;overflow-y:auto"><table class="table table-sm"><thead><tr>';
|
|
||||||
d.columns.forEach(c=>{html+='<th>'+escHtml(c)+'</th>'});
|
|
||||||
html+='</tr><tr class="pma-filter-row">';
|
|
||||||
d.columns.forEach(c=>{
|
|
||||||
const cv=new URLSearchParams(filters.replace(/^&/,'')).get(c)||'';
|
|
||||||
html+='<td><input type="text" class="form-input pma-fi" data-col="'+escHtml(c)+'" value="'+escHtml(cv)+'" placeholder="'+escHtml(c)+'" style="font-size:0.75rem;padding:0.2rem 0.35rem;width:100%;min-width:50px"></td>';
|
|
||||||
});
|
|
||||||
html+='</tr></thead><tbody>';
|
|
||||||
d.rows.forEach(row=>{
|
|
||||||
html+='<tr>';
|
|
||||||
row.forEach(val=>{html+='<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis"><code>'+escHtml(String(val).substring(0,80))+'</code></td>'});
|
|
||||||
html+='</tr>';
|
|
||||||
});
|
|
||||||
html+='</tbody></table></div>';
|
|
||||||
html+='<div style="display:flex;gap:0.5rem;margin-top:0.5rem;align-items:center">';
|
|
||||||
html+='<button type="submit" class="btn btn-primary btn-sm"><i class="fas fa-search"></i> Filter</button>';
|
|
||||||
html+='<button type="button" class="btn btn-secondary btn-sm" onclick="clearBrowseFilters(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\')"><i class="fas fa-undo"></i> Clear</button>';
|
|
||||||
if(tp>1){
|
|
||||||
html+='<span style="flex:1"></span>';
|
|
||||||
for(let p=1;p<=tp&&p<=10;p++)html+='<button type="button" class="btn btn-xs '+(p===page?'btn-primary':'btn-secondary')+'" onclick="loadBrowse(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\','+p+')">'+p+'</button>';
|
|
||||||
if(tp>10)html+='<span class="text-muted">...</span>';
|
|
||||||
}
|
|
||||||
html+='</div></form>';
|
|
||||||
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='<div style="text-align:center;padding:2rem;color:var(--text-muted)"><i class="fas fa-spinner fa-spin"></i> Loading users...</div>';
|
|
||||||
|
|
||||||
fetch('/servers/<?= $server['id'] ?>/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='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
|
||||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-users"></i> Database Users</h2>'
|
|
||||||
+'<span class="badge badge-info">'+(d.users?.length||0)+' users</span>'
|
|
||||||
+'<span style="flex:1"></span>'
|
|
||||||
+'<button class="btn btn-sm btn-success" onclick="showCreateUser()"><i class="fas fa-plus"></i> New User</button></div>';
|
|
||||||
html+='<div class="table-responsive"><table class="table table-sm"><thead><tr><th>User</th><th>Host</th><th>Databases</th><th>Expired</th><th>Actions</th></tr></thead><tbody>';
|
|
||||||
(d.users||[]).forEach(u=>{
|
|
||||||
const dbList=(d.grants||[]).filter(g=>g.user===u.user&&g.host===u.host).map(g=>g.db).join(', ');
|
|
||||||
html+='<tr><td><strong>'+escHtml(u.user)+'</strong></td><td><code>'+escHtml(u.host)+'</code></td><td>'+(dbList||'<span class="text-muted">-</span>')+'</td><td>'+escHtml(u.expired)+'</td>'
|
|
||||||
+'<td class="actions-cell">'
|
|
||||||
+'<button class="btn btn-xs btn-secondary" onclick="showEditUser(\''+escHtml(u.user)+"','"+escHtml(u.host)+'\')" title="Change password"><i class="fas fa-key"></i></button> '
|
|
||||||
+'<button class="btn btn-xs btn-danger" onclick="deleteUser(\''+escHtml(u.user)+"','"+escHtml(u.host)+'\')" title="Delete"><i class="fas fa-trash"></i></button>'
|
|
||||||
+'</td></tr>';
|
|
||||||
});
|
|
||||||
html+='</tbody></table></div>';
|
|
||||||
panel.innerHTML=html;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCreateUser() {
|
|
||||||
const panel=document.getElementById('pmaUsers');
|
|
||||||
let html='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
|
||||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-user-plus"></i> Create User</h2>'
|
|
||||||
+'<span style="flex:1"></span>'
|
|
||||||
+'<button class="btn btn-sm btn-secondary" onclick="loadUsers()"><i class="fas fa-arrow-left"></i> Back</button></div>'
|
|
||||||
+'<div class="dashboard-grid"><div class="dashboard-card"><div class="card-header"><h2><i class="fas fa-user"></i> Account</h2></div><div class="card-body">'
|
|
||||||
+'<div class="form-row"><div class="form-group"><label>Username</label><input type="text" id="nuUser" class="form-input" placeholder="username"></div>'
|
|
||||||
+'<div class="form-group"><label>Password</label><input type="password" id="nuPass" class="form-input" placeholder="password"></div>'
|
|
||||||
+'<div class="form-group"><label>Host</label><input type="text" id="nuHost" class="form-input" value="localhost" placeholder="localhost"></div></div></div></div>'
|
|
||||||
+'<div class="dashboard-card"><div class="card-header"><h2><i class="fas fa-database"></i> Database Access</h2></div><div class="card-body">'
|
|
||||||
+'<div style="margin-bottom:0.75rem"><label class="checkbox-label"><input type="checkbox" id="nuAllDb" onchange="document.querySelectorAll(\'.nuDbCheck\').forEach(c=>c.checked=this.checked)"> <strong>Select all</strong></label></div>';
|
|
||||||
|
|
||||||
<?php foreach ($databases as $d): ?>
|
|
||||||
var dbname = '<?= htmlspecialchars($d['name']) ?>';
|
|
||||||
if (dbname !== 'information_schema' && dbname !== 'performance_schema' && dbname !== 'mysql' && dbname !== 'sys') {
|
|
||||||
html+='<label class="checkbox-label" style="margin-top:0.25rem"><input type="checkbox" class="nuDbCheck" value="'+dbname+'"> <span>'+dbname+'</span></label>';
|
|
||||||
}
|
|
||||||
<?php endforeach; ?>
|
|
||||||
|
|
||||||
html+='</div></div></div>'
|
|
||||||
+'<div class="dashboard-card"><div class="card-header"><h2><i class="fas fa-lock"></i> Privileges</h2></div><div class="card-body">'
|
|
||||||
+'<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.25rem">';
|
|
||||||
['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+='<label class="checkbox-label" style="font-size:0.82rem"><input type="checkbox" class="nuPriv" value="'+p+'"'+checked+'> <span>'+p+'</span></label>';
|
|
||||||
});
|
|
||||||
html+='</div></div></div>'
|
|
||||||
+'<div style="margin-top:1rem;display:flex;gap:0.5rem">'
|
|
||||||
+'<button class="btn btn-primary" onclick="doCreateUser()"><i class="fas fa-save"></i> Create User</button>'
|
|
||||||
+'<button class="btn btn-secondary" onclick="loadUsers()">Cancel</button></div>';
|
|
||||||
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/<?= $server['id'] ?>/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/<?= $server['id'] ?>/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='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
|
||||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-key"></i> Change Password</h2>'
|
|
||||||
+'<code>'+escHtml(user)+'</code> @ <code>'+escHtml(host)+'</code>'
|
|
||||||
+'<span style="flex:1"></span>'
|
|
||||||
+'<button class="btn btn-sm btn-secondary" onclick="loadUsers()"><i class="fas fa-arrow-left"></i> Back</button></div>'
|
|
||||||
+'<div class="card"><div class="card-body">'
|
|
||||||
+'<div class="form-group"><label>New Password</label><input type="password" id="epPass" class="form-input" placeholder="new password"></div>'
|
|
||||||
+'<div style="margin-top:1rem;display:flex;gap:0.5rem">'
|
|
||||||
+'<button class="btn btn-primary" onclick="doChangePass(\''+escHtml(user)+"','"+escHtml(host)+'\')"><i class="fas fa-save"></i> Save</button>'
|
|
||||||
+'<button class="btn btn-secondary" onclick="loadUsers()">Cancel</button></div></div></div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function doChangePass(user, host) {
|
|
||||||
const pass = document.getElementById('epPass').value;
|
|
||||||
if (!pass) { showToast('error','Password required'); return; }
|
|
||||||
fetch('/servers/<?= $server['id'] ?>/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/<?= $server['id'] ?>/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/<?= $server['id'] ?>/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;}
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/assets/js/database-manager.js"></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
42
views/servers/edit.php
Executable file → Normal file
42
views/servers/edit.php
Executable file → Normal file
@@ -155,45 +155,5 @@ $hasKey = !empty($server['ssh_key']);
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
let permIndex = <?= count($permissions) ?>;
|
let permIndex = <?= count($permissions) ?>;
|
||||||
|
|
||||||
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 = `
|
|
||||||
<select name="permissions[${idx}][type]" class="form-input permission-type">
|
|
||||||
<option value="sudo" ${type === 'sudo' ? 'selected' : ''}>Sudo (passwordless)</option>
|
|
||||||
<option value="command" ${type === 'command' ? 'selected' : ''}>Command</option>
|
|
||||||
<option value="file_read" ${type === 'file_read' ? 'selected' : ''}>File Read</option>
|
|
||||||
<option value="file_write" ${type === 'file_write' ? 'selected' : ''}>File Write</option>
|
|
||||||
<option value="custom" ${type === 'custom' ? 'selected' : ''}>Custom Command</option>
|
|
||||||
</select>
|
|
||||||
<input type="text" name="permissions[${idx}][value]" class="form-input permission-value"
|
|
||||||
placeholder="e.g., systemctl" value="${escapeHtml(value || '')}">
|
|
||||||
<input type="text" name="permissions[${idx}][description]" class="form-input permission-desc"
|
|
||||||
placeholder="Description (optional)" value="${escapeHtml(description || '')}">
|
|
||||||
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
|
|
||||||
`;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/assets/js/server-permissions.js"></script>
|
||||||
|
|||||||
33
views/servers/index.php
Executable file → Normal file
33
views/servers/index.php
Executable file → Normal file
@@ -141,35 +141,4 @@ $groups = $groups ?? [];
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="/assets/js/server-list.js"></script>
|
||||||
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);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
0
views/servers/logs.php
Executable file → Normal file
0
views/servers/logs.php
Executable file → Normal file
@@ -321,326 +321,7 @@ $configValid = $nginx['config_valid'] ?? false;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let currentEditFile = null;
|
const serverId = <?= json_encode($server['id']) ?>;
|
||||||
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('<span>' + i + '</span>');
|
|
||||||
}
|
|
||||||
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(/>/g, '>')
|
|
||||||
.replace(/#(.*)$/gm, '<span class="hl-comment">#$1</span>')
|
|
||||||
.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, '<span class="hl-keyword">$1</span>')
|
|
||||||
.replace(/\b(\d+)\b/g, '<span class="hl-number">$1</span>')
|
|
||||||
.replace(/\b(https?|fastcgi|unix):\/\/[^\s;{]+/g, '<span class="hl-string">$&</span>')
|
|
||||||
.replace(/'(.*?)'/g, '<span class="hl-string">\'$1\'</span>')
|
|
||||||
.replace(/(\$\w+)/g, '<span class="hl-var">$1</span>')
|
|
||||||
.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 '<span class="hl-string">' + m + '</span>';
|
|
||||||
}
|
|
||||||
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 = '<i class="fas fa-spinner fa-spin"></i> Working...';
|
|
||||||
|
|
||||||
fetch('/servers/<?= $server['id'] ?>/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/<?= $server['id'] ?>/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/<?= $server['id'] ?>/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/<?= $server['id'] ?>/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/<?= $server['id'] ?>/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 = '<i class="fas fa-spinner fa-spin"></i> Creating...';
|
|
||||||
|
|
||||||
const formData = new URLSearchParams(new FormData(document.getElementById('newSiteForm')));
|
|
||||||
formData.append('_csrf_token', getCsrfToken());
|
|
||||||
|
|
||||||
fetch('/servers/<?= $server['id'] ?>/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 = '<i class="fas fa-plus"></i> 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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/assets/js/nginx-manager.js"></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
64
views/servers/processes.php
Executable file → Normal file
64
views/servers/processes.php
Executable file → Normal file
@@ -101,66 +101,6 @@ $processList = $processList ?? [];
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let pendingPid = null;
|
const serverId = <?= $server['id'] ?>;
|
||||||
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 = '<i class="fas fa-spinner fa-spin"></i> Killing...';
|
|
||||||
|
|
||||||
fetch('/servers/<?= $server['id'] ?>/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 = '<i class="fas fa-check"></i> 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();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/assets/js/server-processes.js"></script>
|
||||||
|
|||||||
@@ -49,92 +49,7 @@ $totalPages = max(1, ceil(count($services) / $perPage));
|
|||||||
<script>
|
<script>
|
||||||
const allServices = <?= json_encode($services, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;
|
const allServices = <?= json_encode($services, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
const perPage = <?= $perPage ?>;
|
const perPage = <?= $perPage ?>;
|
||||||
|
const serverId = <?= $server['id'] ?>;
|
||||||
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 += '<tr class="service-row">';
|
|
||||||
html += '<td><code>' + escHtml(s.name) + '</code></td>';
|
|
||||||
html += '<td><span class="badge ' + (isActive ? 'badge-success' : isInactive ? 'badge-secondary' : 'badge-warning') + '">' + s.status + '</span></td>';
|
|
||||||
html += '<td class="actions-cell" style="white-space:nowrap">';
|
|
||||||
if (!isActive) html += '<button class="btn btn-xs btn-success" onclick="serviceAction(\'start\',\'' + escHtml(s.name) + '\')" title="Start"><i class="fas fa-play"></i></button> ';
|
|
||||||
if (!isInactive) html += '<button class="btn btn-xs btn-danger" onclick="serviceAction(\'stop\',\'' + escHtml(s.name) + '\')" title="Stop"><i class="fas fa-stop"></i></button> ';
|
|
||||||
html += '<button class="btn btn-xs btn-warning" onclick="serviceAction(\'restart\',\'' + escHtml(s.name) + '\')" title="Restart"><i class="fas fa-redo"></i></button> ';
|
|
||||||
html += '<button class="btn btn-xs btn-info" onclick="serviceAction(\'reload\',\'' + escHtml(s.name) + '\')" title="Reload"><i class="fas fa-sync-alt"></i></button> ';
|
|
||||||
if (!s.enabled) html += '<button class="btn btn-xs btn-secondary" onclick="serviceAction(\'enable\',\'' + escHtml(s.name) + '\')" title="Enable"><i class="fas fa-power-off"></i></button> ';
|
|
||||||
if (s.enabled) html += '<button class="btn btn-xs btn-secondary" onclick="serviceAction(\'disable\',\'' + escHtml(s.name) + '\')" title="Disable"><i class="fas fa-ban"></i></button> ';
|
|
||||||
html += '</td></tr>';
|
|
||||||
});
|
|
||||||
if (!html) html = '<tr><td colspan="3" style="text-align:center;color:var(--text-muted);padding:2rem">No services match the filter.</td></tr>';
|
|
||||||
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 += '<button class="btn btn-xs ' + (p === page ? 'btn-primary' : 'btn-secondary') + '" onclick="renderPage(list,' + p + ')" data-list=\'' + JSON.stringify(list).replace(/'/g,"'") + '\'>' + p + '</button>';
|
|
||||||
}
|
|
||||||
if (totalPages > 15) html += '<span class="text-muted" style="font-size:0.8rem">...</span>';
|
|
||||||
container.innerHTML = html + ' <span class="text-muted" style="font-size:0.8rem">' + list.length + ' services</span>';
|
|
||||||
// Fix pagination onclick by rebinding
|
|
||||||
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/<?= $server['id'] ?>/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(); });
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/assets/js/server-services.js"></script>
|
||||||
|
|
||||||
|
|||||||
329
views/servers/show.php
Executable file → Normal file
329
views/servers/show.php
Executable file → Normal file
@@ -370,330 +370,9 @@ $canManage = $server_role === 'owner' || $server_role === 'manager';
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||||
|
<script>const historicalData = <?= json_encode($historicalMetrics, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;</script>
|
||||||
<script>
|
<script>
|
||||||
const historicalData = <?= json_encode($historicalMetrics, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;
|
const serverId = <?= $server['id'] ?>;
|
||||||
|
const serverName = '<?= addslashes($server['name'] ?? '') ?>';
|
||||||
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 <strong><?= htmlspecialchars($server['name'] ?? '') ?></strong> 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 <strong><?= htmlspecialchars($server['name'] ?? '') ?></strong> 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/<?= $server['id'] ?>/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/<?= $server['id'] ?>/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/<?= $server['id'] ?>/' + 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/<?= $server['id'] ?>/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/<?= $server['id'] ?>/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/<?= $server['id'] ?>/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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/assets/js/server-show.js"></script>
|
||||||
|
|||||||
@@ -189,77 +189,6 @@ $certs = $ssl['certificates'] ?? [];
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function getCsrfToken() {
|
const serverId = <?= $server['id'] ?>;
|
||||||
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/<?= $server['id'] ?>/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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-plus"></i> 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(); }
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/assets/js/server-ssl.js"></script>
|
||||||
|
|||||||
0
views/servers/system_info.php
Executable file → Normal file
0
views/servers/system_info.php
Executable file → Normal file
@@ -121,46 +121,6 @@ $lastLogins = $sysusers['last_logins'] ?? '';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; }
|
const serverId = <?= $server['id'] ?>;
|
||||||
|
|
||||||
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/<?= $server['id'] ?>/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/<?= $server['id'] ?>/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/<?= $server['id'] ?>/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/<?= $server['id'] ?>/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/<?= $server['id'] ?>/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')}});
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/assets/js/system-users.js"></script>
|
||||||
|
|||||||
@@ -71,66 +71,4 @@
|
|||||||
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
|
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
<script src="/assets/js/team-index.js"></script>
|
||||||
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 = '<i class="fas fa-users-cog"></i><p>No teams match your search.</p>';
|
|
||||||
container.appendChild(div);
|
|
||||||
} else {
|
|
||||||
empty.querySelector('p').textContent = 'No teams match your search.';
|
|
||||||
empty.style.display = '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (empty) empty.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update URL without reload
|
|
||||||
const url = q ? '?search=' + encodeURIComponent(query) : window.location.pathname;
|
|
||||||
window.history.replaceState({}, '', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('teamSearch')?.addEventListener('input', function() {
|
|
||||||
filterTeams(this.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore search on page load
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -146,200 +146,8 @@ $availableServers = $availableServers ?? [];
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function getCsrfToken() {
|
const teamId = <?= $team['id'] ?>;
|
||||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
const teamName = '<?= htmlspecialchars(addslashes($team['name'] ?? '')) ?>';
|
||||||
}
|
const teamDescription = '<?= htmlspecialchars(addslashes($team['description'] ?? '')) ?>';
|
||||||
|
|
||||||
function editTeam() {
|
|
||||||
const name = prompt('Team name:', '<?= htmlspecialchars(addslashes($team['name'] ?? '')) ?>');
|
|
||||||
if (!name) return;
|
|
||||||
const desc = prompt('Description:', '<?= htmlspecialchars(addslashes($team['description'] ?? '')) ?>');
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('name', name);
|
|
||||||
form.append('description', desc || '');
|
|
||||||
form.append('_csrf_token', getCsrfToken());
|
|
||||||
|
|
||||||
fetch('/teams/<?= $team['id'] ?>/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/<?= $team['id'] ?>/delete';
|
|
||||||
document.getElementById('postForm').submit();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───── Autocomplete para usuarios ───── */
|
|
||||||
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 = '<strong>' + escapeHtml(u.username) + '</strong> <small>' + escapeHtml(u.email) + '</small>';
|
|
||||||
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 = '<div class="autocomplete-empty">No users found</div>';
|
|
||||||
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');
|
|
||||||
|
|
||||||
// Auto-add the user
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('user_id', id);
|
|
||||||
form.append('_csrf_token', getCsrfToken());
|
|
||||||
|
|
||||||
fetch('/teams/<?= $team['id'] ?>/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 = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───── Funciones existentes ───── */
|
|
||||||
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/<?= $team['id'] ?>/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/<?= $team['id'] ?>/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/<?= $team['id'] ?>/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/<?= $team['id'] ?>/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); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/assets/js/team-show.js"></script>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ $commandHistory = $commandHistory ?? [];
|
|||||||
\___ \ / _ \ '__\ \ / / _ \ '__| | |\/| |/ _` | '_ \ / _` |/ _` |/ _ \ '__|
|
\___ \ / _ \ '__\ \ / / _ \ '__| | |\/| |/ _` | '_ \ / _` |/ _` |/ _ \ '__|
|
||||||
___) | __/ | \ V / __/ | | | | | (_| | | | | (_| | (_| | __/ |
|
___) | __/ | \ V / __/ | | | | | (_| | | | | (_| | (_| | __/ |
|
||||||
|____/ \___|_| \_/ \___|_| |_| |_|\__,_|_| |_|\__,_|\__, |\___|_|
|
|____/ \___|_| \_/ \___|_| |_| |_|\__,_|_| |_|\__,_|\__, |\___|_|
|
||||||
|___/
|
|___/
|
||||||
</pre>
|
</pre>
|
||||||
<p>Connected to <strong><?= htmlspecialchars($server['name']) ?></strong></p>
|
<p>Connected to <strong><?= htmlspecialchars($server['name']) ?></strong></p>
|
||||||
<p class="text-muted">Type commands below to execute them on the remote server.</p>
|
<p class="text-muted">Type commands below to execute them on the remote server.</p>
|
||||||
@@ -149,121 +149,5 @@ $commandHistory = $commandHistory ?? [];
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const serverId = <?= $server['id'] ?>;
|
const serverId = <?= $server['id'] ?>;
|
||||||
|
|
||||||
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 escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = '<span class="terminal-prompt-inline">$</span> ' + escapeHtml(text);
|
|
||||||
} else {
|
|
||||||
line.innerHTML = '<pre>' + escapeHtml(text) + '</pre>';
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/assets/js/terminal.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user