Compare commits

..

69 Commits

Author SHA1 Message Date
e8c51a2110 Merge pull request 'SEO, PWA, sidebar redesign, notifications management, admin dashboard' (#95) from fix/policy-container-width into master
Reviewed-on: #95
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-14 10:25:18 -04:00
9bb25b3c40 feat: add SEO meta tags, Open Graph, Twitter Cards, JSON-LD schema, sitemap, robots.txt 2026-06-14 10:21:12 -04:00
c76f92886c fix: replace deprecated apple-mobile-web-app-capable with mobile-web-app-capable 2026-06-14 10:19:22 -04:00
8fde59f411 Merge pull request 'fix: CSS sync, sidebar redesign, PWA support, notifications management, admin dashboard' (#94) from fix/policy-container-width into master
Reviewed-on: #94
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-14 10:16:53 -04:00
639af6371e feat: add PWA support - manifest, service worker, offline page, icons 2026-06-14 10:15:20 -04:00
16979fc8d1 Merge pull request 'fix: CSS sync, sidebar redesign, asset versioning, route/auth fixes, admin dashboard, notifications management' (#93) from fix/policy-container-width into master
Reviewed-on: #93
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-14 10:09:10 -04:00
01f1215cef fix: replace confirm() with proper delete confirmation modal 2026-06-14 10:07:21 -04:00
a25ebdcb75 feat: add resend notification action with push 2026-06-14 10:05:19 -04:00
aea0a4d189 feat: add delete notification button with confirmation in admin notifications 2026-06-14 10:03:59 -04:00
6b2300de07 fix: style custom checkbox with checkmark icon, add disabled button colors 2026-06-14 09:58:44 -04:00
e6a70c427e fix: add terms/privacy checkbox to download modal, button disabled until checked 2026-06-14 09:56:43 -04:00
448a593423 fix: add required permissions section to download modal, use direct download to avoid blob HTTPS warning 2026-06-14 09:54:30 -04:00
5453a5d856 feat: add download modal with app summary and progress bar for APK download 2026-06-14 09:52:18 -04:00
f48a5a5495 feat: improve sidebar design - rounded nav items, gradient header accent, better collapsible, refined sublist 2026-06-14 09:48:34 -04:00
0b8d9b52c8 Merge pull request 'fix: CSS sync, sidebar restore, asset versioning, route/auth fixes, admin dashboard' (#92) from fix/policy-container-width into master
Reviewed-on: #92
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-14 09:43:43 -04:00
f8ce4066fe fix: remove Notifications link from sidebar 2026-06-14 09:41:42 -04:00
e6017b80da feat: group admin sidebar items into single Administration page with action cards 2026-06-14 09:39:33 -04:00
36b0d8dfd9 fix: close notification dropdown when opening user menu and vice versa 2026-06-14 09:36:45 -04:00
8fbc0f164a fix: restore profile card layout, simplify user menu to My Account and Log out 2026-06-14 09:34:27 -04:00
379158ac93 fix: center profile page content 2026-06-14 09:33:05 -04:00
00bad074d3 fix: remove notifications link from user menu, add view-all button to notification dropdown, fix admin link 2026-06-14 09:31:27 -04:00
74cf9739d6 refactor: replace sidebar user footer with topbar dropdown menu 2026-06-14 09:26:52 -04:00
baa9ab4b7b Merge pull request 'fix/policy-container-width' (#91) from fix/policy-container-width into master
Reviewed-on: #91
2026-06-14 09:19:33 -04:00
b7bd4bfe92 fix: append command history row in real-time after execution 2026-06-14 09:12:28 -04:00
49d724cf9c fix: add Accept: application/json to terminal execute fetch 2026-06-14 09:09:40 -04:00
7233c224ce fix: set status='active' on command_history insert so history displays 2026-06-14 09:07:46 -04:00
5c802f9c1e fix: add missing closing brace to TerminalController::execute method 2026-06-14 09:05:13 -04:00
12ad0e0510 fix: cache CSRF token in View to prevent mismatch on multiple renders 2026-06-14 09:03:53 -04:00
4ce6b27f96 fix: use serverId variable and add Accept: application/json to server-show.js fetch calls 2026-06-14 09:02:13 -04:00
4ee2644be0 Merge pull request 'fix: sync CSS with refactored layout, restore sidebar, add asset versioning, fix routes/auth' (#90) from fix/policy-container-width into master
Reviewed-on: #90
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-14 08:55:35 -04:00
d1da560764 fix: sync CSS classes with refactored layout, restore missing sidebar items, add asset versioning, fix routes and auth 2026-06-14 08:54:37 -04:00
08a27fe00e Merge pull request 'fix: correct asset paths - app.css->style.css, favicon.svg->server.svg' (#89) from fix/policy-container-width into master
Reviewed-on: #89
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-14 08:09:34 -04:00
c9ab2ca98b fix: correct asset paths in layout - app.css->style.css, favicon.svg->server.svg 2026-06-14 08:09:06 -04:00
216a38bc71 Merge pull request 'refactor: extract all inline CSS/JS from PHP files into separate asset files' (#88) from fix/policy-container-width into master
Reviewed-on: #88
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-14 08:05:52 -04:00
88f3c1f964 refactor: extract inline CSS/JS from views into separate asset files (20+ files)
Phase A — HTML removed from PHP files:
- RateLimitMiddleware.php: heredoc moved to views/errors/429.php
- public/index.php: inline HTML replaced with View::error()
- AuthMiddleware.php: <script> redirect replaced with <meta refresh>

Phase B — Inline CSS/JS extracted from views:
- CSS: landing.css (459 lines), legal.css (shared by terms+privacy)
- JS: 17 new files extracted from ~20 view files
  - main.js (layout sidebar + notifications)
  - dashboard-chart.js, server-show.js, server-services.js
  - nginx-manager.js, database-manager.js
  - terminal.js, team-show.js, team-index.js
  - server-list.js, server-permissions.js (shared by edit+create)
  - server-ssl.js, server-processes.js, system-users.js
  - admin-users.js, audit-log.js, admin-notifications.js, admin-security.js
  - notifications.js

Total: -3264 lines from views, +288 lines in new assets
2026-06-14 08:04:01 -04:00
73d02e8ca4 Merge pull request 'feat: redesign 429 Too Many Requests page with live countdown' (#87) from fix/policy-container-width into master
Reviewed-on: #87
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-14 07:44:56 -04:00
142fe304c9 feat: redesign 429 Too Many Requests page with live countdown timer 2026-06-14 07:44:08 -04:00
7c74e2316e Merge pull request 'security: fix auth bypass, XSS, IDOR, command injection, session hardening, rate limiting' (#86) from fix/policy-container-width into master
Reviewed-on: #86
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-14 07:38:52 -04:00
a5eb101d98 security: fix auth bypass, XSS, IDOR, command injection, session hardening, rate limiting
- Phase 1: Require authentication for API register endpoint; restrict role creation
- Phase 2: Add $fillable whitelist to Server model to prevent mass assignment
- Phase 3: Fix XSS via escapeHtml in sidebar, JSON_HEX_TAG in script embeds
- Phase 4: Add canView() checks to TerminalController history/clearHistory
- Phase 5: escapeshellarg() on certbot params, sanitize service names, regex-based command blocklist, suppress exception messages
- Phase 6: SESSION_SECURE=true, SameSite=Strict, logout via POST+CSRF, chmod 640 .env
- Phase 7: DB-based rate limiting replacing session-based, applied to API login/register
2026-06-14 07:32:18 -04:00
40748d7e5f Merge pull request 'fix: hide scroll-indicator on mobile to prevent overlap with hero terminal' (#85) from fix/policy-container-width into master
Reviewed-on: #85
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-14 07:16:45 -04:00
c16236314e fix: hide scroll-indicator on mobile to prevent overlap with hero terminal 2026-06-14 07:16:04 -04:00
471c4a5d49 Merge pull request 'fix: widen policy page containers to 85%' (#84) from fix/policy-container-width into master
Reviewed-on: #84
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-13 21:42:46 -04:00
fee7f3b24a fix: widen policy page containers to 85% width 2026-06-13 21:42:16 -04:00
9ef4eb2856 Merge pull request 'Privacy Policy, Terms & animated landing page' (#83) from feat/privacy-terms-landing into master
Reviewed-on: #83
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-13 21:34:46 -04:00
b05c1120f0 feat: add privacy policy, terms, and animated landing page 2026-06-13 21:33:42 -04:00
6f0c750703 Merge pull request 'fix: remove redundant NotificationPoller to prevent duplicate notifications' (#82) from feat/version-update-notification into master
Reviewed-on: #82
2026-06-13 17:51:27 -04:00
3639f16757 bump to 1.8.5 (versionCode 17) 2026-06-13 17:51:03 -04:00
52947f7b21 fix: filter FCM sends by notifications_enabled preference 2026-06-13 17:48:53 -04:00
19fc37c1f3 fix: remove redundant NotificationPoller to prevent duplicate notifications 2026-06-13 17:44:37 -04:00
ba3582c77f Merge pull request 'fix: allow toggling notifications without password' (#81) from feat/version-update-notification into master
Reviewed-on: #81
2026-06-13 17:38:55 -04:00
60e0719eda fix: allow toggling notifications without password 2026-06-13 17:37:27 -04:00
2864b33dcb Merge pull request 'feat: user notification preference toggle (web + Android)' (#80) from feat/version-update-notification into master
Reviewed-on: #80
2026-06-13 17:33:53 -04:00
411c16a77d remove plan file 2026-06-13 17:31:46 -04:00
a499a1ed8b bump to 1.8.4 (versionCode 16) and rebuild APK 2026-06-13 17:31:32 -04:00
ec38afe3e9 feat: add notifications_enabled toggle for users (web + Android) 2026-06-13 17:31:07 -04:00
81f61ff96e Merge pull request 'feat: notify all users on app version update' (#79) from feat/version-update-notification into master
Reviewed-on: #79
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-13 17:13:36 -04:00
5691d8b862 Merge pull request 'feat/server-thresholds' (#78) from feat/server-thresholds into master
Reviewed-on: #78
2026-06-13 17:05:25 -04:00
a2cbb28851 chore: ignore all dotfiles/dotdirs via .gitignore 2026-06-13 17:05:00 -04:00
585d3e2e76 fix: use created_at_timestamp for Android notification dates; bump to 1.8.3 2026-06-13 17:02:22 -04:00
06d31122b8 Merge pull request 'fix: use server-side time_ago for notification timestamps' (#77) from feat/server-thresholds into master
Reviewed-on: #77
2026-06-13 16:57:20 -04:00
045f33bbc1 fix: use server-side time_ago for notification timestamps 2026-06-13 16:56:43 -04:00
020990c603 Merge pull request 'feat: notification bell with modal in top bar' (#76) from feat/server-thresholds into master
Reviewed-on: #76
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-13 16:52:44 -04:00
edd9933430 fix: move script block outside foreach to prevent const redeclaration error 2026-06-13 16:51:44 -04:00
19bae94f4c remove plan file from tracking 2026-06-13 16:48:11 -04:00
e1b6650454 feat: notification dropdown panel and /notifications page 2026-06-13 16:48:05 -04:00
25dfb517ee feat: notification bell with modal showing 10 latest notifications 2026-06-13 16:41:56 -04:00
7ed584d5d2 Merge pull request 'feat: threshold modal with range sliders and card-based layout' (#75) from feat/server-thresholds into master
Reviewed-on: #75
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
2026-06-13 16:33:41 -04:00
e92f51e36b replace number inputs with range sliders in threshold modal 2026-06-13 16:32:53 -04:00
dc34f29169 improve threshold modal design with card-based layout and color-coded inputs 2026-06-13 16:32:53 -04:00
90 changed files with 4911 additions and 2637 deletions

2
.gitignore vendored
View File

@@ -8,3 +8,5 @@ android/build/
android/app/build/
android/local.properties
config/firebase-service-account.json
.*
!.gitignore

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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,
)

View File

@@ -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 }
}
}

View File

@@ -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 }
}
}

View File

@@ -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()
}
}
}

View File

@@ -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 }
}
}

View File

@@ -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"

View File

@@ -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' => [

View 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.');

View 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;

View 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; }
}

View 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; }
}

View File

@@ -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);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View 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'));
}

View 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);
});
}

View 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
View 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
========================================================================== */

View 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();
}

View 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);

View 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
View 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);
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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();
}
});

View 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(){});
}

View 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);
}

View 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;
}

View 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();
});

View 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,"&#39;") + '\'>' + 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(); });

View 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;
});
}

View 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(); }
});

View 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')}});

View 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();
}
}

View 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); }
});
}

View 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();

View File

@@ -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
View 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
View File

@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: https://servermanager.devlab.lat/sitemap.xml

23
public/sitemap.xml Normal file
View 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
View 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.

View File

@@ -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']);

View File

@@ -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');
}
}

View File

@@ -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'],

View File

@@ -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'];

View File

@@ -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,
]);
}
}

View File

@@ -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()]);
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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,
];
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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'),
]);
}

View File

@@ -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]);
}
}

View File

@@ -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']);

View File

@@ -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;

View File

@@ -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
View 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>

View File

@@ -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()">&times;</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
View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -154,153 +154,16 @@ $aggregatedMetrics = $aggregatedMetrics ?? [];
<?= htmlspecialchars($activity['actor_name'] ?? $activity['username'] ?? 'System') ?>
&middot; <?= 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
View 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
View 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>

View File

@@ -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>

View File

@@ -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">&times;</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()">&times;</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 &amp; 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>

View 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 &middot; <?= $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
View 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>&copy; <?= 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
View 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
View 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
View 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>

View File

@@ -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
View 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
View 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
View File

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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
View 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>

View File

@@ -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,"&#39;") + '\'>' + 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
View 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()">&times;</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>

View File

@@ -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
View File

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>