Compare commits
69 Commits
95066d9aea
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e8c51a2110 | |||
| 9bb25b3c40 | |||
| c76f92886c | |||
| 8fde59f411 | |||
| 639af6371e | |||
| 16979fc8d1 | |||
| 01f1215cef | |||
| a25ebdcb75 | |||
| aea0a4d189 | |||
| 6b2300de07 | |||
| e6a70c427e | |||
| 448a593423 | |||
| 5453a5d856 | |||
| f48a5a5495 | |||
| 0b8d9b52c8 | |||
| f8ce4066fe | |||
| e6017b80da | |||
| 36b0d8dfd9 | |||
| 8fbc0f164a | |||
| 379158ac93 | |||
| 00bad074d3 | |||
| 74cf9739d6 | |||
| baa9ab4b7b | |||
| b7bd4bfe92 | |||
| 49d724cf9c | |||
| 7233c224ce | |||
| 5c802f9c1e | |||
| 12ad0e0510 | |||
| 4ce6b27f96 | |||
| 4ee2644be0 | |||
| d1da560764 | |||
| 08a27fe00e | |||
| c9ab2ca98b | |||
| 216a38bc71 | |||
| 88f3c1f964 | |||
| 73d02e8ca4 | |||
| 142fe304c9 | |||
| 7c74e2316e | |||
| a5eb101d98 | |||
| 40748d7e5f | |||
| c16236314e | |||
| 471c4a5d49 | |||
| fee7f3b24a | |||
| 9ef4eb2856 | |||
| b05c1120f0 | |||
| 6f0c750703 | |||
| 3639f16757 | |||
| 52947f7b21 | |||
| 19fc37c1f3 | |||
| ba3582c77f | |||
| 60e0719eda | |||
| 2864b33dcb | |||
| 411c16a77d | |||
| a499a1ed8b | |||
| ec38afe3e9 | |||
| 81f61ff96e | |||
| 5691d8b862 | |||
| a2cbb28851 | |||
| 585d3e2e76 | |||
| 06d31122b8 | |||
| 045f33bbc1 | |||
| 020990c603 | |||
| edd9933430 | |||
| 19bae94f4c | |||
| e1b6650454 | |||
| 25dfb517ee | |||
| 7ed584d5d2 | |||
| e92f51e36b | |||
| dc34f29169 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,3 +8,5 @@ android/build/
|
||||
android/app/build/
|
||||
android/local.properties
|
||||
config/firebase-service-account.json
|
||||
.*
|
||||
!.gitignore
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.devlab.app"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 15
|
||||
versionName = "1.8.3"
|
||||
versionCode = 17
|
||||
versionName = "1.8.5"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -62,19 +62,6 @@ class MainActivity : ComponentActivity() {
|
||||
requestNotificationPermission()
|
||||
checkForUpdates()
|
||||
|
||||
lifecycle.addObserver(LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
ServerManagerApp.instance.notificationPoller?.start()
|
||||
ServerManagerApp.instance.notificationPoller?.checkNow()
|
||||
}
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
ServerManagerApp.instance.notificationPoller?.stop()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
})
|
||||
|
||||
setContent {
|
||||
ServerManagerTheme {
|
||||
AppRoot()
|
||||
@@ -158,15 +145,6 @@ fun AppRoot() {
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
ServerManagerApp.instance.notificationPoller?.onCountChanged = { count ->
|
||||
unreadCount = count
|
||||
}
|
||||
onDispose {
|
||||
ServerManagerApp.instance.notificationPoller?.onCountChanged = null
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (inLoggedInArea) {
|
||||
|
||||
@@ -3,20 +3,15 @@ package com.devlab.app
|
||||
import android.app.Application
|
||||
import com.devlab.app.service.NotificationForegroundService
|
||||
import com.devlab.app.util.NotificationHelper
|
||||
import com.devlab.app.util.NotificationPoller
|
||||
import com.devlab.app.worker.NotificationWorker
|
||||
|
||||
class ServerManagerApp : Application() {
|
||||
var notificationPoller: NotificationPoller? = null
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
NotificationHelper.createChannels(this)
|
||||
NotificationWorker.schedule(this)
|
||||
NotificationForegroundService.start(this)
|
||||
notificationPoller = NotificationPoller(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -8,4 +8,5 @@ data class AppNotification(
|
||||
val type: String = "info",
|
||||
val is_read: Int = 0,
|
||||
val created_at: String = "",
|
||||
val created_at_timestamp: Long = 0,
|
||||
)
|
||||
|
||||
@@ -99,7 +99,7 @@ class NotificationForegroundService : android.app.Service() {
|
||||
if (!response.success) return
|
||||
|
||||
val newNotifications = response.data.filter { note ->
|
||||
val noteTime = parseTime(note.created_at)
|
||||
val noteTime = note.created_at_timestamp * 1000L
|
||||
noteTime > lastCheck && note.is_read == 0
|
||||
}
|
||||
|
||||
@@ -108,19 +108,7 @@ class NotificationForegroundService : android.app.Service() {
|
||||
for (notification in newNotifications) {
|
||||
NotificationHelper.showNotification(this, notification)
|
||||
}
|
||||
lastCheck = newNotifications.maxOf { parseTime(it.created_at) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTime(time: String?): Long {
|
||||
if (time == null) return 0L
|
||||
return try {
|
||||
val sdf = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
sdf.parse(time)?.time ?: 0L
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
lastCheck = newNotifications.maxOf { it.created_at_timestamp * 1000L }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ private fun NotificationItem(
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = formatTime(notification.created_at),
|
||||
text = formatTime(notification.created_at_timestamp * 1000L),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Grey500
|
||||
)
|
||||
@@ -240,27 +240,17 @@ private fun NotificationItem(
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTime(timestamp: String?): String {
|
||||
if (timestamp == null) return ""
|
||||
return try {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
private fun formatTime(millis: Long): String {
|
||||
if (millis <= 0) return ""
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = now - millis
|
||||
return when {
|
||||
diff < 60_000 -> "Just now"
|
||||
diff < 3600_000 -> "${diff / 60_000}m ago"
|
||||
diff < 86400_000 -> "${diff / 3600_000}h ago"
|
||||
else -> {
|
||||
val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.US)
|
||||
sdf.format(Date(millis))
|
||||
}
|
||||
val date = sdf.parse(timestamp) ?: return timestamp
|
||||
val now = Calendar.getInstance()
|
||||
val then = Calendar.getInstance().apply {
|
||||
this.time = date
|
||||
}
|
||||
|
||||
val diff = now.timeInMillis - then.timeInMillis
|
||||
when {
|
||||
diff < 60_000 -> "Just now"
|
||||
diff < 3600_000 -> "${diff / 60_000}m ago"
|
||||
diff < 86400_000 -> "${diff / 3600_000}h ago"
|
||||
else -> {
|
||||
val out = SimpleDateFormat("MMM d, HH:mm", Locale.US)
|
||||
out.format(date)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { timestamp }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,19 +113,8 @@ object NotificationHelper {
|
||||
markReadPendingIntent
|
||||
)
|
||||
.setAutoCancel(true)
|
||||
.setWhen(parseTime(notification.created_at))
|
||||
.setWhen(if (notification.created_at_timestamp > 0) notification.created_at_timestamp * 1000L else System.currentTimeMillis())
|
||||
|
||||
NotificationManagerCompat.from(context).notify(notification.id, built.build())
|
||||
}
|
||||
|
||||
private fun parseTime(time: String?): Long {
|
||||
if (time == null) return System.currentTimeMillis()
|
||||
return try {
|
||||
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.parse(time)?.time ?: System.currentTimeMillis()
|
||||
} catch (_: Exception) {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.devlab.app.util
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.devlab.app.data.api.RetrofitClient
|
||||
import com.devlab.app.data.model.AppNotification
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class NotificationPoller(private val context: Context) {
|
||||
@@ -56,7 +55,7 @@ class NotificationPoller(private val context: Context) {
|
||||
onCountChanged?.invoke(_unreadCount)
|
||||
|
||||
val newNotes = response.data.filter { note ->
|
||||
val noteTime = parseTime(note.created_at)
|
||||
val noteTime = note.created_at_timestamp * 1000L
|
||||
noteTime > lastCheck && note.is_read == 0
|
||||
}
|
||||
|
||||
@@ -65,20 +64,10 @@ class NotificationPoller(private val context: Context) {
|
||||
}
|
||||
|
||||
if (newNotes.isNotEmpty()) {
|
||||
lastCheck = newNotes.maxOf { parseTime(it.created_at) }
|
||||
lastCheck = newNotes.maxOf { it.created_at_timestamp * 1000L }
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTime(time: String?): Long {
|
||||
if (time == null) return 0L
|
||||
return try {
|
||||
val sdf = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
sdf.parse(time)?.time ?: 0L
|
||||
} catch (_: Exception) { 0L }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.devlab.app.data.api.RetrofitClient
|
||||
import com.devlab.app.data.model.AppNotification
|
||||
import com.devlab.app.util.NotificationHelper
|
||||
import com.devlab.app.util.PreferencesManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -40,7 +39,7 @@ class NotificationWorker(
|
||||
Log.d("NotificationWorker", "Got ${response.data.size} notifications, unread: ${response.unread_count}")
|
||||
|
||||
val newNotifications = response.data.filter { note ->
|
||||
val noteTime = parseTime(note.created_at)
|
||||
val noteTime = note.created_at_timestamp * 1000L
|
||||
noteTime > lastCheck && note.is_read == 0
|
||||
}
|
||||
|
||||
@@ -51,7 +50,7 @@ class NotificationWorker(
|
||||
}
|
||||
|
||||
val maxTime = newNotifications.maxOfOrNull {
|
||||
parseTime(it.created_at)
|
||||
it.created_at_timestamp * 1000L
|
||||
} ?: now
|
||||
|
||||
prefs.setLastNotificationCheck(
|
||||
@@ -71,18 +70,6 @@ class NotificationWorker(
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTime(time: String?): Long {
|
||||
if (time == null) return 0L
|
||||
return try {
|
||||
val sdf = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
sdf.parse(time)?.time ?: 0L
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val WORK_NAME = "notification_check"
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ return [
|
||||
'debug' => filter_var($_ENV['APP_DEBUG'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'url' => $_ENV['APP_URL'] ?? 'http://localhost',
|
||||
'timezone' => $_ENV['APP_TIMEZONE'] ?? 'America/Santo_Domingo',
|
||||
'asset_version' => $_ENV['ASSET_VERSION'] ?? '0.0.0',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
|
||||
2
database/migrations/014_privacy_terms.sql
Normal file
2
database/migrations/014_privacy_terms.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT IGNORE INTO app_config (`key`, `value`) VALUES ('privacy_policy', 'PRIVACY POLICY\nLast updated: June 2026\n\n1. DATA CONTROLLER\nServerManager (hereinafter "the Platform") is a server management tool. The data controller is the organization or individual who deploys and operates the Platform.\n\n2. DATA WE COLLECT\nWe collect and process the following data:\n\na) Account Information: username, email address, password (hashed with Argon2id), and role assignment.\n\nb) Server Credentials: SSH hostnames, IP addresses, ports, usernames, and private keys or passwords. All credentials are encrypted at rest using AES-256-CBC with a master key stored outside the database.\n\nc) Monitoring Data: CPU usage, RAM usage, disk usage, network traffic, process lists, and system uptime collected from connected servers.\n\nd) Usage Logs: Audit logs recording actions performed on the Platform (login, server access, configuration changes, command execution), including IP address, timestamp, and action details.\n\ne) Session Data: Session identifiers stored in the sessions table to maintain authentication state.\n\nf) Device Tokens: FCM (Firebase Cloud Messaging) tokens for push notifications on mobile devices.\n\n3. LEGAL BASIS FOR PROCESSING\nData is processed based on:\n- Legitimate interest in managing and monitoring IT infrastructure\n- Contractual necessity to provide the server management service\n- Legal obligations for audit and security logging\n\n4. DATA STORAGE AND SECURITY\n- Passwords: hashed using Argon2id\n- SSH credentials: encrypted with AES-256-CBC\n- Sessions: stored server-side in the database with HTTP-only cookies and SameSite flags\n- Communications: HTTPS encryption is enforced\n- Audit logs: immutable records of all sensitive operations\n\n5. DATA RETENTION\n- Account data: retained while the account is active and up to 12 months after deletion\n- Audit logs: retained for a minimum of 12 months\n- Monitoring data: retained according to configured thresholds\n- Session data: deleted upon logout or expiry\n\n6. YOUR RIGHTS\nDepending on applicable law, you may have the right to:\n- Access your personal data\n- Rectify inaccurate data\n- Delete your account and associated data\n- Restrict or object to processing\n- Data portability\n\nTo exercise these rights, contact your Platform administrator.\n\n7. DATA SHARING\nWe do not sell personal data. Data may be shared with:\n- Third-party services explicitly configured by the administrator (e.g., Firebase for push notifications)\n- Law enforcement only when required by applicable law\n\n8. COOKIES AND TRACKING\nThe Platform uses session cookies for authentication. No tracking cookies or third-party analytics are used.\n\n9. CHANGES TO THIS POLICY\nUsers will be notified of material changes via the Platform notification system.\n\n10. CONTACT\nContact your Platform administrator for privacy-related inquiries.');
|
||||
INSERT IGNORE INTO app_config (`key`, `value`) VALUES ('terms_conditions', 'TERMS AND CONDITIONS\nLast updated: June 2026\n\n1. ACCEPTANCE OF TERMS\nBy accessing or using ServerManager (hereinafter "the Platform"), you agree to be bound by these Terms and Conditions. If you do not agree, do not use the Platform.\n\n2. DESCRIPTION OF SERVICE\nThe Platform provides web-based remote server management including but not limited to:\n- SSH terminal access\n- System monitoring (CPU, RAM, disk, network)\n- Service management (start, stop, restart)\n- Nginx configuration management\n- SSL certificate management\n- Database management\n- Process management\n- System user management\n- Team collaboration\n- Mobile device notifications\n\n3. USER ACCOUNTS\n3.1 Account Creation: Users must provide accurate information when creating an account.\n3.2 Account Security: Users are responsible for maintaining the confidentiality of their credentials and for all activities under their account.\n3.3 Role-Based Access: Access is granted based on assigned roles (Operator, Admin, Super Admin). Privilege escalation is prohibited.\n\n4. ACCEPTABLE USE\nYou agree not to:\n- Use the Platform for any unlawful purpose\n- Attempt to access servers or data for which you lack authorization\n- Share your account credentials with unauthorized persons\n- Use the Platform to disrupt, damage, or compromise connected systems\n- Circumvent access controls, authentication mechanisms, or audit logging\n- Use automated tools to scrape, overload, or abuse the Platform\n\n5. INTELLECTUAL PROPERTY\nThe Platform software and its code are the property of the respective licensors. No ownership rights are transferred to users.\n\n6. LIMITATION OF LIABILITY\nThe Platform is provided "as is" without warranty of any kind. The operators shall not be liable for:\n- Damages resulting from server misconfiguration or command execution\n- Data loss resulting from system management actions\n- Unauthorized access due to compromised user credentials\n- Service interruptions or downtime\n- Indirect, incidental, or consequential damages\n\n7. INDEMNIFICATION\nUsers agree to indemnify and hold harmless the Platform operators from claims arising from their use of the service or violation of these terms.\n\n8. TERMINATION\nThe Platform administrator may suspend or terminate access:\n- For violation of these terms\n- At their sole discretion without prior notice\n- Upon user request\n\nUpon termination, access to the Platform and connected servers will be revoked.\n\n9. MODIFICATIONS\nThese terms may be updated at any time. Continued use after changes constitutes acceptance of the new terms. Users will be notified of material changes.\n\n10. GOVERNING LAW\nThese terms are governed by the laws of the jurisdiction where the Platform is deployed and operated.\n\n11. SEVERABILITY\nIf any provision of these terms is found to be unenforceable, the remaining provisions will remain in full force and effect.\n\n12. CONTACT\nFor questions regarding these terms, contact your Platform administrator.');
|
||||
8
database/migrations/015_rate_limits.sql
Normal file
8
database/migrations/015_rate_limits.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Migration 015: Rate limiting storage
|
||||
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
identifier VARCHAR(255) NOT NULL,
|
||||
attempts INT NOT NULL DEFAULT 1,
|
||||
first_attempt_at BIGINT NOT NULL,
|
||||
UNIQUE KEY idx_identifier (identifier)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
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; }
|
||||
}
|
||||
@@ -121,7 +121,7 @@ code, pre {
|
||||
}
|
||||
|
||||
/* App Container */
|
||||
.app-container {
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -145,10 +145,26 @@ code, pre {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar:hover::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: var(--sidebar-collapsed);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-header::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -157,9 +173,21 @@ code, pre {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
height: var(--topbar-height);
|
||||
min-height: var(--topbar-height);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
.sidebar-header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 1.25rem;
|
||||
right: 1.25rem;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--accent), transparent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
@@ -167,34 +195,17 @@ code, pre {
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-logo i {
|
||||
.sidebar-brand i {
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-toggle i,
|
||||
.mobile-toggle i,
|
||||
.nav-link i,
|
||||
.nav-item i,
|
||||
.nav-sub-item i,
|
||||
.user-menu a i {
|
||||
.nav-item-toggle i {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -204,61 +215,6 @@ code, pre {
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 0;
|
||||
transition: all var(--transition);
|
||||
font-size: 0.92rem;
|
||||
white-space: nowrap;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.nav-link i {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
border-left-color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 0.75rem 1rem;
|
||||
@@ -321,34 +277,94 @@ code, pre {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
margin: 0 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition);
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item i {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 20px;
|
||||
background: var(--accent);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 0.5rem 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
padding: 0.6rem 1.25rem 0.35rem;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Collapsed state */
|
||||
.sidebar.collapsed .logo-text,
|
||||
.sidebar.collapsed .nav-link span,
|
||||
.sidebar.collapsed .nav-section,
|
||||
.sidebar.collapsed .user-details,
|
||||
.sidebar.collapsed .user-menu,
|
||||
.sidebar.collapsed .nav-divider {
|
||||
.sidebar.collapsed .nav-item span,
|
||||
.sidebar.collapsed .nav-chevron,
|
||||
.sidebar.collapsed .notification-badge,
|
||||
.sidebar.collapsed .nav-divider,
|
||||
.sidebar.collapsed .nav-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-logo {
|
||||
.sidebar.collapsed .sidebar-brand {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-link {
|
||||
.sidebar.collapsed .nav-item {
|
||||
justify-content: center;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .user-info {
|
||||
justify-content: center;
|
||||
margin: 0 0.35rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Main Content
|
||||
========================================================================== */
|
||||
|
||||
.main-content {
|
||||
.main-area {
|
||||
flex: 1;
|
||||
margin-left: var(--sidebar-width);
|
||||
transition: margin-left var(--transition);
|
||||
@@ -357,7 +373,7 @@ code, pre {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar.collapsed ~ .main-content {
|
||||
.sidebar.collapsed ~ .main-area {
|
||||
margin-left: var(--sidebar-collapsed);
|
||||
}
|
||||
|
||||
@@ -365,7 +381,7 @@ code, pre {
|
||||
Top Bar
|
||||
========================================================================== */
|
||||
|
||||
.top-bar {
|
||||
.topbar {
|
||||
height: var(--topbar-height);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
@@ -378,48 +394,58 @@ code, pre {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.top-bar-left {
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.mobile-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.mobile-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.top-bar-right {
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.top-bar-actions {
|
||||
.topbar-user {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.topbar-user:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.topbar-user-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-menu-right {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.88rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dropdown-header small {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
@@ -442,11 +468,15 @@ code, pre {
|
||||
Content Area
|
||||
========================================================================== */
|
||||
|
||||
.content-area {
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Page Header
|
||||
========================================================================== */
|
||||
@@ -686,6 +716,14 @@ code, pre {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.btn:disabled,
|
||||
.btn.disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
@@ -697,6 +735,26 @@ code, pre {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:disabled,
|
||||
.btn-primary.disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border-color);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-primary:disabled:hover,
|
||||
.btn-primary.disabled:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border-color);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
@@ -891,13 +949,45 @@ textarea.form-input {
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
position: relative;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.checkbox-label .checkmark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-input);
|
||||
transition: all var(--transition);
|
||||
flex-shrink: 0;
|
||||
font-size: 0.65rem;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.checkbox-label:hover .checkmark {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"]:checked + .checkmark {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"]:focus-visible + .checkmark {
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
@@ -1098,7 +1188,8 @@ textarea.form-input {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.dropdown.active .dropdown-menu {
|
||||
.dropdown.active .dropdown-menu,
|
||||
.dropdown-menu.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -2340,53 +2431,33 @@ textarea.form-input {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.sidebar.mobile-open.collapsed .logo-text,
|
||||
.sidebar.mobile-open.collapsed .nav-link span,
|
||||
.sidebar.mobile-open.collapsed .nav-section,
|
||||
.sidebar.mobile-open.collapsed .user-details,
|
||||
.sidebar.mobile-open.collapsed .user-menu,
|
||||
.sidebar.mobile-open.collapsed .nav-divider {
|
||||
.sidebar.mobile-open.collapsed .nav-item span,
|
||||
.sidebar.mobile-open.collapsed .nav-chevron,
|
||||
.sidebar.mobile-open.collapsed .notification-badge,
|
||||
.sidebar.mobile-open.collapsed .nav-divider,
|
||||
.sidebar.mobile-open.collapsed .nav-section {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar.mobile-open.collapsed .sidebar-logo {
|
||||
.sidebar.mobile-open.collapsed .sidebar-brand {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.sidebar.mobile-open.collapsed .nav-link {
|
||||
.sidebar.mobile-open.collapsed .nav-item {
|
||||
justify-content: flex-start;
|
||||
padding: 0.65rem 1.25rem;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar.mobile-open.collapsed .user-info {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
.main-area {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.mobile-overlay.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
.page-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
.topbar {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
@@ -2414,8 +2485,9 @@ textarea.form-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-submenu {
|
||||
background: rgba(0,0,0,0.1);
|
||||
.nav-sublist {
|
||||
background: rgba(0,0,0,0.06);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2481,11 +2553,11 @@ textarea.form-input {
|
||||
========================================================================== */
|
||||
|
||||
@media print {
|
||||
.sidebar, .top-bar {
|
||||
.sidebar, .topbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
.main-area {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -2780,16 +2852,20 @@ textarea.form-input {
|
||||
}
|
||||
|
||||
/* Sidebar accordion */
|
||||
.nav-item-accordion .nav-link {
|
||||
.nav-item-accordion .nav-item-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item-accordion.expanded > .nav-item-toggle {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-chevron {
|
||||
margin-left: auto;
|
||||
font-size: 0.7rem;
|
||||
transition: transform 0.2s;
|
||||
font-size: 0.65rem;
|
||||
transition: transform 0.25s ease;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -2797,29 +2873,26 @@ textarea.form-input {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.nav-submenu {
|
||||
.nav-sublist {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease;
|
||||
background: rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.nav-submenu-loading {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
background: rgba(0,0,0,0.08);
|
||||
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.nav-sub-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 1rem 0.4rem 2.8rem;
|
||||
padding: 0.35rem 1rem 0.35rem 2.5rem;
|
||||
margin: 0 0.25rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: background 0.1s;
|
||||
transition: all 0.15s;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.nav-sub-item:hover {
|
||||
@@ -2827,6 +2900,13 @@ textarea.form-input {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-sub-item i {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-dot-sm {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -3134,3 +3214,230 @@ textarea.form-input {
|
||||
background: var(--bg-hover, #e9ecef);
|
||||
border-color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
border: 2px solid var(--bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
border: 2px solid var(--bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-track {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Notification Bell & Modal
|
||||
========================================================================== */
|
||||
|
||||
.notification-btn-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.notification-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 100px;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
box-shadow: 0 0 0 2px var(--bg-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notif-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.notif-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.notif-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notif-unread {
|
||||
background: rgba(99, 102, 241, 0.04);
|
||||
}
|
||||
|
||||
.notif-unread:hover {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
.notif-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.notif-icon.notif-danger { background: var(--danger-bg); color: var(--danger); }
|
||||
.notif-icon.notif-warning { background: var(--warning-bg); color: var(--warning); }
|
||||
.notif-icon.notif-info { background: var(--info-bg); color: var(--info); }
|
||||
|
||||
.notif-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notif-title {
|
||||
font-size: 0.88em;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.notif-message {
|
||||
font-size: 0.82em;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notif-time {
|
||||
font-size: 0.72em;
|
||||
color: var(--text-muted);
|
||||
margin-top: 3px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.notif-read .notif-title {
|
||||
font-weight: 400;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.notif-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--info);
|
||||
flex-shrink: 0;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Notification Dropdown Panel
|
||||
========================================================================== */
|
||||
|
||||
.notification-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: 380px;
|
||||
max-height: 520px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
z-index: 300;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-dropdown.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notif-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.notif-list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notif-view-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 16px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.notif-view-all:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
|
||||
|
||||
BIN
public/assets/img/icon-192.png
Normal file
BIN
public/assets/img/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
BIN
public/assets/img/icon-512.png
Normal file
BIN
public/assets/img/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
97
public/assets/js/admin-notifications.js
Normal file
97
public/assets/js/admin-notifications.js
Normal file
@@ -0,0 +1,97 @@
|
||||
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';
|
||||
});
|
||||
}
|
||||
|
||||
let deleteId = null;
|
||||
|
||||
function showDeleteModal(id, title) {
|
||||
deleteId = id;
|
||||
document.getElementById('deleteModalTitle').textContent = '"' + title + '"';
|
||||
document.getElementById('deleteModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
deleteId = null;
|
||||
document.getElementById('deleteModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function executeDelete() {
|
||||
if (!deleteId) return;
|
||||
const btn = document.getElementById('deleteConfirmBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
|
||||
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
fetch('/admin/notifications/' + deleteId + '/delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: '_csrf_token=' + encodeURIComponent(csrf),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
closeDeleteModal();
|
||||
if (data.success) location.reload();
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('error', 'Failed to delete notification');
|
||||
closeDeleteModal();
|
||||
});
|
||||
}
|
||||
|
||||
function confirmResend(id) {
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
fetch('/admin/notifications/' + id + '/resend', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: '_csrf_token=' + encodeURIComponent(csrf),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
})
|
||||
.catch(() => showToast('error', 'Failed to resend notification'));
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
37
public/assets/js/app.js
Executable file → Normal file
37
public/assets/js/app.js
Executable file → Normal file
@@ -12,14 +12,6 @@
|
||||
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const sidebarToggle = document.getElementById('sidebarToggle');
|
||||
const mobileToggle = document.getElementById('mobileToggle');
|
||||
const mobileOverlay = document.getElementById('mobileOverlay');
|
||||
|
||||
function closeMobileSidebar() {
|
||||
if (!sidebar) return;
|
||||
sidebar.classList.remove('mobile-open');
|
||||
if (mobileOverlay) mobileOverlay.classList.remove('active');
|
||||
}
|
||||
|
||||
if (sidebarToggle && sidebar) {
|
||||
sidebarToggle.addEventListener('click', function () {
|
||||
@@ -34,35 +26,6 @@
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (mobileToggle && sidebar) {
|
||||
mobileToggle.addEventListener('click', function () {
|
||||
const opening = !sidebar.classList.contains('mobile-open');
|
||||
if (opening) {
|
||||
sidebar.classList.remove('collapsed');
|
||||
sidebar.classList.add('mobile-open');
|
||||
if (mobileOverlay) mobileOverlay.classList.add('active');
|
||||
} else {
|
||||
closeMobileSidebar();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (sidebar) {
|
||||
document.addEventListener('click', function (e) {
|
||||
if (window.innerWidth <= 768 &&
|
||||
sidebar.classList.contains('mobile-open') &&
|
||||
!sidebar.contains(e.target) &&
|
||||
e.target !== mobileToggle &&
|
||||
e.target !== mobileOverlay) {
|
||||
closeMobileSidebar();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (mobileOverlay) {
|
||||
mobileOverlay.addEventListener('click', closeMobileSidebar);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Theme Toggle
|
||||
========================================================================== */
|
||||
|
||||
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;}
|
||||
294
public/assets/js/main.js
Normal file
294
public/assets/js/main.js
Normal file
@@ -0,0 +1,294 @@
|
||||
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();
|
||||
const userMenu = document.getElementById('userMenu');
|
||||
if (userMenu) userMenu.classList.remove('open');
|
||||
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();
|
||||
}
|
||||
const userWrapper = document.querySelector('.topbar-user-dropdown');
|
||||
if (userWrapper && !userWrapper.contains(e.target)) {
|
||||
const userMenu = document.getElementById('userMenu');
|
||||
if (userMenu) userMenu.classList.remove('open');
|
||||
}
|
||||
}
|
||||
|
||||
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, d.pagination?.total || 0);
|
||||
})
|
||||
.catch(() => {
|
||||
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--danger)">Failed to load notifications.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotifications(items, unreadCount, total) {
|
||||
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>';
|
||||
});
|
||||
|
||||
if (total > items.length) {
|
||||
html += '<a href="/notifications" class="notif-view-all">'
|
||||
+ 'View all notifications (' + total + ' total)'
|
||||
+ '</a>';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleUserMenu(e) {
|
||||
e.stopPropagation();
|
||||
const menu = document.getElementById('userMenu');
|
||||
if (!menu) return;
|
||||
const isOpen = menu.classList.contains('open');
|
||||
document.querySelectorAll('.dropdown-menu.open').forEach(function(m) {
|
||||
if (m !== menu) m.classList.remove('open');
|
||||
});
|
||||
if (!isOpen) {
|
||||
menu.classList.add('open');
|
||||
closeNotifications();
|
||||
setTimeout(function() {
|
||||
document.addEventListener('click', closeUserMenu);
|
||||
}, 0);
|
||||
} else {
|
||||
menu.classList.remove('open');
|
||||
}
|
||||
}
|
||||
|
||||
function closeUserMenu(e) {
|
||||
const menu = document.getElementById('userMenu');
|
||||
if (!menu) return;
|
||||
const wrapper = menu.closest('.topbar-user-dropdown');
|
||||
if (wrapper && !wrapper.contains(e.target)) {
|
||||
menu.classList.remove('open');
|
||||
document.removeEventListener('click', closeUserMenu);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDownloadBtn() {
|
||||
const cb = document.getElementById('agreeTerms');
|
||||
document.getElementById('downloadConfirmBtn').disabled = !cb.checked;
|
||||
}
|
||||
|
||||
function showDownloadModal() {
|
||||
const cb = document.getElementById('agreeTerms');
|
||||
cb.checked = false;
|
||||
document.getElementById('downloadConfirmBtn').disabled = true;
|
||||
document.getElementById('downloadModal').style.display = 'flex';
|
||||
document.getElementById('downloadProgressWrap').style.display = 'none';
|
||||
document.getElementById('downloadModalFooter').style.display = 'flex';
|
||||
document.getElementById('downloadConfirmBtn').innerHTML = '<i class="fas fa-download"></i> Download APK';
|
||||
document.getElementById('downloadProgressBar').style.width = '0%';
|
||||
document.getElementById('downloadPercent').textContent = '0%';
|
||||
}
|
||||
|
||||
function closeDownloadModal() {
|
||||
document.getElementById('downloadModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function startDownload() {
|
||||
const btn = document.getElementById('downloadConfirmBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Downloading...';
|
||||
|
||||
document.getElementById('downloadProgressWrap').style.display = 'block';
|
||||
document.getElementById('downloadModalFooter').style.display = 'none';
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = '/sysadmin.apk';
|
||||
anchor.download = 'sysadmin.apk';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
|
||||
let pct = 0;
|
||||
const interval = setInterval(function() {
|
||||
pct += Math.floor(Math.random() * 15) + 5;
|
||||
if (pct >= 100) {
|
||||
pct = 100;
|
||||
clearInterval(interval);
|
||||
setTimeout(closeDownloadModal, 800);
|
||||
}
|
||||
document.getElementById('downloadProgressBar').style.width = pct + '%';
|
||||
document.getElementById('downloadPercent').textContent = pct + '%';
|
||||
}, 300);
|
||||
}
|
||||
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(); });
|
||||
330
public/assets/js/server-show.js
Normal file
330
public/assets/js/server-show.js
Normal file
@@ -0,0 +1,330 @@
|
||||
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: {
|
||||
'Accept': 'application/json',
|
||||
'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: {
|
||||
'Accept': 'application/json',
|
||||
'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: {
|
||||
'Accept': 'application/json',
|
||||
'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: {
|
||||
'Accept': 'application/json',
|
||||
'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', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
})
|
||||
.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: {
|
||||
'Accept': 'application/json',
|
||||
'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); }
|
||||
});
|
||||
}
|
||||
137
public/assets/js/terminal.js
Normal file
137
public/assets/js/terminal.js
Normal file
@@ -0,0 +1,137 @@
|
||||
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(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.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: {
|
||||
'Accept': 'application/json',
|
||||
'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');
|
||||
}
|
||||
if (data.history) {
|
||||
appendHistoryRow(data.history);
|
||||
}
|
||||
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 appendHistoryRow(h) {
|
||||
const tbody = document.querySelector('.card-body .table tbody');
|
||||
if (!tbody) return;
|
||||
const emptyState = tbody.closest('.card-body')?.querySelector('.empty-state');
|
||||
if (emptyState) emptyState.remove();
|
||||
const row = document.createElement('tr');
|
||||
const time = h.executed_at ? new Date(h.executed_at.replace(' ', 'T')).toLocaleTimeString('en-US', { hour12: false }) : '';
|
||||
row.innerHTML =
|
||||
'<td>' + time + '</td>' +
|
||||
'<td><code>' + escapeHtml(h.command) + '</code></td>' +
|
||||
'<td><span class="badge ' + (h.exit_status === 0 ? 'badge-success' : 'badge-danger') + '">' + (h.exit_status === 0 ? 'OK' : 'ERR') + '</span></td>' +
|
||||
'<td>' + escapeHtml(h.username || 'System') + '</td>';
|
||||
tbody.prepend(row);
|
||||
const container = tbody.closest('.table-responsive');
|
||||
if (container) container.scrollTop = 0;
|
||||
}
|
||||
|
||||
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();
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
if (($_ENV['APP_DEBUG'] ?? false) === 'true') {
|
||||
echo '<h1>Server Error</h1>';
|
||||
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>';
|
||||
if (isset($app)) {
|
||||
$app->getView()->error(500);
|
||||
}
|
||||
echo '<h1>500 Internal Server Error</h1>';
|
||||
echo '<p>An unexpected error occurred.</p>';
|
||||
}
|
||||
|
||||
26
public/manifest.json
Normal file
26
public/manifest.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "ServerManager",
|
||||
"short_name": "SM",
|
||||
"description": "Linux Server Management Platform",
|
||||
"start_url": "/dashboard",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f1117",
|
||||
"theme_color": "#6366f1",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"lang": "en",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/img/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Sitemap: https://servermanager.devlab.lat/sitemap.xml
|
||||
23
public/sitemap.xml
Normal file
23
public/sitemap.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://servermanager.devlab.lat/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://servermanager.devlab.lat/login</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://servermanager.devlab.lat/privacy</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://servermanager.devlab.lat/terms</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
71
public/sw.js
Normal file
71
public/sw.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const CACHE = 'sm-v1';
|
||||
const STATIC_ASSETS = [
|
||||
'/assets/css/style.css',
|
||||
'/assets/js/app.js',
|
||||
'/assets/js/main.js',
|
||||
'/assets/img/server.svg',
|
||||
'/assets/img/icon-192.png',
|
||||
'/assets/img/icon-512.png',
|
||||
'/manifest.json',
|
||||
'/offline',
|
||||
];
|
||||
|
||||
self.addEventListener('install', function(e) {
|
||||
e.waitUntil(
|
||||
caches.open(CACHE).then(function(cache) {
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
}).then(function() {
|
||||
return self.skipWaiting();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function(e) {
|
||||
e.waitUntil(
|
||||
caches.keys().then(function(keys) {
|
||||
return Promise.all(
|
||||
keys.filter(function(k) { return k !== CACHE; }).map(function(k) { return caches.delete(k); })
|
||||
);
|
||||
}).then(function() {
|
||||
return self.clients.claim();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function(e) {
|
||||
var url = new URL(e.request.url);
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.startsWith('/assets/') ||
|
||||
url.pathname === '/manifest.json'
|
||||
) {
|
||||
e.respondWith(
|
||||
caches.open(CACHE).then(function(cache) {
|
||||
return cache.match(e.request).then(function(cached) {
|
||||
var fetchPromise = fetch(e.request).then(function(response) {
|
||||
cache.put(e.request, response.clone());
|
||||
return response;
|
||||
});
|
||||
return cached || fetchPromise;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
e.respondWith(
|
||||
fetch(e.request).catch(function() {
|
||||
return caches.match(e.request).then(function(cached) {
|
||||
if (cached) return cached;
|
||||
if (e.request.mode === 'navigate') {
|
||||
return caches.match('/offline');
|
||||
}
|
||||
return new Response('Offline', { status: 503 });
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
Binary file not shown.
@@ -17,17 +17,27 @@ use ServerManager\Middleware\RoleMiddleware;
|
||||
|
||||
$router = App::getInstance()->getRouter();
|
||||
|
||||
$router->get('/', [DashboardController::class, 'index'], [AuthMiddleware::class]);
|
||||
$router->get('/', [DashboardController::class, 'landing']);
|
||||
$router->get('/dashboard', [DashboardController::class, 'index'], [AuthMiddleware::class]);
|
||||
$router->get('/dashboard/stats', [DashboardController::class, 'stats'], [AuthMiddleware::class]);
|
||||
$router->get('/dashboard/refresh-metrics', [DashboardController::class, 'refreshMetrics'], [AuthMiddleware::class]);
|
||||
$router->get('/dashboard/chart-data', [DashboardController::class, 'chartData'], [AuthMiddleware::class]);
|
||||
$router->get('/notifications', [DashboardController::class, 'notifications'], [AuthMiddleware::class]);
|
||||
|
||||
$router->get('/privacy', [DashboardController::class, 'privacy']);
|
||||
$router->get('/terms', [DashboardController::class, 'terms']);
|
||||
|
||||
$router->get('/offline', function () {
|
||||
$view = \ServerManager\Core\App::getInstance()->getView();
|
||||
$view->display('errors.offline', ['title' => 'Offline']);
|
||||
});
|
||||
|
||||
$router->get('/login', [AuthController::class, 'loginForm']);
|
||||
$router->post('/login', [AuthController::class, 'login'], [CSRFMiddleware::class, RateLimitMiddleware::class]);
|
||||
$router->get('/register', [AuthController::class, 'registerForm']);
|
||||
$router->post('/register', [AuthController::class, 'register'], [CSRFMiddleware::class]);
|
||||
$router->get('/logout', [AuthController::class, 'logout']);
|
||||
$router->post('/logout', [AuthController::class, 'logout'], [CSRFMiddleware::class]);
|
||||
|
||||
$router->get('/profile', [AuthController::class, 'profile'], [AuthMiddleware::class]);
|
||||
$router->post('/profile', [AuthController::class, 'updateProfile'], [AuthMiddleware::class, CSRFMiddleware::class]);
|
||||
@@ -68,6 +78,11 @@ $router->get('/servers/:id/metrics', [ServerController::class, 'metrics'], [Auth
|
||||
$router->post('/servers/:id/thresholds', [ServerController::class, 'saveThresholds'], [AuthMiddleware::class, CSRFMiddleware::class]);
|
||||
$router->get('/sidebar/servers', [ServerController::class, 'sidebarServers'], [AuthMiddleware::class]);
|
||||
|
||||
$router->get('/account', function () {
|
||||
$view = \ServerManager\Core\App::getInstance()->getView();
|
||||
$view->redirect('/profile');
|
||||
});
|
||||
|
||||
$router->get('/teams', [TeamController::class, 'index'], [AuthMiddleware::class, RoleMiddleware::class]);
|
||||
$router->get('/teams/search-users', [TeamController::class, 'searchUsers'], [AuthMiddleware::class, RoleMiddleware::class]);
|
||||
$router->get('/teams/create', [TeamController::class, 'create'], [AuthMiddleware::class, RoleMiddleware::class]);
|
||||
@@ -86,6 +101,13 @@ $router->post('/terminal/:id/execute', [TerminalController::class, 'execute'], [
|
||||
$router->get('/terminal/:id/history', [TerminalController::class, 'history'], [AuthMiddleware::class]);
|
||||
$router->post('/terminal/:id/clear-history', [TerminalController::class, 'clearHistory'], [AuthMiddleware::class, CSRFMiddleware::class]);
|
||||
|
||||
$router->get('/admin', [AdminController::class, 'index'], [AuthMiddleware::class, RoleMiddleware::class]);
|
||||
|
||||
$router->get('/admin/activity', function () {
|
||||
$view = \ServerManager\Core\App::getInstance()->getView();
|
||||
$view->redirect('/admin/audit');
|
||||
}, [AuthMiddleware::class, RoleMiddleware::class]);
|
||||
|
||||
$router->group(['prefix' => 'admin', 'middleware' => [AuthMiddleware::class, RoleMiddleware::class]], function (ServerManager\Core\Router $router) {
|
||||
$router->get('/users', [AdminController::class, 'users']);
|
||||
$router->post('/users/create', [AdminController::class, 'createUser'], [CSRFMiddleware::class]);
|
||||
@@ -98,6 +120,11 @@ $router->group(['prefix' => 'admin', 'middleware' => [AuthMiddleware::class, Rol
|
||||
$router->post('/app-version', [AdminController::class, 'appVersion'], [CSRFMiddleware::class]);
|
||||
$router->get('/notifications', [AdminController::class, 'notifications']);
|
||||
$router->post('/notifications/send', [AdminController::class, 'sendNotification'], [CSRFMiddleware::class]);
|
||||
$router->post('/notifications/:id/delete', [AdminController::class, 'deleteNotification'], [CSRFMiddleware::class]);
|
||||
$router->post('/notifications/:id/resend', [AdminController::class, 'resendNotification'], [CSRFMiddleware::class]);
|
||||
|
||||
$router->get('/policies', [AdminController::class, 'policies']);
|
||||
$router->post('/policies', [AdminController::class, 'savePolicies'], [CSRFMiddleware::class]);
|
||||
});
|
||||
|
||||
$router->get('/api/status', [ApiController::class, 'status']);
|
||||
@@ -108,8 +135,8 @@ $router->put('/api/servers/:id', [ApiController::class, 'serverUpdate']);
|
||||
$router->delete('/api/servers/:id', [ApiController::class, 'serverDelete']);
|
||||
$router->get('/api/servers/:id/metrics', [ApiController::class, 'serverMetrics']);
|
||||
$router->post('/api/servers/:id/execute', [ApiController::class, 'executeCommand']);
|
||||
$router->post('/api/auth/login', [ApiController::class, 'login']);
|
||||
$router->post('/api/auth/register', [ApiController::class, 'register']);
|
||||
$router->post('/api/auth/login', [ApiController::class, 'login'], [RateLimitMiddleware::class]);
|
||||
$router->post('/api/auth/register', [ApiController::class, 'register'], [RateLimitMiddleware::class]);
|
||||
$router->get('/api/app-version', [ApiController::class, 'appVersion']);
|
||||
$router->get('/api/servers/:id/services', [ApiController::class, 'serverServices']);
|
||||
$router->post('/api/servers/:id/reboot', [ApiController::class, 'serverReboot']);
|
||||
|
||||
@@ -19,11 +19,14 @@ class AdminController
|
||||
private User $userModel;
|
||||
private AuditService $auditService;
|
||||
|
||||
private Database $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->view = App::getInstance()->getView();
|
||||
$this->userModel = new User();
|
||||
$this->auditService = new AuditService();
|
||||
$this->db = Database::getInstance();
|
||||
}
|
||||
|
||||
private function requireSuperAdmin(): void
|
||||
@@ -34,6 +37,13 @@ class AdminController
|
||||
}
|
||||
}
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$this->view->display('admin.index', [
|
||||
'title' => 'Administration - ServerManager',
|
||||
]);
|
||||
}
|
||||
|
||||
public function users(): void
|
||||
{
|
||||
$this->requireSuperAdmin();
|
||||
@@ -285,6 +295,61 @@ class AdminController
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteNotification(int $id): void
|
||||
{
|
||||
$this->requireSuperAdmin();
|
||||
|
||||
$notificationModel = new Notification();
|
||||
$note = $notificationModel->findById($id);
|
||||
|
||||
if (!$note) {
|
||||
$this->view->json(['success' => false, 'message' => 'Notification not found.'], 404);
|
||||
}
|
||||
|
||||
$notificationModel->delete($id);
|
||||
|
||||
$this->auditService->log('notification_deleted', 'notification', $id, [
|
||||
'title' => $note['title'],
|
||||
]);
|
||||
|
||||
$this->view->json(['success' => true, 'message' => 'Notification deleted.']);
|
||||
}
|
||||
|
||||
public function resendNotification(int $id): void
|
||||
{
|
||||
$this->requireSuperAdmin();
|
||||
|
||||
$notificationModel = new Notification();
|
||||
$note = $notificationModel->findById($id);
|
||||
|
||||
if (!$note) {
|
||||
$this->view->json(['success' => false, 'message' => 'Notification not found.'], 404);
|
||||
}
|
||||
|
||||
$title = $note['title'];
|
||||
$message = $note['message'];
|
||||
$type = $note['type'] ?? 'info';
|
||||
$userId = $note['user_id'] ? (int) $note['user_id'] : null;
|
||||
|
||||
$fcm = new FCMService();
|
||||
$pushResult = '';
|
||||
if ($userId) {
|
||||
$sent = $fcm->sendToUser($userId, $title, $message, $type, $id);
|
||||
$pushResult = $sent ? ' (push sent)' : ($fcm->isConfigured() ? ' (push failed)' : '');
|
||||
} else {
|
||||
$count = $fcm->sendToAll($title, $message, $type, $id);
|
||||
$pushResult = $fcm->isConfigured() ? " (push sent to {$count} devices)" : '';
|
||||
}
|
||||
|
||||
$this->auditService->log('notification_resent', 'notification', $id, [
|
||||
'title' => $title,
|
||||
'target' => $userId ? "user #{$userId}" : 'all users',
|
||||
'push' => $fcm->isConfigured() ? 'sent' : 'not_configured',
|
||||
]);
|
||||
|
||||
$this->view->json(['success' => true, 'message' => 'Notification resent successfully.' . $pushResult]);
|
||||
}
|
||||
|
||||
public function sendNotification(): void
|
||||
{
|
||||
$this->requireSuperAdmin();
|
||||
@@ -299,6 +364,18 @@ class AdminController
|
||||
return;
|
||||
}
|
||||
|
||||
if ($userId) {
|
||||
$userModel = new User();
|
||||
$targetUser = $userModel->findById($userId);
|
||||
if (!$targetUser || empty($targetUser['notifications_enabled'])) {
|
||||
$this->view->json([
|
||||
'success' => true,
|
||||
'message' => 'Notification saved to database. User has notifications disabled.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$notificationModel = new Notification();
|
||||
$noteId = $notificationModel->create([
|
||||
'user_id' => $userId,
|
||||
@@ -325,4 +402,39 @@ class AdminController
|
||||
|
||||
$this->view->json(['success' => true, 'message' => 'Notification sent successfully.' . $pushResult]);
|
||||
}
|
||||
|
||||
public function policies(): void
|
||||
{
|
||||
$this->requireSuperAdmin();
|
||||
|
||||
$configs = $this->db->fetchAll('SELECT `key`, `value` FROM app_config WHERE `key` IN (?, ?)', ['privacy_policy', 'terms_conditions']);
|
||||
$values = [];
|
||||
foreach ($configs as $row) {
|
||||
$values[$row['key']] = $row['value'];
|
||||
}
|
||||
|
||||
$this->view->display('admin.policies', [
|
||||
'title' => 'Legal Policies - ServerManager',
|
||||
'privacy' => $values['privacy_policy'] ?? '',
|
||||
'terms' => $values['terms_conditions'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
public function savePolicies(): void
|
||||
{
|
||||
$this->requireSuperAdmin();
|
||||
|
||||
if (isset($_POST['privacy_policy'])) {
|
||||
$this->db->update('app_config', ['value' => $_POST['privacy_policy']], '`key` = ?', ['privacy_policy']);
|
||||
}
|
||||
|
||||
if (isset($_POST['terms_conditions'])) {
|
||||
$this->db->update('app_config', ['value' => $_POST['terms_conditions']], '`key` = ?', ['terms_conditions']);
|
||||
}
|
||||
|
||||
$this->auditService->log('policies_updated', 'config', null, ['details' => 'Legal policies updated']);
|
||||
|
||||
Session::setFlash('success', 'Policies saved successfully.');
|
||||
$this->view->redirect('/admin/policies');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +341,25 @@ class ApiController
|
||||
$this->view->json(['error' => 'Command is required'], 400);
|
||||
}
|
||||
|
||||
$blockedPatterns = [
|
||||
'/\brm\s+\-rf\s+\//',
|
||||
'/\bmkfs\b/',
|
||||
'/\bdd\s+if\=/',
|
||||
'/\:\s*\{\s*:\s*\|[^\}]*\}\s*;/',
|
||||
'/\bshutdown\b/',
|
||||
'/\breboot\b/',
|
||||
'/\bhalt\b/',
|
||||
'/\bpoweroff\b/',
|
||||
];
|
||||
foreach ($blockedPatterns as $pattern) {
|
||||
if (preg_match($pattern, $command)) {
|
||||
$this->view->json([
|
||||
'success' => false,
|
||||
'error' => 'This command has been blocked for safety reasons.',
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
$serverModel = new Server();
|
||||
$server = $serverModel->findById($id);
|
||||
|
||||
@@ -378,7 +397,7 @@ class ApiController
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->view->json([
|
||||
'error' => 'Command execution failed: ' . $e->getMessage(),
|
||||
'error' => 'Command execution failed.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
@@ -496,6 +515,12 @@ class ApiController
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
$user = $this->authenticateRequest();
|
||||
|
||||
if (!in_array($user['role'], ['super_admin', 'admin'], true)) {
|
||||
$this->view->json(['error' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
|
||||
|
||||
$validator = new Validator();
|
||||
@@ -504,6 +529,7 @@ class ApiController
|
||||
'email' => 'required|email',
|
||||
'password' => 'required|min:8',
|
||||
'confirm_password' => 'required',
|
||||
'role' => 'in:operator,admin',
|
||||
];
|
||||
|
||||
if (!$validator->validate($input, $rules)) {
|
||||
@@ -522,6 +548,13 @@ class ApiController
|
||||
return;
|
||||
}
|
||||
|
||||
$role = in_array($input['role'] ?? 'operator', ['operator', 'admin'], true)
|
||||
? $input['role'] : 'operator';
|
||||
|
||||
if ($role === 'admin' && $user['role'] !== 'super_admin') {
|
||||
$this->view->json(['error' => 'Only super admins can create admin users'], 403);
|
||||
}
|
||||
|
||||
$userModel = new User();
|
||||
|
||||
$existing = $userModel->findByUsername($input['username']);
|
||||
@@ -546,7 +579,7 @@ class ApiController
|
||||
'username' => $input['username'],
|
||||
'email' => $input['email'],
|
||||
'password' => $input['password'],
|
||||
'role' => 'admin',
|
||||
'role' => $role,
|
||||
'is_active' => 1,
|
||||
]);
|
||||
|
||||
@@ -592,25 +625,32 @@ class ApiController
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
|
||||
|
||||
$validator = new Validator();
|
||||
$rules = [
|
||||
'email' => 'required|email',
|
||||
];
|
||||
|
||||
if (!$validator->validate($input, $rules)) {
|
||||
$this->view->json([
|
||||
'success' => false,
|
||||
'error' => $validator->getFirstError(),
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$updateData = ['email' => $input['email']];
|
||||
$updateData = [];
|
||||
|
||||
if (isset($input['notifications_enabled'])) {
|
||||
$updateData['notifications_enabled'] = $input['notifications_enabled'] ? 1 : 0;
|
||||
}
|
||||
|
||||
$hasEmail = !empty($input['email']);
|
||||
$hasPassword = !empty($input['new_password']);
|
||||
|
||||
if (!$hasEmail && !$hasPassword) {
|
||||
if (!empty($updateData)) {
|
||||
$userModel = new User();
|
||||
$userModel->update((int) $user['id'], $updateData);
|
||||
}
|
||||
$this->view->json(['success' => true, 'message' => 'Profile updated successfully.']);
|
||||
}
|
||||
|
||||
if ($hasEmail) {
|
||||
$validator = new Validator();
|
||||
$rules = ['email' => 'required|email'];
|
||||
if (!$validator->validate($input, $rules)) {
|
||||
$this->view->json(['success' => false, 'error' => $validator->getFirstError()], 400);
|
||||
}
|
||||
$updateData['email'] = $input['email'];
|
||||
}
|
||||
|
||||
if (!empty($input['new_password'])) {
|
||||
if (strlen($input['new_password']) < 8) {
|
||||
$this->view->json([
|
||||
@@ -713,9 +753,15 @@ class ApiController
|
||||
$result = $notificationModel->getForUser((int) $user['id'], $page, $perPage);
|
||||
$unreadCount = $notificationModel->getUnreadCount((int) $user['id']);
|
||||
|
||||
$data = array_map(function ($n) {
|
||||
$n['time_ago'] = time_ago($n['created_at'] ?? '');
|
||||
$n['created_at_timestamp'] = $n['created_at'] ? strtotime($n['created_at']) : 0;
|
||||
return $n;
|
||||
}, $result['data']);
|
||||
|
||||
$this->view->json([
|
||||
'success' => true,
|
||||
'data' => $result['data'],
|
||||
'data' => $data,
|
||||
'pagination' => [
|
||||
'page' => $result['page'],
|
||||
'per_page' => $result['per_page'],
|
||||
|
||||
@@ -67,6 +67,7 @@ class AuthController
|
||||
Session::set('username', $user['username']);
|
||||
Session::set('user_role', $user['role']);
|
||||
Session::set('user_email', $user['email']);
|
||||
Session::set('api_token', $user['api_token']);
|
||||
Session::set('login_time', time());
|
||||
|
||||
$lifetime = App::getConfig()['session']['lifetime'] ?? 1440;
|
||||
@@ -171,6 +172,25 @@ class AuthController
|
||||
{
|
||||
$userId = Session::get('user_id');
|
||||
|
||||
$updateData = [];
|
||||
|
||||
if (isset($_POST['notifications_enabled'])) {
|
||||
$updateData['notifications_enabled'] = 1;
|
||||
} else {
|
||||
$updateData['notifications_enabled'] = 0;
|
||||
}
|
||||
|
||||
$user = $this->userModel->findById($userId);
|
||||
|
||||
$changingEmail = isset($_POST['email']) && $_POST['email'] !== $user['email'];
|
||||
$changingPassword = !empty($_POST['new_password']);
|
||||
|
||||
if (!$changingEmail && !$changingPassword) {
|
||||
$this->userModel->update($userId, $updateData);
|
||||
Session::setFlash('success', 'Profile updated successfully.');
|
||||
$this->view->redirect('/profile');
|
||||
}
|
||||
|
||||
$validator = new Validator();
|
||||
$rules = [
|
||||
'email' => 'required|email',
|
||||
@@ -184,21 +204,13 @@ class AuthController
|
||||
$this->view->redirect('/profile');
|
||||
}
|
||||
|
||||
$user = $this->userModel->findById($userId);
|
||||
|
||||
$security = new \ServerManager\Core\Security();
|
||||
if (!$security->verifyPassword($_POST['current_password'], $user['password_hash'])) {
|
||||
Session::setFlash('error', 'Current password is incorrect.');
|
||||
$this->view->redirect('/profile');
|
||||
}
|
||||
|
||||
$updateData = ['email' => $_POST['email']];
|
||||
|
||||
if (isset($_POST['notifications_enabled'])) {
|
||||
$updateData['notifications_enabled'] = 1;
|
||||
} else {
|
||||
$updateData['notifications_enabled'] = 0;
|
||||
}
|
||||
$updateData['email'] = $_POST['email'];
|
||||
|
||||
if (!empty($_POST['new_password'])) {
|
||||
$updateData['password'] = $_POST['new_password'];
|
||||
|
||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
||||
namespace ServerManager\Controllers;
|
||||
|
||||
use ServerManager\Core\App;
|
||||
use ServerManager\Core\Database;
|
||||
use ServerManager\Core\Session;
|
||||
use ServerManager\Models\Server;
|
||||
use ServerManager\Models\Notification;
|
||||
use ServerManager\Services\MonitoringService;
|
||||
use ServerManager\Services\AuditService;
|
||||
|
||||
@@ -23,6 +25,29 @@ class DashboardController
|
||||
$this->auditService = new AuditService();
|
||||
}
|
||||
|
||||
public function landing(): void
|
||||
{
|
||||
if (Session::has('user_id')) {
|
||||
$this->view->redirect('/dashboard');
|
||||
}
|
||||
|
||||
$stats = [];
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$stats['servers'] = $db->fetch('SELECT COUNT(*) as count FROM servers')['count'] ?? 0;
|
||||
$stats['users'] = $db->fetch('SELECT COUNT(*) as count FROM users')['count'] ?? 0;
|
||||
$stats['online_servers'] = $db->fetch("SELECT COUNT(*) as count FROM servers WHERE current_status = 'online'")['count'] ?? 0;
|
||||
} catch (\Throwable) {
|
||||
$stats = ['servers' => 0, 'users' => 0, 'online_servers' => 0];
|
||||
}
|
||||
|
||||
$this->view->setLayout('auth')->display('pages.landing', [
|
||||
'title' => 'ServerManager - Linux Server Management Platform',
|
||||
'description' => 'ServerManager — Free and open-source Linux server management platform. Monitor server metrics, execute commands via web terminal, and receive push notifications on your phone.',
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$serverModel = new Server();
|
||||
@@ -80,4 +105,46 @@ class DashboardController
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function privacy(): void
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$row = $db->fetch('SELECT `value` FROM app_config WHERE `key` = ?', ['privacy_policy']);
|
||||
$content = $row ? $row['value'] : '';
|
||||
|
||||
$this->view->setLayout('auth')->display('pages.privacy', [
|
||||
'title' => 'Privacy Policy - ServerManager',
|
||||
'content' => $content,
|
||||
]);
|
||||
}
|
||||
|
||||
public function terms(): void
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$row = $db->fetch('SELECT `value` FROM app_config WHERE `key` = ?', ['terms_conditions']);
|
||||
$content = $row ? $row['value'] : '';
|
||||
|
||||
$this->view->setLayout('auth')->display('pages.terms', [
|
||||
'title' => 'Terms & Conditions - ServerManager',
|
||||
'content' => $content,
|
||||
]);
|
||||
}
|
||||
|
||||
public function notifications(): void
|
||||
{
|
||||
$userId = (int) Session::get('user_id');
|
||||
$page = (int) ($_GET['page'] ?? 1);
|
||||
$perPage = 20;
|
||||
|
||||
$notificationModel = new Notification();
|
||||
$result = $notificationModel->getForUser($userId, $page, $perPage);
|
||||
$unreadCount = $notificationModel->getUnreadCount($userId);
|
||||
|
||||
$this->view->display('notifications.index', [
|
||||
'title' => 'Notifications - ServerManager',
|
||||
'notifications' => $result['data'],
|
||||
'pagination' => $result,
|
||||
'unreadCount' => $unreadCount,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1261,8 +1261,8 @@ class ServerController
|
||||
$this->view->json(['success' => false, 'message' => 'Domain is required']);
|
||||
}
|
||||
|
||||
$domainList = preg_replace('/\s+/', ',', $domains);
|
||||
$emailArg = !empty($email) ? "--email {$email}" : '--register-unsafely-without-email';
|
||||
$domainList = escapeshellarg(preg_replace('/\s+/', ',', $domains));
|
||||
$emailArg = !empty($email) ? '--email ' . escapeshellarg($email) : '--register-unsafely-without-email';
|
||||
$cmd = "sudo certbot --nginx -d {$domainList} {$emailArg} --non-interactive --agree-tos 2>&1; echo EXIT:\$?";
|
||||
$result = $ssh->exec($cmd);
|
||||
$exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1');
|
||||
@@ -1773,22 +1773,27 @@ class ServerController
|
||||
$this->view->json(['success' => false, 'message' => 'Invalid action or service']);
|
||||
}
|
||||
|
||||
$sanitizedService = preg_replace('/[^a-zA-Z0-9\-_@.]/', '', $service);
|
||||
if ($sanitizedService === '') {
|
||||
$this->view->json(['success' => false, 'message' => 'Invalid service name']);
|
||||
}
|
||||
|
||||
try {
|
||||
$ssh = new SSHService();
|
||||
if (!$ssh->connect($server)) {
|
||||
throw new \RuntimeException('Could not establish SSH connection');
|
||||
}
|
||||
$this->auditService->logServerAction($id, "service_{$action}", "{$service} {$action}");
|
||||
$this->auditService->logServerAction($id, "service_{$action}", "{$sanitizedService} {$action}");
|
||||
|
||||
$result = $ssh->exec("sudo systemctl {$action} {$service} 2>&1; echo EXIT:\$?");
|
||||
$result = $ssh->exec("sudo systemctl {$action} {$sanitizedService} 2>&1; echo EXIT:\$?");
|
||||
$exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1');
|
||||
$ssh->disconnect();
|
||||
|
||||
$this->view->json([
|
||||
'success' => $exitCode === 0,
|
||||
'message' => $exitCode === 0 ? "Service '{$service}' {$action}ed." : "Failed to {$action} '{$service}'.",
|
||||
'output' => trim($result['output']),
|
||||
]);
|
||||
'success' => $exitCode === 0,
|
||||
'message' => $exitCode === 0 ? "Service '{$sanitizedService}' {$action}ed." : "Failed to {$action} '{$sanitizedService}'.",
|
||||
'output' => trim($result['output']),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->view->json(['success' => false, 'message' => 'Failed: ' . $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -66,14 +66,22 @@ class TerminalController
|
||||
$this->view->json(['success' => false, 'message' => 'Command is required']);
|
||||
}
|
||||
|
||||
$blockedCommands = ['rm -rf /', 'mkfs', 'dd if=', ':(){ :|:& };:'];
|
||||
foreach ($blockedCommands as $blocked) {
|
||||
if (stripos($command, $blocked) !== false) {
|
||||
$blockedPatterns = [
|
||||
'/\brm\s+\-rf\s+\//',
|
||||
'/\bmkfs\b/',
|
||||
'/\bdd\s+if\=/',
|
||||
'/\:\s*\{\s*:\s*\|[^\}]*\}\s*;/',
|
||||
'/\bshutdown\b/',
|
||||
'/\breboot\b/',
|
||||
'/\bhalt\b/',
|
||||
'/\bpoweroff\b/',
|
||||
];
|
||||
foreach ($blockedPatterns as $pattern) {
|
||||
if (preg_match($pattern, $command)) {
|
||||
$this->auditService->log('blocked_command', 'server', $id, [
|
||||
'command' => $command,
|
||||
'reason' => 'Blocked dangerous command pattern',
|
||||
]);
|
||||
|
||||
$this->view->json([
|
||||
'success' => false,
|
||||
'message' => 'This command has been blocked for safety reasons.',
|
||||
@@ -92,7 +100,7 @@ class TerminalController
|
||||
$ssh->disconnect();
|
||||
|
||||
$commandHistory = new CommandHistory();
|
||||
$commandHistory->log(
|
||||
$historyId = $commandHistory->log(
|
||||
$id,
|
||||
Session::get('user_id'),
|
||||
$command,
|
||||
@@ -107,17 +115,29 @@ class TerminalController
|
||||
'output' => $result['output'],
|
||||
'exit_status' => $result['exit_status'],
|
||||
'stderr' => $result['stderr'],
|
||||
'history' => [
|
||||
'id' => $historyId,
|
||||
'command' => $command,
|
||||
'exit_status' => $result['exit_status'] ?? 0,
|
||||
'executed_at' => date('Y-m-d H:i:s'),
|
||||
'username' => Session::get('username'),
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->view->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to execute command: ' . $e->getMessage(),
|
||||
'message' => 'Failed to execute command.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function history(int $id): void
|
||||
{
|
||||
$userId = (int) Session::get('user_id');
|
||||
if (!$this->serverModel->canView($id, $userId)) {
|
||||
$this->view->json(['success' => false, 'message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$commandHistory = new CommandHistory();
|
||||
$history = $commandHistory->getByServer($id, 100);
|
||||
|
||||
@@ -129,6 +149,11 @@ class TerminalController
|
||||
|
||||
public function clearHistory(int $id): void
|
||||
{
|
||||
$userId = (int) Session::get('user_id');
|
||||
if (!$this->serverModel->canView($id, $userId)) {
|
||||
$this->view->json(['success' => false, 'message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$commandHistory = new CommandHistory();
|
||||
$commandHistory->clearHistory($id);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ class View
|
||||
private string $layout = 'main';
|
||||
private array $data = [];
|
||||
private Security $security;
|
||||
private ?string $csrfTokenCache = null;
|
||||
|
||||
public function __construct(Security $security)
|
||||
{
|
||||
@@ -80,7 +81,10 @@ class View
|
||||
|
||||
public function csrfToken(): string
|
||||
{
|
||||
return $this->security->generateCSRFToken();
|
||||
if ($this->csrfTokenCache === null) {
|
||||
$this->csrfTokenCache = $this->security->generateCSRFToken();
|
||||
}
|
||||
return $this->csrfTokenCache;
|
||||
}
|
||||
|
||||
public function csrfField(): string
|
||||
|
||||
@@ -47,6 +47,14 @@ if (!function_exists('asset')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('asset_version')) {
|
||||
function asset_version(): string
|
||||
{
|
||||
$config = \ServerManager\Core\App::getConfig();
|
||||
return $config['app']['asset_version'] ?? '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('url')) {
|
||||
function url(string $path = ''): string
|
||||
{
|
||||
@@ -90,11 +98,23 @@ if (!function_exists('auth')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$apiToken = \ServerManager\Core\Session::get('api_token');
|
||||
|
||||
if (empty($apiToken)) {
|
||||
$userModel = new \ServerManager\Models\User();
|
||||
$user = $userModel->findById((int) \ServerManager\Core\Session::get('user_id'));
|
||||
if ($user && !empty($user['api_token'])) {
|
||||
$apiToken = $user['api_token'];
|
||||
\ServerManager\Core\Session::set('api_token', $apiToken);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => \ServerManager\Core\Session::get('user_id'),
|
||||
'username' => \ServerManager\Core\Session::get('username'),
|
||||
'role' => \ServerManager\Core\Session::get('user_role'),
|
||||
'email' => \ServerManager\Core\Session::get('user_email'),
|
||||
'api_token' => $apiToken,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class AuthMiddleware
|
||||
if (!headers_sent()) {
|
||||
header("Location: {$url}");
|
||||
} else {
|
||||
echo '<script>window.location.href="' . $url . '";</script>';
|
||||
echo '<meta http-equiv="refresh" content="0;url=' . htmlspecialchars($url) . '">';
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace ServerManager\Middleware;
|
||||
|
||||
use ServerManager\Core\Session;
|
||||
use ServerManager\Core\Database;
|
||||
use ServerManager\Core\App;
|
||||
|
||||
@@ -17,24 +16,50 @@ class RateLimitMiddleware
|
||||
$decayMinutes = $config['decay_minutes'] ?? 15;
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
$key = 'rate_limit_' . $ip . '_' . ($_SERVER['REQUEST_URI'] ?? '/');
|
||||
$identifier = hash('sha256', $ip . '_' . ($_SERVER['REQUEST_URI'] ?? '/'));
|
||||
$now = time();
|
||||
$windowStart = $now - ($decayMinutes * 60);
|
||||
|
||||
$attempts = Session::get($key, ['count' => 0, 'first_attempt' => 0]);
|
||||
$db = Database::getInstance();
|
||||
|
||||
if ($attempts['first_attempt'] && (time() - $attempts['first_attempt']) > ($decayMinutes * 60)) {
|
||||
$attempts = ['count' => 0, 'first_attempt' => 0];
|
||||
$existing = $db->fetch(
|
||||
'SELECT attempts, first_attempt_at FROM rate_limits WHERE identifier = ?',
|
||||
[$identifier]
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
if ((int) $existing['first_attempt_at'] < $windowStart) {
|
||||
$db->query(
|
||||
'UPDATE rate_limits SET attempts = 1, first_attempt_at = ? WHERE identifier = ?',
|
||||
[$now, $identifier]
|
||||
);
|
||||
$attempts = 1;
|
||||
} else {
|
||||
$attempts = (int) $existing['attempts'] + 1;
|
||||
$db->query(
|
||||
'UPDATE rate_limits SET attempts = ? WHERE identifier = ?',
|
||||
[$attempts, $identifier]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$db->insert('rate_limits', [
|
||||
'identifier' => $identifier,
|
||||
'attempts' => 1,
|
||||
'first_attempt_at' => $now,
|
||||
]);
|
||||
$attempts = 1;
|
||||
}
|
||||
|
||||
$attempts['count']++;
|
||||
|
||||
if ($attempts['first_attempt'] === 0) {
|
||||
$attempts['first_attempt'] = time();
|
||||
if (mt_rand(1, 100) <= 5) {
|
||||
$db->query(
|
||||
'DELETE FROM rate_limits WHERE first_attempt_at < ?',
|
||||
[$windowStart]
|
||||
);
|
||||
}
|
||||
|
||||
Session::set($key, $attempts);
|
||||
|
||||
if ($attempts['count'] > $maxAttempts) {
|
||||
$retryAfter = ($decayMinutes * 60) - (time() - $attempts['first_attempt']);
|
||||
if ($attempts > $maxAttempts) {
|
||||
$firstAttemptAt = $existing ? (int) $existing['first_attempt_at'] : $now;
|
||||
$retryAfter = ($decayMinutes * 60) - ($now - $firstAttemptAt);
|
||||
|
||||
if ($this->isApiRequest()) {
|
||||
http_response_code(429);
|
||||
@@ -49,7 +74,13 @@ class RateLimitMiddleware
|
||||
|
||||
http_response_code(429);
|
||||
header("Retry-After: {$retryAfter}");
|
||||
echo '<h1>429 Too Many Requests</h1><p>Please wait ' . ceil($retryAfter / 60) . ' minutes before trying again.</p>';
|
||||
|
||||
echo App::getInstance()->getView()->renderView('errors.429', [
|
||||
'retryAfter' => $retryAfter,
|
||||
'retryMinutes' => (int) ceil($retryAfter / 60),
|
||||
'retryAfterMs' => $retryAfter * 1000,
|
||||
'requestUri' => htmlspecialchars($_SERVER['REQUEST_URI'] ?? '/', ENT_QUOTES, 'UTF-8'),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class CommandHistory
|
||||
'command' => $command,
|
||||
'output' => mb_substr($output ?? '', 0, 65535),
|
||||
'exit_status' => $exitStatus,
|
||||
'status' => 'active',
|
||||
'executed_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -143,4 +143,10 @@ class Notification
|
||||
$result = $this->db->fetch("SELECT COUNT(*) as total FROM users WHERE status = 'active'");
|
||||
return (int) ($result['total'] ?? 0);
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->db->delete('notifications', 'id = ?', [$id]);
|
||||
$this->db->delete('notification_reads', 'notification_id = ?', [$id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,16 @@ class Server
|
||||
private Encryption $encryption;
|
||||
private array $encryptedFields = ['ssh_password', 'ssh_key'];
|
||||
|
||||
private array $fillable = [
|
||||
'name', 'ip_address', 'ssh_port', 'ssh_user', 'ssh_password', 'ssh_key',
|
||||
'description', 'group_name', 'is_active', 'current_status', 'status',
|
||||
'agent_key', 'agent_installed', 'agent_install_path', 'agent_last_seen_at',
|
||||
'created_by', 'created_at', 'updated_at',
|
||||
'threshold_cpu_warning', 'threshold_cpu_critical',
|
||||
'threshold_ram_warning', 'threshold_ram_critical',
|
||||
'threshold_disk_warning', 'threshold_disk_critical',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = Database::getInstance();
|
||||
@@ -108,8 +118,14 @@ class Server
|
||||
];
|
||||
}
|
||||
|
||||
private function filterData(array $data): array
|
||||
{
|
||||
return array_intersect_key($data, array_flip($this->fillable));
|
||||
}
|
||||
|
||||
public function create(array $data): int
|
||||
{
|
||||
$data = $this->filterData($data);
|
||||
if (!empty($data['ssh_password'])) {
|
||||
$data['ssh_password'] = $this->encryption->encrypt($data['ssh_password']);
|
||||
} else {
|
||||
@@ -143,6 +159,7 @@ class Server
|
||||
|
||||
public function update(int $id, array $data): bool
|
||||
{
|
||||
$data = $this->filterData($data);
|
||||
if (array_key_exists('ssh_password', $data)) {
|
||||
if (!empty($data['ssh_password'])) {
|
||||
$data['ssh_password'] = $this->encryption->encrypt($data['ssh_password']);
|
||||
|
||||
@@ -54,6 +54,12 @@ class FCMService
|
||||
{
|
||||
if (!$this->isConfigured()) return false;
|
||||
|
||||
$enabled = $this->db->fetch(
|
||||
'SELECT notifications_enabled FROM users WHERE id = ? AND notifications_enabled = 1',
|
||||
[$userId]
|
||||
);
|
||||
if (!$enabled) return false;
|
||||
|
||||
$tokens = $this->db->fetchAll(
|
||||
'SELECT id, token FROM user_fcm_tokens WHERE user_id = ?',
|
||||
[$userId]
|
||||
@@ -78,7 +84,11 @@ class FCMService
|
||||
{
|
||||
if (!$this->isConfigured()) return 0;
|
||||
|
||||
$tokens = $this->db->fetchAll('SELECT id, token FROM user_fcm_tokens');
|
||||
$tokens = $this->db->fetchAll(
|
||||
'SELECT f.id, f.token FROM user_fcm_tokens f
|
||||
JOIN users u ON u.id = f.user_id
|
||||
WHERE u.notifications_enabled = 1'
|
||||
);
|
||||
if (empty($tokens)) return 0;
|
||||
|
||||
$sent = 0;
|
||||
|
||||
@@ -187,45 +187,4 @@ function selected(string $value, string $compare): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<script src="/assets/js/audit-log.js?v=<?= asset_version() ?>"></script>
|
||||
|
||||
35
views/admin/index.php
Normal file
35
views/admin/index.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Administration</h1>
|
||||
<p class="page-subtitle">Manage system settings and configurations</p>
|
||||
</div>
|
||||
|
||||
<div class="action-grid">
|
||||
<a href="/teams" class="action-card">
|
||||
<i class="fas fa-users-cog"></i>
|
||||
<span>Teams</span>
|
||||
</a>
|
||||
<a href="/admin/users" class="action-card">
|
||||
<i class="fas fa-users"></i>
|
||||
<span>Users</span>
|
||||
</a>
|
||||
<a href="/admin/audit" class="action-card">
|
||||
<i class="fas fa-history"></i>
|
||||
<span>Audit Logs</span>
|
||||
</a>
|
||||
<a href="/admin/security" class="action-card">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<span>Security</span>
|
||||
</a>
|
||||
<a href="/admin/app-version" class="action-card">
|
||||
<i class="fas fa-mobile-alt"></i>
|
||||
<span>App Version</span>
|
||||
</a>
|
||||
<a href="/admin/notifications" class="action-card">
|
||||
<i class="fas fa-bell"></i>
|
||||
<span>Notifications</span>
|
||||
</a>
|
||||
<a href="/admin/policies" class="action-card">
|
||||
<i class="fas fa-file-contract"></i>
|
||||
<span>Policies</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -29,12 +29,13 @@ $data = $notifications['data'] ?? [];
|
||||
<th>Message</th>
|
||||
<th>Target</th>
|
||||
<th>Read by</th>
|
||||
<th style="width:60px">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($data)): ?>
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted">No notifications sent yet.</td>
|
||||
<td colspan="7" class="text-center text-muted">No notifications sent yet.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($data as $note): ?>
|
||||
@@ -65,6 +66,20 @@ $data = $notifications['data'] ?? [];
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:0.25rem">
|
||||
<button class="btn btn-icon btn-xs" title="Resend"
|
||||
onclick="confirmResend(<?= $note['id'] ?>)"
|
||||
style="color:var(--info)">
|
||||
<i class="fas fa-redo"></i>
|
||||
</button>
|
||||
<button class="btn btn-icon btn-xs" title="Delete"
|
||||
onclick="showDeleteModal(<?= $note['id'] ?>, '<?= htmlspecialchars(addslashes($note['title'] ?? '')) ?>')"
|
||||
style="color:var(--danger)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
@@ -139,45 +154,29 @@ $data = $notifications['data'] ?? [];
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showSendModal() {
|
||||
document.getElementById('notificationModal').style.display = 'flex';
|
||||
}
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal" id="deleteModal" onclick="if (event.target === this) closeDeleteModal()">
|
||||
<div class="modal-dialog" style="max-width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-trash" style="color:var(--danger);margin-right:0.5rem"></i>Delete Notification</h3>
|
||||
<button class="modal-close" onclick="closeDeleteModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom:0.5rem;font-size:0.92rem">
|
||||
Are you sure you want to delete this notification?
|
||||
</p>
|
||||
<p id="deleteModalTitle" style="font-size:0.88rem;color:var(--text-muted);background:var(--bg-tertiary);padding:0.5rem 0.75rem;border-radius:var(--radius-sm)"></p>
|
||||
<p style="margin-top:0.75rem;font-size:0.82rem;color:var(--text-muted)">
|
||||
This action cannot be undone. The notification will be removed for all users.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeDeleteModal()">Cancel</button>
|
||||
<button class="btn btn-danger" id="deleteConfirmBtn" onclick="executeDelete()">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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>
|
||||
<script src="/assets/js/admin-notifications.js?v=<?= asset_version() ?>"></script>
|
||||
|
||||
38
views/admin/policies.php
Normal file
38
views/admin/policies.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Legal Policies</h1>
|
||||
<p class="page-subtitle">Manage Privacy Policy and Terms & Conditions</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/admin/policies">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<div class="dashboard-card" style="margin-bottom:1.5rem;max-width:100%">
|
||||
<div class="card-header">
|
||||
<h2><i class="fas fa-shield-alt"></i> Privacy Policy</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label for="privacy_policy">Content</label>
|
||||
<textarea name="privacy_policy" id="privacy_policy" class="form-control" rows="20" style="font-family:monospace;width:100%;min-height:400px;font-size:0.9rem;line-height:1.5;padding:1rem"><?= htmlspecialchars($privacy ?? '') ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card" style="margin-bottom:1.5rem;max-width:100%">
|
||||
<div class="card-header">
|
||||
<h2><i class="fas fa-file-contract"></i> Terms & Conditions</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label for="terms_conditions">Content</label>
|
||||
<textarea name="terms_conditions" id="terms_conditions" class="form-control" rows="20" style="font-family:monospace;width:100%;min-height:400px;font-size:0.9rem;line-height:1.5;padding:1rem"><?= htmlspecialchars($terms ?? '') ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -102,22 +102,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<script src="/assets/js/admin-security.js?v=<?= asset_version() ?>"></script>
|
||||
|
||||
@@ -121,81 +121,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<script src="/assets/js/admin-users.js?v=<?= asset_version() ?>"></script>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<h3>Change Password</h3>
|
||||
<div class="form-group">
|
||||
<label for="current_password">Current Password</label>
|
||||
<input type="password" id="current_password" name="current_password" class="form-input" required>
|
||||
<input type="password" id="current_password" name="current_password" class="form-input">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_password">New Password (leave blank to keep current)</label>
|
||||
|
||||
@@ -154,153 +154,16 @@ $aggregatedMetrics = $aggregatedMetrics ?? [];
|
||||
<?= htmlspecialchars($activity['actor_name'] ?? $activity['username'] ?? 'System') ?>
|
||||
· <?= date('H:i', strtotime($activity['created_at'] ?? '')) ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
const aggregatedData = <?= json_encode($aggregatedMetrics) ?>;
|
||||
|
||||
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>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
<script>const aggregatedData = <?= json_encode($aggregatedMetrics, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;</script>
|
||||
<script src="/assets/js/dashboard-chart.js?v=<?= asset_version() ?>"></script>
|
||||
</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>
|
||||
21
views/errors/offline.php
Normal file
21
views/errors/offline.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Offline — ServerManager</title>
|
||||
<link rel="stylesheet" href="/assets/css/style.css?v=<?= asset_version() ?>">
|
||||
</head>
|
||||
<body class="error-page">
|
||||
<div class="error-card">
|
||||
<div class="error-code" style="font-size:4rem">📡</div>
|
||||
<h1 class="error-message">You're Offline</h1>
|
||||
<p class="error-description">ServerManager needs an internet connection to manage your servers.<br>Check your connection and try again.</p>
|
||||
<div class="error-actions">
|
||||
<a href="/dashboard" class="btn btn-primary">
|
||||
<i class="fas fa-sync"></i> Retry
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,8 +4,17 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($title ?? 'ServerManager') ?></title>
|
||||
<meta name="description" content="ServerManager — Linux server management platform. Monitor, manage, and control your servers with real-time metrics and web terminal.">
|
||||
<link rel="canonical" href="<?= htmlspecialchars(($_ENV['APP_URL'] ?? 'https://sysadmin.lan') . parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH)) ?>">
|
||||
<meta property="og:title" content="<?= htmlspecialchars($title ?? 'ServerManager') ?>">
|
||||
<meta property="og:description" content="ServerManager — Linux server management platform.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:image" content="<?= htmlspecialchars(($_ENV['APP_URL'] ?? 'https://sysadmin.lan') . '/assets/img/icon-512.png') ?>">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/server.svg">
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#0f1117">
|
||||
<link rel="stylesheet" href="/assets/css/style.css?v=<?= asset_version() ?>">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
@@ -13,6 +22,6 @@
|
||||
<?= $content ?? '' ?>
|
||||
</div>
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
<script src="/assets/js/app.js"></script>
|
||||
<script src="/assets/js/app.js?v=<?= asset_version() ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,211 +1,279 @@
|
||||
<?php
|
||||
$notifications = $notifications ?? [];
|
||||
$unreadCount = $unreadCount ?? 0;
|
||||
$teamId = $teamId ?? null;
|
||||
$title = $title ?? 'ServerManager';
|
||||
$description = $description ?? 'ServerManager — Linux server management platform. Monitor, manage, and control your servers from anywhere with real-time metrics, web terminal, and push notifications.';
|
||||
$content = $content ?? '';
|
||||
|
||||
$currentRoute = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
$canonicalUrl = ($_ENV['APP_URL'] ?? 'https://sysadmin.lan') . $currentRoute;
|
||||
|
||||
if (!isset($user) || $user === null) {
|
||||
$user = auth();
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="<?= $_SESSION['csrf_token'] ?? '' ?>">
|
||||
<title><?= htmlspecialchars($title ?? 'ServerManager') ?></title>
|
||||
<title><?= htmlspecialchars($title) ?> — ServerManager</title>
|
||||
<meta name="description" content="<?= htmlspecialchars($description) ?>">
|
||||
<meta name="api-token" content="<?= htmlspecialchars($user['api_token'] ?? '') ?>">
|
||||
<meta name="csrf-token" content="<?= $this->csrfToken() ?>">
|
||||
|
||||
<!-- Canonical -->
|
||||
<link rel="canonical" href="<?= htmlspecialchars($canonicalUrl) ?>">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="<?= htmlspecialchars($title) ?> — ServerManager">
|
||||
<meta property="og:description" content="<?= htmlspecialchars($description) ?>">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="<?= htmlspecialchars($canonicalUrl) ?>">
|
||||
<meta property="og:image" content="<?= htmlspecialchars(($_ENV['APP_URL'] ?? 'https://sysadmin.lan') . '/assets/img/icon-512.png') ?>">
|
||||
<meta property="og:site_name" content="ServerManager">
|
||||
<meta property="og:locale" content="en_US">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="<?= htmlspecialchars($title) ?> — ServerManager">
|
||||
<meta name="twitter:description" content="<?= htmlspecialchars($description) ?>">
|
||||
<meta name="twitter:image" content="<?= htmlspecialchars(($_ENV['APP_URL'] ?? 'https://sysadmin.lan') . '/assets/img/icon-512.png') ?>">
|
||||
|
||||
<!-- JSON-LD Schema -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "ServerManager",
|
||||
"url": "<?= htmlspecialchars($_ENV['APP_URL'] ?? 'https://sysadmin.lan') ?>",
|
||||
"description": "Linux server management platform with real-time monitoring, web terminal, and push notifications.",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "Linux",
|
||||
"browserRequirements": "Requires JavaScript. Supports Chrome, Firefox, Safari, Edge.",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="stylesheet" href="/assets/css/style.css?v=<?= asset_version() ?>">
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/server.svg">
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#0f1117">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="apple-touch-icon" href="/assets/img/icon-192.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<div class="app-layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<a href="/dashboard" class="sidebar-logo">
|
||||
<i class="fas fa-server"></i>
|
||||
<span class="logo-text">ServerManager</span>
|
||||
<a href="/dashboard" class="sidebar-brand">
|
||||
<i class="fas fa-cubes"></i>
|
||||
<span>ServerManager</span>
|
||||
</a>
|
||||
<button class="sidebar-toggle" id="sidebarToggle" title="Toggle sidebar">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item">
|
||||
<a href="/dashboard" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/dashboard') ? 'active' : '' ?>">
|
||||
<i class="fas fa-tachometer-alt"></i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item nav-item-accordion <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/servers') ? 'active' : '' ?>">
|
||||
<a href="#" class="nav-link" onclick="toggleServerList(event)">
|
||||
<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>
|
||||
<?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>
|
||||
<a href="/logout" title="Logout"><i class="fas fa-sign-out-alt"></i></a>
|
||||
<!-- Dashboard -->
|
||||
<a href="/dashboard" class="nav-item <?= str_starts_with($currentRoute, '/dashboard') ? 'active' : '' ?>">
|
||||
<i class="fas fa-chart-pie"></i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
|
||||
<!-- Servers -->
|
||||
<div class="nav-item-accordion <?= str_starts_with($currentRoute, '/servers') || str_starts_with($currentRoute, '/terminal') ? 'expanded' : '' ?>">
|
||||
<a href="#" class="nav-item nav-item-toggle <?= str_starts_with($currentRoute, '/servers') ? 'active' : '' ?>" onclick="toggleServerList(event)">
|
||||
<i class="fas fa-server"></i>
|
||||
<span>Servers</span>
|
||||
<i class="fas fa-chevron-right nav-chevron"></i>
|
||||
</a>
|
||||
<div class="nav-sublist" id="serverList" style="<?= str_starts_with($currentRoute, '/servers') || str_starts_with($currentRoute, '/terminal') ? '' : '' ?>">
|
||||
<a href="/servers" class="nav-sub-item"><i class="fas fa-list"></i> All Servers</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Administration -->
|
||||
<?php if (is_super_admin()): ?>
|
||||
<hr class="nav-divider">
|
||||
<div class="nav-section">Administration</div>
|
||||
|
||||
<a href="/admin" class="nav-item <?= str_starts_with($currentRoute, '/admin') ? 'active' : '' ?>">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span>Administration</span>
|
||||
</a>
|
||||
<?php elseif (is_admin()): ?>
|
||||
<hr class="nav-divider">
|
||||
<div class="nav-section">Administration</div>
|
||||
|
||||
<a href="/teams" class="nav-item <?= str_starts_with($currentRoute, '/teams') ? 'active' : '' ?>">
|
||||
<i class="fas fa-users-cog"></i>
|
||||
<span>Teams</span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Mobile -->
|
||||
<hr class="nav-divider">
|
||||
<div class="nav-section">Mobile</div>
|
||||
<a href="#" class="nav-item" onclick="showDownloadModal(); return false;">
|
||||
<i class="fas fa-download"></i>
|
||||
<span>Download APK</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
|
||||
</aside>
|
||||
|
||||
<div class="mobile-overlay" id="mobileOverlay"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="top-bar">
|
||||
<div class="top-bar-left">
|
||||
<button class="mobile-toggle" id="mobileToggle">
|
||||
<!-- Main Content -->
|
||||
<div class="main-area">
|
||||
<!-- Topbar -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<button class="btn btn-icon" id="sidebarToggle" onclick="document.getElementById('sidebar').classList.toggle('collapsed')">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="breadcrumb">
|
||||
<?= htmlspecialchars($title ?? 'ServerManager') ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-bar-right">
|
||||
<div class="top-bar-actions">
|
||||
<button class="btn-icon" id="refreshBtn" title="Refresh">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
<div class="topbar-right">
|
||||
<div class="notification-btn-wrapper">
|
||||
<button class="btn btn-icon notification-btn" onclick="toggleNotifications()" title="Notifications">
|
||||
<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 class="btn-icon" id="themeToggle" title="Toggle theme">
|
||||
<i class="fas fa-moon"></i>
|
||||
<div class="notification-dropdown" id="notifDropdown">
|
||||
<div class="notif-header">
|
||||
<h3>Notifications</h3>
|
||||
<button class="btn btn-text btn-sm" id="markAllReadBtn" onclick="markAllRead()"
|
||||
style="display:<?= $unreadCount > 0 ? 'inline-flex' : 'none' ?>">
|
||||
<i class="fas fa-check-double"></i> Mark all read
|
||||
</button>
|
||||
</div>
|
||||
<div class="notif-list" 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>
|
||||
</div>
|
||||
<div class="dropdown topbar-user-dropdown">
|
||||
<button class="topbar-user" onclick="toggleUserMenu(event)">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
<?= htmlspecialchars($user['username'] ?? 'Guest') ?>
|
||||
<i class="fas fa-chevron-down" style="font-size:0.7rem;margin-left:4px"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-right" id="userMenu">
|
||||
<div class="dropdown-header">
|
||||
<strong><?= htmlspecialchars($user['username'] ?? 'Guest') ?></strong>
|
||||
<small><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $user['role'] ?? 'guest'))) ?></small>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a href="/profile" class="dropdown-item"><i class="fas fa-user-cog"></i> My Account</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<form method="POST" action="/logout" style="padding:0;margin:0">
|
||||
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
|
||||
<button type="submit" class="dropdown-item" style="width:100%;text-align:left;border:none;background:none;cursor:pointer;font:inherit">
|
||||
<i class="fas fa-sign-out-alt"></i> Log out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-area">
|
||||
<?php if ($flash = \ServerManager\Core\Session::getAllFlashes()): ?>
|
||||
<?php foreach ($flash as $type => $messages): ?>
|
||||
<?php foreach ($messages as $message): ?>
|
||||
<div class="toast toast-<?= htmlspecialchars($type) ?>" data-autohide="true">
|
||||
<div class="toast-body">
|
||||
<i class="fas fa-<?= $type === 'success' ? 'check-circle' : ($type === 'error' ? 'times-circle' : 'info-circle') ?>"></i>
|
||||
<?= htmlspecialchars($message) ?>
|
||||
<!-- Page Content -->
|
||||
<main class="page-content">
|
||||
<div class="container">
|
||||
<?php if (isset($_SESSION['_flash'])): ?>
|
||||
<?php foreach ($_SESSION['_flash'] as $type => $messages): ?>
|
||||
<?php foreach ($messages as $msg): ?>
|
||||
<div class="alert alert-<?= $type === 'error' ? 'danger' : $type ?>">
|
||||
<i class="fas fa-<?= $type === 'success' ? 'check-circle' : ($type === 'error' ? 'exclamation-circle' : 'info-circle') ?>"></i>
|
||||
<?= htmlspecialchars($msg) ?>
|
||||
</div>
|
||||
<button class="toast-close">×</button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<?php unset($_SESSION['_flash']); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?= $content ?? '' ?>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
|
||||
<script src="/assets/js/app.js"></script>
|
||||
<!-- Download Modal -->
|
||||
<div class="modal" id="downloadModal" onclick="if (event.target === this) closeDownloadModal()">
|
||||
<div class="modal-dialog" style="max-width: 440px;">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-download" style="color:var(--accent);margin-right:0.5rem"></i>Download ServerManager</h3>
|
||||
<button class="modal-close" onclick="closeDownloadModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom:1rem;font-size:0.92rem;line-height:1.6">
|
||||
The Android companion app lets you manage your servers on the go:
|
||||
</p>
|
||||
<ul style="list-style:none;padding:0;margin:0 0 1.25rem;font-size:0.88rem;display:flex;flex-direction:column;gap:0.6rem">
|
||||
<li><i class="fas fa-check-circle" style="color:var(--success);width:20px;text-align:center;margin-right:0.5rem"></i> Monitor server status and metrics</li>
|
||||
<li><i class="fas fa-check-circle" style="color:var(--success);width:20px;text-align:center;margin-right:0.5rem"></i> Receive push notifications for alerts</li>
|
||||
<li><i class="fas fa-check-circle" style="color:var(--success);width:20px;text-align:center;margin-right:0.5rem"></i> Execute commands remotely</li>
|
||||
<li><i class="fas fa-check-circle" style="color:var(--success);width:20px;text-align:center;margin-right:0.5rem"></i> Manage services and processes</li>
|
||||
</ul>
|
||||
<div style="padding:0.75rem;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:0.82rem;color:var(--text-muted);margin-bottom:1rem">
|
||||
<strong style="color:var(--text-secondary);display:block;margin-bottom:0.4rem"><i class="fas fa-shield-alt" style="margin-right:0.35rem"></i>Required Permissions</strong>
|
||||
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:0.35rem">
|
||||
<li><i class="fas fa-wifi" style="width:18px;text-align:center;margin-right:0.4rem;font-size:0.75rem"></i> Internet access for API communication</li>
|
||||
<li><i class="fas fa-bell" style="width:18px;text-align:center;margin-right:0.4rem;font-size:0.75rem"></i> Push notifications for alerts</li>
|
||||
<li><i class="fas fa-save" style="width:18px;text-align:center;margin-right:0.4rem;font-size:0.75rem"></i> Storage for offline data cache</li>
|
||||
</ul>
|
||||
</div>
|
||||
<label class="checkbox-label" style="margin-bottom:0.75rem;font-size:0.82rem">
|
||||
<input type="checkbox" id="agreeTerms" onchange="toggleDownloadBtn()">
|
||||
<span class="checkmark"><i class="fas fa-check"></i></span>
|
||||
I agree to the <a href="/terms" target="_blank" style="color:var(--accent);text-decoration:underline">Terms & Conditions</a>
|
||||
and <a href="/privacy" target="_blank" style="color:var(--accent);text-decoration:underline">Privacy Policy</a>
|
||||
</label>
|
||||
<div id="downloadProgressWrap" style="display:none">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.8rem;color:var(--text-muted);margin-bottom:0.3rem">
|
||||
<span>Downloading...</span>
|
||||
<span id="downloadPercent">0%</span>
|
||||
</div>
|
||||
<div style="height:6px;background:var(--bg-tertiary);border-radius:3px;overflow:hidden">
|
||||
<div id="downloadProgressBar" style="height:100%;width:0%;background:linear-gradient(90deg,var(--accent),var(--accent-hover));border-radius:3px;transition:width 0.3s ease"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" id="downloadModalFooter">
|
||||
<button class="btn btn-secondary" onclick="closeDownloadModal()">Cancel</button>
|
||||
<button class="btn btn-primary" id="downloadConfirmBtn" onclick="startDownload()" disabled>
|
||||
<i class="fas fa-download"></i> Download APK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/app.js?v=<?= asset_version() ?>"></script>
|
||||
<?php if (isset($scripts)): ?>
|
||||
<?php foreach ($scripts as $script): ?>
|
||||
<script src="<?= htmlspecialchars($script) ?>"></script>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<script src="/assets/js/main.js?v=<?= asset_version() ?>"></script>
|
||||
<script>
|
||||
let serverListLoaded = false;
|
||||
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> '
|
||||
+ 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';
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
70
views/notifications/index.php
Normal file
70
views/notifications/index.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
$notifications = $notifications ?? [];
|
||||
$pagination = $pagination ?? [];
|
||||
$unreadCount = $unreadCount ?? 0;
|
||||
$data = $notifications ?? [];
|
||||
?>
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<div class="back-link">
|
||||
<a href="/dashboard"><i class="fas fa-arrow-left"></i> Back to Dashboard</a>
|
||||
</div>
|
||||
<h1 class="page-title">Notifications</h1>
|
||||
<p class="page-subtitle">
|
||||
<?= $unreadCount ?> unread · <?= $pagination['total'] ?? 0 ?> total
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-header-right">
|
||||
<?php if ($unreadCount > 0): ?>
|
||||
<button class="btn btn-sm btn-text" onclick="markAllRead()" style="font-size:0.85em">
|
||||
<i class="fas fa-check-double"></i> Mark all read
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:0">
|
||||
<?php if (empty($data)): ?>
|
||||
<div style="padding:60px 20px;text-align:center;color:var(--text-muted)">
|
||||
<i class="fas fa-check-circle" style="font-size:2.5em;margin-bottom:12px;display:block;color:var(--success)"></i>
|
||||
<p style="font-size:1.05em">All caught up!</p>
|
||||
<p style="font-size:0.9em">No notifications to show.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($data as $n): ?>
|
||||
<?php
|
||||
$typeColors = ['error' => 'danger', 'warning' => 'warning', 'info' => 'info'];
|
||||
$icons = ['error' => 'fa-times-circle', 'warning' => 'fa-exclamation-triangle', 'info' => 'fa-info-circle'];
|
||||
$tc = $typeColors[$n['type']] ?? 'info';
|
||||
$icon = $icons[$n['type']] ?? 'fa-info-circle';
|
||||
$isRead = !empty($n['is_read']);
|
||||
?>
|
||||
<div class="notif-item <?= $isRead ? 'notif-read' : 'notif-unread' ?>" data-id="<?= $n['id'] ?>">
|
||||
<div class="notif-icon notif-<?= $tc ?>"><i class="fas <?= $icon ?>"></i></div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-title"><?= htmlspecialchars($n['title'] ?? '') ?></div>
|
||||
<div class="notif-message"><?= htmlspecialchars($n['message'] ?? '') ?></div>
|
||||
<div class="notif-time"><?= time_ago($n['created_at'] ?? '') ?></div>
|
||||
</div>
|
||||
<?php if (!$isRead): ?>
|
||||
<div class="notif-dot"></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (($pagination['total_pages'] ?? 1) > 1): ?>
|
||||
<div class="pagination">
|
||||
<?php for ($i = 1; $i <= $pagination['total_pages']; $i++): ?>
|
||||
<a href="/notifications?page=<?= $i ?>" class="btn btn-sm <?= ($pagination['page'] ?? 1) === $i ? 'btn-primary' : 'btn-secondary' ?>">
|
||||
<?= $i ?>
|
||||
</a>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<script src="/assets/js/notifications.js?v=<?= asset_version() ?>"></script>
|
||||
186
views/pages/landing.php
Normal file
186
views/pages/landing.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php ?><link rel="stylesheet" href="/assets/css/landing.css?v=<?= asset_version() ?>">
|
||||
|
||||
<div class="landing-page">
|
||||
<section class="landing-hero">
|
||||
<div class="floating-elements">
|
||||
<div class="floating-elem"><i class="fas fa-server"></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-shield-alt"></i></div>
|
||||
<div class="floating-elem"><i class="fas fa-cloud"></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 class="hero-icon-wrapper">
|
||||
<div class="hero-server-icon">
|
||||
<i class="fas fa-server"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="hero-title">
|
||||
Manage your <span class="gradient-text">servers</span> with ease
|
||||
</h1>
|
||||
|
||||
<p class="hero-subtitle">
|
||||
A self-hosted, open-source platform for remote server management.
|
||||
Monitor, maintain, and control your infrastructure from a clean web dashboard.
|
||||
</p>
|
||||
|
||||
<div class="hero-cta">
|
||||
<a href="/login" class="btn btn-primary-custom">
|
||||
<i class="fas fa-arrow-right"></i> Get Started
|
||||
</a>
|
||||
<a href="https://github.com/devlab-lat/server-manager" class="btn btn-secondary-custom" target="_blank">
|
||||
<i class="fab fa-github"></i> View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Terminal demo -->
|
||||
<div class="hero-terminal">
|
||||
<div class="terminal-bar">
|
||||
<span class="terminal-dot red"></span>
|
||||
<span class="terminal-dot yellow"></span>
|
||||
<span class="terminal-dot green"></span>
|
||||
<span class="terminal-title">server-manager@dashboard:~</span>
|
||||
</div>
|
||||
<div class="terminal-body-landing">
|
||||
<div class="terminal-line-landing" style="animation-delay:0.1s"><span class="prompt">$</span> <span class="cmd">serverman list --servers</span></div>
|
||||
<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>
|
||||
<div class="terminal-line-landing" style="animation-delay:0.9s"><span class="prompt">$</span> <span class="cmd">serverman status web-01</span></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.7s"><span class="prompt">$</span> <span class="cmd"><span class="blinking-cursor"></span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scroll-indicator">
|
||||
<div class="mouse"></div>
|
||||
<span>Scroll to explore</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features section -->
|
||||
<section class="features-section" id="features">
|
||||
<div class="features-header">
|
||||
<h2>Everything you need to manage servers</h2>
|
||||
<p>A comprehensive toolkit for sysadmins and developers.</p>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon blue"><i class="fas fa-terminal"></i></div>
|
||||
<h3>Remote SSH Terminal</h3>
|
||||
<p>Browser-based SSH client with session persistence, tab support, and full terminal emulation.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon green"><i class="fas fa-chart-line"></i></div>
|
||||
<h3>Real-time Monitoring</h3>
|
||||
<p>Track CPU, memory, disk usage, and network activity with live-updating graphs and alerts.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon purple"><i class="fas fa-users-cog"></i></div>
|
||||
<h3>Team Collaboration</h3>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<section class="stats-section">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number" data-target="15">0</div>
|
||||
<div class="stat-label">Servers Managed</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<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>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number" data-target="24">0</div>
|
||||
<div class="stat-label">Hour Monitoring</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="landing-footer">
|
||||
<div class="footer-links">
|
||||
<a href="/terms">Terms</a>
|
||||
<a href="/privacy">Privacy</a>
|
||||
<a href="https://github.com/devlab-lat/server-manager">GitHub</a>
|
||||
</div>
|
||||
<span>© <?= date('Y') ?> ServerManager. Released under the MIT License.</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Page-specific JS -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Animated counter
|
||||
const counters = document.querySelectorAll('.stat-number');
|
||||
|
||||
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>
|
||||
27
views/pages/privacy.php
Normal file
27
views/pages/privacy.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php ?><link rel="stylesheet" href="/assets/css/legal.css?v=<?= asset_version() ?>">
|
||||
<div class="legal-page">
|
||||
<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)">
|
||||
<h1 style="color:var(--text-primary);margin-bottom:0.25rem;font-size:1.8rem;font-weight:700">Privacy Policy</h1>
|
||||
<p style="color:var(--text-muted);font-size:0.9rem;margin-bottom:2rem">Last updated: June 2026</p>
|
||||
<hr style="border-color:var(--border-color);margin-bottom:2rem;opacity:0.5">
|
||||
<div class="legal-content" style="line-height:1.8;color:var(--text-primary);font-size:1rem">
|
||||
<?php
|
||||
$lines = explode("\n", $content ?? '');
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
if ($trimmed === '') {
|
||||
echo '<br>';
|
||||
} elseif (str_starts_with($trimmed, '# ')) {
|
||||
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 (str_starts_with($trimmed, '## ')) {
|
||||
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>';
|
||||
} else {
|
||||
echo '<p style="margin-bottom:0.75rem">' . htmlspecialchars($trimmed) . '</p>';
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
27
views/pages/terms.php
Normal file
27
views/pages/terms.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php ?><link rel="stylesheet" href="/assets/css/legal.css?v=<?= asset_version() ?>">
|
||||
<div class="legal-page">
|
||||
<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)">
|
||||
<h1 style="color:var(--text-primary);margin-bottom:0.25rem;font-size:1.8rem;font-weight:700">Terms & Conditions</h1>
|
||||
<p style="color:var(--text-muted);font-size:0.9rem;margin-bottom:2rem">Last updated: June 2026</p>
|
||||
<hr style="border-color:var(--border-color);margin-bottom:2rem;opacity:0.5">
|
||||
<div class="legal-content" style="line-height:1.8;color:var(--text-primary);font-size:1rem">
|
||||
<?php
|
||||
$lines = explode("\n", $content ?? '');
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
if ($trimmed === '') {
|
||||
echo '<br>';
|
||||
} elseif (str_starts_with($trimmed, '# ')) {
|
||||
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 (str_starts_with($trimmed, '## ')) {
|
||||
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>';
|
||||
} else {
|
||||
echo '<p style="margin-bottom:0.75rem">' . htmlspecialchars($trimmed) . '</p>';
|
||||
}
|
||||
}
|
||||
?>
|
||||
</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 ?>;
|
||||
|
||||
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 src="/assets/js/server-permissions.js?v=<?= asset_version() ?>"></script>
|
||||
|
||||
@@ -92,322 +92,8 @@ $status = $db['status'] ?? 'inactive';
|
||||
</div>
|
||||
|
||||
<script>
|
||||
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/<?= $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;}
|
||||
const serverId = <?= json_encode($server['id']) ?>;
|
||||
const databases = <?= json_encode(array_column($databases, 'name')) ?>;
|
||||
</script>
|
||||
<script src="/assets/js/database-manager.js?v=<?= asset_version() ?>"></script>
|
||||
<?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>
|
||||
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 src="/assets/js/server-permissions.js?v=<?= asset_version() ?>"></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>
|
||||
|
||||
<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>
|
||||
<script src="/assets/js/server-list.js?v=<?= asset_version() ?>"></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>
|
||||
|
||||
<script>
|
||||
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/<?= $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();
|
||||
}
|
||||
});
|
||||
const serverId = <?= json_encode($server['id']) ?>;
|
||||
</script>
|
||||
<script src="/assets/js/nginx-manager.js?v=<?= asset_version() ?>"></script>
|
||||
<?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>
|
||||
|
||||
<script>
|
||||
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/<?= $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();
|
||||
});
|
||||
const serverId = <?= $server['id'] ?>;
|
||||
</script>
|
||||
<script src="/assets/js/server-processes.js?v=<?= asset_version() ?>"></script>
|
||||
|
||||
@@ -47,94 +47,9 @@ $totalPages = max(1, ceil(count($services) / $perPage));
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const allServices = <?= json_encode($services) ?>;
|
||||
const allServices = <?= json_encode($services, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;
|
||||
const perPage = <?= $perPage ?>;
|
||||
|
||||
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(); });
|
||||
const serverId = <?= $server['id'] ?>;
|
||||
</script>
|
||||
<script src="/assets/js/server-services.js?v=<?= asset_version() ?>"></script>
|
||||
|
||||
|
||||
406
views/servers/show.php
Executable file → Normal file
406
views/servers/show.php
Executable file → Normal file
@@ -304,49 +304,62 @@ $canManage = $server_role === 'owner' || $server_role === 'manager';
|
||||
</div>
|
||||
|
||||
<div id="thresholdModal" class="modal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-dialog" style="max-width:560px">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-bell"></i> Notification Thresholds</h3>
|
||||
<button type="button" class="modal-close" onclick="closeThresholdModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted" style="margin-bottom:20px">
|
||||
Notifications are sent to all users with access to this server when the agent reports values
|
||||
exceeding these thresholds. Only the server owner can modify them.
|
||||
<p class="text-muted" style="margin-bottom:24px;font-size:0.9em;line-height:1.5">
|
||||
When the agent reports values above these thresholds, all users with access to this server
|
||||
receive a notification and push alert. Only the server owner can modify them.
|
||||
</p>
|
||||
<form id="thresholdForm" onsubmit="return false">
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Resource</th>
|
||||
<th>Warning (%)</th>
|
||||
<th>Critical (%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach (['cpu' => 'CPU', 'ram' => 'RAM', 'disk' => 'Disk'] as $key => $label): ?>
|
||||
<tr>
|
||||
<td><strong><?= $label ?></strong></td>
|
||||
<td>
|
||||
<input type="number" name="<?= $key ?>_warning"
|
||||
value="<?= htmlspecialchars((string)($server["threshold_{$key}_warning"] ?? 80)) ?>"
|
||||
min="0" max="100" step="0.01" class="form-control" style="width:120px">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" name="<?= $key ?>_critical"
|
||||
value="<?= htmlspecialchars((string)($server["threshold_{$key}_critical"] ?? 95)) ?>"
|
||||
min="0" max="100" step="0.01" class="form-control" style="width:120px">
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php $resources = [
|
||||
'cpu' => ['label' => 'CPU', 'icon' => 'fas fa-microchip', 'color' => 'var(--info)'],
|
||||
'ram' => ['label' => 'RAM', 'icon' => 'fas fa-memory', 'color' => 'var(--purple)'],
|
||||
'disk' => ['label' => 'Disk', 'icon' => 'fas fa-hdd', 'color' => 'var(--warning)'],
|
||||
]; ?>
|
||||
<?php foreach ($resources as $key => $res): ?>
|
||||
<div style="background:var(--card-bg);border:1px solid var(--border);border-radius:10px;padding:16px 20px;margin-bottom:12px">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px">
|
||||
<i class="<?= $res['icon'] ?>" style="color:<?= $res['color'] ?>;font-size:1.2em;width:20px;text-align:center"></i>
|
||||
<strong style="font-size:1em"><?= $res['label'] ?></strong>
|
||||
</div>
|
||||
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||
<div style="flex:1">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:0.82em;color:var(--warning);margin-bottom:6px;font-weight:600">
|
||||
<i class="fas fa-exclamation-triangle" style="font-size:0.85em"></i> Warning
|
||||
</label>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<input type="range" name="<?= $key ?>_warning"
|
||||
value="<?= htmlspecialchars((string)($server["threshold_{$key}_warning"] ?? 80)) ?>"
|
||||
min="0" max="100" step="0.5"
|
||||
oninput="document.getElementById('<?= $key ?>_warn_val').textContent = this.value + '%'"
|
||||
style="flex:1;height:6px;-webkit-appearance:none;appearance:none;border-radius:3px;background:linear-gradient(to right, var(--success), var(--warning) 50%, var(--danger) 80%);outline:none;cursor:pointer">
|
||||
<span id="<?= $key ?>_warn_val" style="min-width:52px;font-size:0.95em;font-weight:700;color:var(--warning);text-align:right"><?= htmlspecialchars((string)($server["threshold_{$key}_warning"] ?? 80)) ?>%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:0.82em;color:var(--danger);margin-bottom:6px;font-weight:600">
|
||||
<i class="fas fa-bolt" style="font-size:0.85em"></i> Critical
|
||||
</label>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<input type="range" name="<?= $key ?>_critical"
|
||||
value="<?= htmlspecialchars((string)($server["threshold_{$key}_critical"] ?? 95)) ?>"
|
||||
min="0" max="100" step="0.5"
|
||||
oninput="document.getElementById('<?= $key ?>_crit_val').textContent = this.value + '%'"
|
||||
style="flex:1;height:6px;-webkit-appearance:none;appearance:none;border-radius:3px;background:linear-gradient(to right, var(--success), var(--warning) 50%, var(--danger) 80%);outline:none;cursor:pointer">
|
||||
<span id="<?= $key ?>_crit_val" style="min-width:52px;font-size:0.95em;font-weight:700;color:var(--danger);text-align:right"><?= htmlspecialchars((string)($server["threshold_{$key}_critical"] ?? 95)) ?>%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" onclick="saveThresholds()"><i class="fas fa-save"></i> Save</button>
|
||||
<button class="btn btn-primary" onclick="saveThresholds()"><i class="fas fa-save"></i> Save Thresholds</button>
|
||||
<button class="btn btn-secondary" onclick="closeThresholdModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -357,330 +370,9 @@ $canManage = $server_role === 'owner' || $server_role === 'manager';
|
||||
</form>
|
||||
|
||||
<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>
|
||||
const historicalData = <?= json_encode($historicalMetrics) ?>;
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
const serverId = <?= $server['id'] ?>;
|
||||
const serverName = '<?= addslashes($server['name'] ?? '') ?>';
|
||||
</script>
|
||||
<script src="/assets/js/server-show.js?v=<?= asset_version() ?>"></script>
|
||||
|
||||
@@ -189,77 +189,6 @@ $certs = $ssl['certificates'] ?? [];
|
||||
</div>
|
||||
|
||||
<script>
|
||||
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/<?= $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(); }
|
||||
});
|
||||
const serverId = <?= $server['id'] ?>;
|
||||
</script>
|
||||
<script src="/assets/js/server-ssl.js?v=<?= asset_version() ?>"></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>
|
||||
|
||||
<script>
|
||||
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/<?= $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')}});
|
||||
const serverId = <?= $server['id'] ?>;
|
||||
</script>
|
||||
<script src="/assets/js/system-users.js?v=<?= asset_version() ?>"></script>
|
||||
|
||||
@@ -71,66 +71,4 @@
|
||||
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
|
||||
</form>
|
||||
|
||||
<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>
|
||||
<script src="/assets/js/team-index.js?v=<?= asset_version() ?>"></script>
|
||||
|
||||
@@ -146,200 +146,8 @@ $availableServers = $availableServers ?? [];
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const teamId = <?= $team['id'] ?>;
|
||||
const teamName = '<?= htmlspecialchars(addslashes($team['name'] ?? '')) ?>';
|
||||
const teamDescription = '<?= htmlspecialchars(addslashes($team['description'] ?? '')) ?>';
|
||||
</script>
|
||||
<script src="/assets/js/team-show.js?v=<?= asset_version() ?>"></script>
|
||||
|
||||
@@ -41,7 +41,7 @@ $commandHistory = $commandHistory ?? [];
|
||||
\___ \ / _ \ '__\ \ / / _ \ '__| | |\/| |/ _` | '_ \ / _` |/ _` |/ _ \ '__|
|
||||
___) | __/ | \ V / __/ | | | | | (_| | | | | (_| | (_| | __/ |
|
||||
|____/ \___|_| \_/ \___|_| |_| |_|\__,_|_| |_|\__,_|\__, |\___|_|
|
||||
|___/
|
||||
|___/
|
||||
</pre>
|
||||
<p>Connected to <strong><?= htmlspecialchars($server['name']) ?></strong></p>
|
||||
<p class="text-muted">Type commands below to execute them on the remote server.</p>
|
||||
@@ -149,121 +149,5 @@ $commandHistory = $commandHistory ?? [];
|
||||
|
||||
<script>
|
||||
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 src="/assets/js/terminal.js?v=<?= asset_version() ?>"></script>
|
||||
|
||||
Reference in New Issue
Block a user