diff --git a/android-yumee-panels/app/build.gradle.kts b/android-yumee-panels/app/build.gradle.kts new file mode 100644 index 0000000..3ea124a --- /dev/null +++ b/android-yumee-panels/app/build.gradle.kts @@ -0,0 +1,86 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.google.devtools.ksp") +} + +android { + namespace = "com.yumee.panels" + compileSdk = 34 + + defaultConfig { + applicationId = "com.yumee.panels" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.8" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val compose_version = "1.5.4" + val room_version = "2.6.1" + val work_version = "2.9.0" + + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.activity:activity-compose:1.8.2") + implementation(platform("androidx.compose:compose-bom:2023.10.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + + // Room + implementation("androidx.room:room-runtime:$room_version") + implementation("androidx.room:room-ktx:$room_version") + ksp("androidx.room:room-compiler:$room_version") + + // WorkManager + implementation("androidx.work:work-runtime-ktx:$work_version") + + // Network + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.google.code.gson:gson:2.10.1") + + // Lifecycle + implementation("androidx.lifecycle:lifecycle-service:2.7.0") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/android-yumee-panels/app/src/main/AndroidManifest.xml b/android-yumee-panels/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0d0f015 --- /dev/null +++ b/android-yumee-panels/app/src/main/AndroidManifest.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/MainActivity.kt b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/MainActivity.kt new file mode 100644 index 0000000..b8229d4 --- /dev/null +++ b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/MainActivity.kt @@ -0,0 +1,86 @@ +package com.yumee.panels + +import android.content.* +import android.os.* +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.unit.dp +import androidx.room.Room +import com.yumee.panels.data.local.* +import com.yumee.panels.service.MonitorService +import com.yumee.panels.ui.theme.* +import com.yumee.panels.ui.components.* +import kotlinx.coroutines.flow.collect + +class MainActivity : ComponentActivity() { + private lateinit var db: YumeeDatabase + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + db = Room.databaseBuilder(applicationContext, YumeeDatabase::class.java, "yumee_db").build() + + setContent { + YumeePanelsTheme { + DashboardScreen(db) { + val intent = Intent(this, MonitorService::class.java) + startForegroundService(intent) + } + } + } + } +} + +@Composable +fun DashboardScreen(db: YumeeDatabase, onStartService: () -> Unit) { + val monitors by db.monitorDao().getAllMonitors().collectAsState(initial = emptyList()) + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column(modifier = Modifier.padding(16.dp)) { + Text("YUMEE PANELS", style = MaterialTheme.typography.headlineLarge, color = NeonWhite) + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onStartService, + colors = ButtonDefaults.buttonColors(containerColor = NeonWhite, contentColor = DarkerBlue) + ) { + Text("START MONITORING SERVICE") + } + + Spacer(modifier = Modifier.height(24.dp)) + + LazyColumn { + items(monitors.size) { index -> + MonitorItem(monitors[index]) + } + } + } + } +} + +@Composable +fun MonitorItem(monitor: Monitor) { + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + colors = CardDefaults.cardColors(containerColor = DarkerBlue) + ) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + BatteryHealthBar( + isOnline = monitor.isOnline, + health = if (monitor.isOnline) 100 else 0, + modifier = Modifier.size(width = 40.dp, height = 80.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text(monitor.name, style = MaterialTheme.typography.titleLarge, color = NeonWhite) + Text(monitor.url, style = MaterialTheme.typography.bodySmall, color = NeonWhite.copy(alpha = 0.7f)) + Text("Status: ${if (monitor.isOnline) "ONLINE" else "OFFLINE"}", color = if (monitor.isOnline) NeonGreen else NeonRed) + Text("Response: ${monitor.lastResponseTime}ms | Code: ${monitor.lastStatusCode}", color = NeonWhite) + } + } + } +} diff --git a/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/YumeeApp.kt b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/YumeeApp.kt new file mode 100644 index 0000000..7bf35d3 --- /dev/null +++ b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/YumeeApp.kt @@ -0,0 +1,18 @@ +package com.yumee.panels + +import android.app.Application +import androidx.room.Room +import com.yumee.panels.data.local.YumeeDatabase + +class YumeeApp : Application() { + companion object { + lateinit var database: YumeeDatabase + } + + override fun onCreate() { + super.onCreate() + database = Room.databaseBuilder(this, YumeeDatabase::class.java, "yumee_db") + .fallbackToDestructiveMigration() + .build() + } +} diff --git a/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/data/local/Database.kt b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/data/local/Database.kt new file mode 100644 index 0000000..f1a02b0 --- /dev/null +++ b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/data/local/Database.kt @@ -0,0 +1,39 @@ +package com.yumee.panels.data.local + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface MonitorDao { + @Query("SELECT * FROM monitors") + fun getAllMonitors(): Flow> + + @Query("SELECT * FROM monitors WHERE isActive = 1") + suspend fun getActiveMonitors(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMonitor(monitor: Monitor): Long + + @Update + suspend fun updateMonitor(monitor: Monitor) + + @Delete + suspend fun deleteMonitor(monitor: Monitor) + + @Query("SELECT * FROM monitor_logs WHERE monitorId = :monitorId ORDER BY timestamp DESC LIMIT 100") + fun getLogsForMonitor(monitorId: Long): Flow> + + @Insert + suspend fun insertLog(log: MonitorLog) + + @Query("SELECT COUNT(*) FROM monitor_logs WHERE monitorId = :monitorId AND isOnline = 1") + suspend fun getOnlineCount(monitorId: Long): Int + + @Query("SELECT COUNT(*) FROM monitor_logs WHERE monitorId = :monitorId") + suspend fun getTotalCount(monitorId: Long): Int +} + +@Database(entities = [Monitor::class, MonitorLog::class], version = 1) +abstract class YumeeDatabase : RoomDatabase() { + abstract fun monitorDao(): MonitorDao +} diff --git a/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/data/local/Entities.kt b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/data/local/Entities.kt new file mode 100644 index 0000000..21a2111 --- /dev/null +++ b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/data/local/Entities.kt @@ -0,0 +1,29 @@ +package com.yumee.panels.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "monitors") +data class Monitor( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + val url: String, + val intervalSeconds: Int = 30, + val isActive: Boolean = true, + val lastStatusCode: Int = 0, + val lastResponseTime: Long = 0, + val lastChecked: Long = 0, + val isOnline: Boolean = false, + val uptimePercent: Float = 100f +) + +@Entity(tableName = "monitor_logs") +data class MonitorLog( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val monitorId: Long, + val timestamp: Long, + val statusCode: Int, + val responseTime: Long, + val isOnline: Boolean, + val message: String? = null +) diff --git a/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/service/BootReceiver.kt b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/service/BootReceiver.kt new file mode 100644 index 0000000..0092135 --- /dev/null +++ b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/service/BootReceiver.kt @@ -0,0 +1,14 @@ +package com.yumee.panels.service + +import android.content.* +import com.yumee.panels.service.MonitorService + +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED || + intent.action == "android.intent.action.QUICKBOOT_POWERON") { + val serviceIntent = Intent(context, MonitorService::class.java) + context.startForegroundService(serviceIntent) + } + } +} diff --git a/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/service/MonitorService.kt b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/service/MonitorService.kt new file mode 100644 index 0000000..99d21c5 --- /dev/null +++ b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/service/MonitorService.kt @@ -0,0 +1,155 @@ +package com.yumee.panels.service + +import android.app.* +import android.content.* +import android.net.ConnectivityManager +import android.os.* +import androidx.core.app.NotificationCompat +import androidx.room.Room +import com.yumee.panels.MainActivity +import com.yumee.panels.R +import com.yumee.panels.data.local.* +import kotlinx.coroutines.* +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit + +class MonitorService : Service() { + private val serviceJob = SupervisorJob() + private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) + private lateinit var db: YumeeDatabase + private val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() + + private var wakeLock: PowerManager.WakeLock? = null + + companion object { + const val CHANNEL_ID = "monitor_service_channel" + const val NOTIFICATION_ID = 101 + } + + override fun onCreate() { + super.onCreate() + db = Room.databaseBuilder(applicationContext, YumeeDatabase::class.java, "yumee_db").build() + createNotificationChannel() + acquireWakeLock() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val notification = createNotification("Uptime Monitoring Running") + startForeground(NOTIFICATION_ID, notification) + + startMonitoring() + return START_STICKY + } + + private fun startMonitoring() { + serviceScope.launch { + while (isActive) { + val monitors = db.monitorDao().getActiveMonitors() + monitors.forEach { monitor -> + // Simplified: In a real app, track individual interval timers + // Here we check if it's time to ping based on lastChecked + if (System.currentTimeMillis() - monitor.lastChecked >= monitor.intervalSeconds * 1000) { + launch { pingMonitor(monitor) } + } + } + delay(1000) // check every second + } + } + } + + private suspend fun pingMonitor(monitor: Monitor) { + val startTime = System.currentTimeMillis() + var isOnline = false + var statusCode = 0 + var responseTime = 0L + var message: String? = null + + try { + val request = Request.Builder().url(monitor.url).get().build() + client.newCall(request).execute().use { response -> + statusCode = response.code + isOnline = statusCode == 200 + responseTime = System.currentTimeMillis() - startTime + } + } catch (e: Exception) { + message = e.message + isOnline = false + responseTime = System.currentTimeMillis() - startTime + } + + // Update database + val updatedMonitor = monitor.copy( + lastStatusCode = statusCode, + lastResponseTime = responseTime, + lastChecked = System.currentTimeMillis(), + isOnline = isOnline + ) + db.monitorDao().updateMonitor(updatedMonitor) + + db.monitorDao().insertLog(MonitorLog( + monitorId = monitor.id, + timestamp = System.currentTimeMillis(), + statusCode = statusCode, + responseTime = responseTime, + isOnline = isOnline, + message = message + )) + + if (!isOnline) { + sendAlert(monitor.name, "URL is down! Code: $statusCode") + } + } + + private fun sendAlert(name: String, message: String) { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val alertNotification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) // Placeholder + .setContentTitle("Alert: $name") + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + notificationManager.notify(System.currentTimeMillis().toInt(), alertNotification) + } + + private fun createNotification(text: String): Notification { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Yumee Panels") + .setContentText(text) + .setSmallIcon(R.drawable.ic_launcher_foreground) // Placeholder + .setOngoing(true) + .setContentIntent(pendingIntent) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + CHANNEL_ID, "Monitoring Service Channel", + NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(serviceChannel) + } + } + + private fun acquireWakeLock() { + val powerManager = getSystemService(POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Yumee::MonitorLock") + wakeLock?.acquire() + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + serviceJob.cancel() + wakeLock?.let { if (it.isHeld) it.release() } + super.onDestroy() + } +} diff --git a/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/ui/components/Visuals.kt b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/ui/components/Visuals.kt new file mode 100644 index 0000000..210889b --- /dev/null +++ b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/ui/components/Visuals.kt @@ -0,0 +1,65 @@ +package com.yumee.panels.ui.components + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.* +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.* +import androidx.compose.ui.unit.dp +import com.yumee.panels.ui.theme.* + +@Composable +fun BatteryHealthBar( + isOnline: Boolean, + health: Int, // 0 to 100 + modifier: Modifier = Modifier +) { + val color = if (isOnline) NeonWhite else NeonRed + + Column(modifier = modifier) { + Box( + modifier = Modifier + .width(40.dp) + .height(80.dp) + .border(2.dp, color, RoundedCornerShape(4.dp)) + .padding(4.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(health / 100f) + .align(Alignment.BottomCenter) + .background(color) + ) + } + } +} + +@Composable +fun CandlestickChart( + logs: List, + modifier: Modifier = Modifier +) { + Canvas(modifier = modifier) { + val width = size.width + val height = size.height + val barWidth = width / logs.size.coerceAtLeast(1) + val maxResponseTime = logs.maxOfOrNull { it.responseTime }?.coerceAtLeast(100) ?: 1000 + + logs.forEachIndexed { index, log -> + val color = if (log.isOnline) NeonWhite else NeonRed + val barHeight = (log.responseTime.toFloat() / maxResponseTime) * height + val x = index * barWidth + + drawRect( + color = color, + topLeft = Offset(x + 2, height - barHeight), + size = androidx.compose.ui.geometry.Size(barWidth - 4, barHeight), + alpha = 0.8f + ) + } + } +} diff --git a/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/ui/theme/Theme.kt b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/ui/theme/Theme.kt new file mode 100644 index 0000000..b74c655 --- /dev/null +++ b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/ui/theme/Theme.kt @@ -0,0 +1,37 @@ +package com.yumee.panels.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +val DarkPastelBlue = Color(0xFF1B263B) +val DarkerBlue = Color(0xFF0D1B2A) +val NeonWhite = Color(0xFFE0E1DD) +val NeonRed = Color(0xFFFF4D4D) +val NeonGreen = Color(0xFF00FFCC) + +private val DarkColorScheme = darkColorScheme( + primary = NeonWhite, + secondary = NeonWhite, + tertiary = NeonWhite, + background = DarkPastelBlue, + surface = DarkerBlue, + onPrimary = DarkerBlue, + onSecondary = DarkerBlue, + onTertiary = DarkerBlue, + onBackground = NeonWhite, + onSurface = NeonWhite, +) + +@Composable +fun YumeePanelsTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = DarkColorScheme, + typography = Typography, + content = content + ) +} diff --git a/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/ui/theme/Type.kt b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/ui/theme/Type.kt new file mode 100644 index 0000000..6d8b983 --- /dev/null +++ b/android-yumee-panels/app/src/main/kotlin/com/yumee/panels/ui/theme/Type.kt @@ -0,0 +1,29 @@ +package com.yumee.panels.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + letterSpacing = 0.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 20.sp, + letterSpacing = 0.sp + ) +) diff --git a/android-yumee-panels/app/src/main/res/values/strings.xml b/android-yumee-panels/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..dd1032d --- /dev/null +++ b/android-yumee-panels/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Yumee Panels + diff --git a/android-yumee-panels/app/src/main/res/values/themes.xml b/android-yumee-panels/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..871f1fd --- /dev/null +++ b/android-yumee-panels/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + diff --git a/android-yumee-panels/build.gradle.kts b/android-yumee-panels/build.gradle.kts new file mode 100644 index 0000000..07c50fe --- /dev/null +++ b/android-yumee-panels/build.gradle.kts @@ -0,0 +1,6 @@ +// Root build.gradle.kts +plugins { + id("com.android.application") version "8.2.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false + id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false +} diff --git a/android-yumee-panels/settings.gradle.kts b/android-yumee-panels/settings.gradle.kts new file mode 100644 index 0000000..11903cb --- /dev/null +++ b/android-yumee-panels/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "Yumee Panels" +include(":app") diff --git a/api/monitor.php b/api/monitor.php new file mode 100644 index 0000000..91abad6 --- /dev/null +++ b/api/monitor.php @@ -0,0 +1,77 @@ +query("SELECT setting_value FROM settings WHERE setting_key = 'worker_heartbeat'"); + $lastHeartbeat = (int)$stmtStatus->fetchColumn(); + $workerActive = (time() - $lastHeartbeat < 10); + + $stmt = db()->query("SELECT * FROM monitors ORDER BY created_at DESC"); + $monitors = $stmt->fetchAll(); + + foreach ($monitors as &$m) { + // Fetch last 30 logs + $logStmt = db()->prepare("SELECT status_code, latency, checked_at FROM monitor_logs WHERE monitor_id = ? ORDER BY checked_at DESC LIMIT 30"); + $logStmt->execute([$m['id']]); + $m['history'] = array_reverse($logStmt->fetchAll()); + } + + echo json_encode([ + 'success' => true, + 'data' => $monitors, + 'worker_active' => $workerActive + ]); + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } + break; + + case 'add': + $data = json_decode(file_get_contents('php://input'), true); + $name = $data['name'] ?? ''; + $url = $data['url'] ?? ''; + $interval = (int)($data['interval'] ?? 1); + + if (empty($name) || empty($url)) { + echo json_encode(['success' => false, 'error' => 'Name and URL are required.']); + exit; + } + + try { + $stmt = db()->prepare("INSERT INTO monitors (name, url, interval_min) VALUES (?, ?, ?)"); + $stmt->execute([$name, $url, $interval]); + echo json_encode(['success' => true]); + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } + break; + + case 'delete': + $id = $_GET['id'] ?? null; + if (!$id) { + echo json_encode(['success' => false, 'error' => 'Missing ID.']); + exit; + } + try { + $stmt = db()->prepare("DELETE FROM monitors WHERE id = ?"); + $stmt->execute([$id]); + // Also cleanup logs + $stmt = db()->prepare("DELETE FROM monitor_logs WHERE monitor_id = ?"); + $stmt->execute([$id]); + echo json_encode(['success' => true]); + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } + break; + + default: + echo json_encode(['success' => false, 'error' => 'Invalid action.']); + break; +} \ No newline at end of file diff --git a/assets/css/custom.css b/assets/css/custom.css index 50e0502..c2c94b7 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,302 +1,207 @@ +:root { + --bg-dark: #05070a; + --panel-bg: rgba(10, 11, 30, 0.7); + --neon-cyan: #00f3ff; + --neon-magenta: #ff00ff; + --text-main: #e0e0e0; + --status-online: #00ff88; + --status-offline: #ff4d4d; + --font-inter: 'Inter', sans-serif; +} + body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; + background-color: var(--bg-dark); + color: var(--text-main); + font-family: var(--font-inter); margin: 0; + overflow-x: hidden; min-height: 100vh; } -.main-wrapper { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; -} - -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } -} - -.chat-container { - width: 100%; - max-width: 600px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; - display: flex; - flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); - overflow: hidden; -} - -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); - font-weight: 700; - font-size: 1.1rem; - display: flex; - justify-content: space-between; - align-items: center; -} - -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; -} - -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); -} - -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); - color: #fff; - border-bottom-right-radius: 4px; -} - -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; -} - -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); -} - -.chat-input-area form { - display: flex; - gap: 0.75rem; -} - -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; -} - -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); -} - -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; -} - -.chat-input-area button:hover { - background: #000; - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); -} - -/* Background Animations */ -.bg-animations { +/* Background Stars/Sparkle Effect */ +body::before { + content: ''; position: fixed; top: 0; left: 0; width: 100%; height: 100%; - z-index: 0; - overflow: hidden; - pointer-events: none; + background: radial-gradient(circle at 50% 50%, rgba(0, 243, 255, 0.05) 0%, transparent 70%), + url('data:image/svg+xml,'); + background-repeat: repeat; + z-index: -1; + animation: twinkle 5s linear infinite; } -.blob { - position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); +@keyframes twinkle { + 0% { opacity: 0.5; } + 50% { opacity: 0.8; } + 100% { opacity: 0.5; } } -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); +.navbar { + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--neon-cyan); + box-shadow: 0 0 15px rgba(0, 243, 255, 0.3); } -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; -} - -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; -} - -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } - 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } - 100% { transform: translate(0, 0) rotate(360deg) scale(1); } -} - -.admin-link { - font-size: 14px; - color: #fff; - text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.admin-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* Admin Styles */ -.admin-container { - max-width: 900px; - margin: 3rem auto; - padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - box-shadow: 0 20px 50px rgba(0,0,0,0.15); - border: 1px solid rgba(255, 255, 255, 0.4); - position: relative; - z-index: 1; -} - -.admin-container h1 { - margin-top: 0; - color: #212529; - font-weight: 800; -} - -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; -} - -.table th { - background: transparent; - border: none; - padding: 1rem; - color: #6c757d; - font-weight: 600; +.navbar-brand { + font-weight: 700; + color: var(--neon-cyan) !important; text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; + letter-spacing: 2px; + text-shadow: 0 0 10px var(--neon-cyan); } -.table td { - background: #fff; - padding: 1rem; - border: none; +.card { + background: var(--panel-bg); + border: 1px solid rgba(0, 243, 255, 0.2); + border-radius: 12px; + backdrop-filter: blur(15px); + transition: transform 0.3s ease, box-shadow 0.3s ease; } -.table tr td:first-child { border-radius: 12px 0 0 12px; } -.table tr td:last-child { border-radius: 0 12px 12px 0; } - -.form-group { - margin-bottom: 1.25rem; +.card:hover { + transform: translateY(-5px); + box-shadow: 0 0 20px rgba(0, 243, 255, 0.2); } -.form-group label { - display: block; - margin-bottom: 0.5rem; +.neon-btn { + background: transparent; + border: 1px solid var(--neon-cyan); + color: var(--neon-cyan); + padding: 8px 20px; + border-radius: 50px; + text-transform: uppercase; + font-size: 0.8rem; font-weight: 600; - font-size: 0.9rem; + transition: all 0.3s ease; + box-shadow: 0 0 10px rgba(0, 243, 255, 0.2); } +.neon-btn:hover { + background: var(--neon-cyan); + color: var(--bg-dark); + box-shadow: 0 0 20px var(--neon-cyan); +} + +.monitor-card { + padding: 1.5rem; +} + +.status-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + margin-right: 8px; + box-shadow: 0 0 8px currentColor; +} + +.status-online { color: var(--status-online); background-color: var(--status-online); } +.status-offline { color: var(--status-offline); background-color: var(--status-offline); } +.status-unknown { color: #888; background-color: #888; } + .form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(0, 243, 255, 0.2); + color: white; } .form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); + background: rgba(255, 255, 255, 0.1); + border-color: var(--neon-cyan); + color: white; + box-shadow: 0 0 10px rgba(0, 243, 255, 0.3); +} + +.stats-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--neon-cyan); +} + +.stats-label { + font-size: 0.75rem; + text-transform: uppercase; + opacity: 0.7; +} + +/* Battery Bars */ +.health-bars { + display: flex; + gap: 2px; + height: 24px; + align-items: flex-end; +} + +.health-segment { + flex: 1; + height: 100%; + border-radius: 1px; + min-width: 3px; + transition: all 0.3s ease; +} + +.segment-up { + background: var(--status-online); + box-shadow: 0 0 5px var(--status-online); +} + +.segment-down { + background: var(--status-offline); + box-shadow: 0 0 5px var(--status-offline); +} + +.segment-none { + background: rgba(255, 255, 255, 0.1); +} + +/* Candlestick Realtime Chart */ +.sparkline-container { + width: 100%; + height: 60px; + margin: 15px 0; + position: relative; + border-bottom: 1px solid rgba(0, 243, 255, 0.1); +} + +.sparkline-canvas { + width: 100%; + height: 100%; +} + +.pulse-mini { + animation: pulse-mini 1s infinite alternate; +} + +@keyframes pulse-mini { + from { opacity: 1; transform: scale(1); } + to { opacity: 0.7; transform: scale(0.95); } +} + +.pulse-dot { + display: inline-block; + width: 8px; + height: 8px; + background-color: #ff00ff; + border-radius: 50%; + margin-right: 5px; + box-shadow: 0 0 8px #ff00ff; + animation: pulse-dot 1s infinite; +} + +@keyframes pulse-dot { + 0% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.5); opacity: 0.5; } + 100% { transform: scale(1); opacity: 1; } +} + +.neon-glow-green { + filter: drop-shadow(0 0 5px var(--status-online)); +} + +.neon-glow-red { + filter: drop-shadow(0 0 5px var(--status-offline)); } \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js index d349598..22615a1 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,288 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + const monitorList = document.getElementById('monitor-list'); + const addForm = document.getElementById('add-monitor-form'); + const serviceStatus = document.getElementById('service-status'); + let isFetching = false; - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; + const fetchMonitors = async (action = 'list') => { + if (isFetching) return; + isFetching = true; + + try { + const response = await fetch(`api/monitor.php?action=${action}`); + const result = await response.json(); + if (result.success) { + renderMonitors(result.data); + updateSummary(result.data); + updateServiceStatus(result.worker_active); + } + } catch (error) { + console.error('Error fetching monitors:', error); + } finally { + isFetching = false; + } }; - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; + const updateServiceStatus = (active) => { + if (!serviceStatus) return; + const text = serviceStatus.querySelector('.status-text'); + if (active) { + serviceStatus.classList.remove('border-danger'); + serviceStatus.classList.add('border-cyan'); + text.innerText = 'Online'; + text.className = 'status-text text-success'; + } else { + serviceStatus.classList.remove('border-cyan'); + serviceStatus.classList.add('border-danger'); + text.innerText = 'Offline (Restarting...)'; + text.className = 'status-text text-danger'; + } + }; - appendMessage(message, 'visitor'); - chatInput.value = ''; + const updateSummary = (monitors) => { + const total = monitors.length; + const online = monitors.filter(m => m.last_status >= 200 && m.last_status < 400).length; + const offline = total - online; + + const onlineEl = document.getElementById('summary-online'); + const offlineEl = document.getElementById('summary-offline'); + const totalEl = document.getElementById('summary-total'); + + if (onlineEl) onlineEl.innerText = online; + if (offlineEl) offlineEl.innerText = offline; + if (totalEl) totalEl.innerText = total; + }; + + const renderMonitors = (monitors) => { + if (monitors.length === 0) { + monitorList.innerHTML = '
No URLs added yet. Start by adding one!
'; + return; + } + + const currentCount = monitorList.querySelectorAll('.monitor-card').length; + + if (currentCount !== monitors.length) { + monitorList.innerHTML = monitors.map(m => createMonitorHTML(m)).join(''); + attachListeners(); + } else { + monitors.forEach(m => updateMonitorDOM(m)); + } + + monitors.forEach(m => { + const canvas = document.getElementById(`sparkline-${m.id}`); + if (canvas) drawSparkline(canvas, m.history || []); + }); + }; + + const createMonitorHTML = (m) => { + const history = m.history || []; + const segments = Array(30).fill('
'); + history.forEach((h, idx) => { + const isUp = h.status_code >= 200 && h.status_code < 400; + const segIdx = segments.length - history.length + idx; + segments[segIdx] = `
`; + }); + + return ` +
+
+
+
${m.name}
+
+ + ${m.last_status || '---'} +
+
+

${m.url}

+ +
Health Status (Real-time)
+
+ ${segments.join('')} +
+ +
+ +
+ +
+
+
Latency
+
${m.last_latency || 0}ms
+
+
+
Live Feed
+
LIVE
+
+
+ +
+ + +
+
+
+ `; + }; + + const updateMonitorDOM = (m) => { + const card = document.getElementById(`monitor-${m.id}`); + if (!card) return; + + const indicator = card.querySelector('.status-indicator'); + indicator.className = `status-indicator ${getStatusClass(m.last_status)}`; + + const statusText = card.querySelector('.status-text'); + statusText.innerText = m.last_status || '---'; + + const latencyVal = card.querySelector('.latency-value'); + latencyVal.innerText = (m.last_latency || 0) + 'ms'; + + const history = m.history || []; + const barContainer = card.querySelector('.health-bars'); + const segments = Array(30).fill('
'); + history.forEach((h, idx) => { + const isUp = h.status_code >= 200 && h.status_code < 400; + const segIdx = segments.length - history.length + idx; + segments[segIdx] = `
`; + }); + barContainer.innerHTML = segments.join(''); + }; + + const getStatusClass = (code) => { + if (!code || code === 0) return 'status-unknown'; + if (code >= 200 && code < 400) return 'status-online'; + return 'status-offline'; + }; + + const drawSparkline = (canvas, data) => { + const ctx = canvas.getContext('2d'); + const w = canvas.width; + const h = canvas.height; + ctx.clearRect(0, 0, w, h); + + if (data.length < 2) return; + + const points = data.length; + const xStep = w / (points - 1); + const latencies = data.map(d => parseInt(d.latency) || 0); + const maxLat = Math.max(...latencies, 500); + + const getColor = (status) => (status >= 200 && status < 400) ? '#00ff88' : '#ff4d4d'; + + ctx.beginPath(); + ctx.moveTo(0, h); + data.forEach((d, idx) => { + const x = idx * xStep; + const val = parseInt(d.latency) || 0; + const y = h - (val / maxLat) * (h * 0.7) - 15; + ctx.lineTo(x, y); + }); + ctx.lineTo(w, h); + const fillGrad = ctx.createLinearGradient(0, 0, 0, h); + fillGrad.addColorStop(0, 'rgba(0, 243, 255, 0.15)'); + fillGrad.addColorStop(1, 'rgba(0, 243, 255, 0)'); + ctx.fillStyle = fillGrad; + ctx.fill(); + + ctx.lineWidth = 3; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + + for (let i = 0; i < data.length - 1; i++) { + const d1 = data[i]; + const d2 = data[i + 1]; + const x1 = i * xStep; + const y1 = h - (parseInt(d1.latency) || 0) / maxLat * (h * 0.7) - 15; + const x2 = (i + 1) * xStep; + const y2 = h - (parseInt(d2.latency) || 0) / maxLat * (h * 0.7) - 15; + + const color = getColor(d2.status_code); + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.shadowBlur = 8; + ctx.shadowColor = color; + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + } + }; + + const attachListeners = () => { + document.querySelectorAll('.ping-btn').forEach(btn => { + const newBtn = btn.cloneNode(true); + btn.parentNode.replaceChild(newBtn, btn); + newBtn.addEventListener('click', () => pingMonitor(newBtn.dataset.id, newBtn)); + }); + document.querySelectorAll('.delete-btn').forEach(btn => { + const newBtn = btn.cloneNode(true); + btn.parentNode.replaceChild(newBtn, btn); + newBtn.addEventListener('click', () => deleteMonitor(newBtn.dataset.id)); + }); + }; + + const pingMonitor = async (id, btn) => { + btn.disabled = true; + btn.innerHTML = ''; + try { + // Using list to refresh since worker is active + await fetchMonitors('list'); + } finally { + setTimeout(() => { + btn.disabled = false; + btn.innerHTML = ' Test'; + }, 500); + } + }; + + const deleteMonitor = async (id) => { + if (!confirm('Are you sure you want to delete this monitor?')) return; + try { + const response = await fetch(`api/monitor.php?action=delete&id=${id}`); + const result = await response.json(); + if (result.success) { + fetchMonitors('list'); + } + } catch (error) { + console.error('Delete error:', error); + } + }; + + addForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const submitBtn = addForm.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + + const formData = { + name: document.getElementById('mon-name').value, + url: document.getElementById('mon-url').value, + interval: document.getElementById('mon-interval').value + }; try { - const response = await fetch('api/chat.php', { + const response = await fetch('api/monitor.php?action=add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) + body: JSON.stringify(formData) }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); + const result = await response.json(); + if (result.success) { + addForm.reset(); + bootstrap.Modal.getInstance(document.getElementById('addMonitorModal')).hide(); + fetchMonitors('list'); + } else { + alert('Error adding monitor: ' + result.error); + } } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + console.error('Add error:', error); + } finally { + submitBtn.disabled = false; } }); -}); + + fetchMonitors('list'); + setInterval(() => fetchMonitors('ping_all'), 1000); +}); \ No newline at end of file diff --git a/assets/pasted-20260225-062901-2d735e56.jpg b/assets/pasted-20260225-062901-2d735e56.jpg new file mode 100644 index 0000000..196e944 Binary files /dev/null and b/assets/pasted-20260225-062901-2d735e56.jpg differ diff --git a/index.php b/index.php index 7205f3d..a372d9a 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,109 @@ - - + - - - New Style - - - - - - - - - - - - - - - - - - - + + + Yumee Panels - Real-time Uptime Monitor + + + + + + -
-
-

Analyzing your requirements and generating your website…

-
- Loading… -
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

+ +
-
- Page updated: (UTC) -
+ + +
+ +
+
+
+
Online Monitors
+
0
+
+
+
+
+
Offline / Error
+
0
+
+
+
+
+
Total URLs
+
0
+
+
+
+ + +
+ +
+
+ Loading... +
+
+
+
+ + + + + + - + \ No newline at end of file diff --git a/worker.lock b/worker.lock new file mode 100644 index 0000000..e69de29 diff --git a/worker.php b/worker.php new file mode 100644 index 0000000..ba827b2 --- /dev/null +++ b/worker.php @@ -0,0 +1,130 @@ +query("SELECT setting_value FROM settings WHERE setting_key = 'telegram_token'"); + $token = $stmt->fetchColumn(); + $stmt = db()->query("SELECT setting_value FROM settings WHERE setting_key = 'telegram_chat_id'"); + $chatId = $stmt->fetchColumn(); + + if (!$token || !$chatId) return false; + + $url = "https://api.telegram.org/bot$token/sendMessage"; + $data = [ + 'chat_id' => $chatId, + 'text' => "🔔 *Uptime Alert*\n" . $text, + 'parse_mode' => 'Markdown' + ]; + + $options = [ + 'http' => [ + 'header' => "Content-type: application/x-www-form-urlencoded\r\n", + 'method' => 'POST', + 'content' => http_build_query($data), + ], + ]; + $context = stream_context_create($options); + return file_get_contents($url, false, $context); + } catch (Exception $e) { + return false; + } +} + +echo "Yumee Panels Background Worker Started (v1.1)... +"; + +while (true) { + try { + $start_time = microtime(true); + + // Fetch monitors + $stmt = db()->query("SELECT * FROM monitors"); + $monitors = $stmt->fetchAll(); + + if (!empty($monitors)) { + $mh = curl_multi_init(); + $handles = []; + + foreach ($monitors as $m) { + $ch = curl_init($m['url']); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_USERAGENT, 'YumeePinger-Worker/1.1'); + curl_multi_add_handle($mh, $ch); + $handles[$m['id']] = ['ch' => $ch, 'data' => $m]; + } + + $running = null; + do { + curl_multi_exec($mh, $running); + usleep(5000); + } while ($running); + + foreach ($handles as $id => $h) { + $ch = $h['ch']; + $m = $h['data']; + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $latency = round(curl_getinfo($ch, CURLINFO_TOTAL_TIME) * 1000); + curl_multi_remove_handle($mh, $ch); + curl_close($ch); + + $is_up = ($http_code >= 200 && $http_code < 400); + $prev_status = $m['last_status']; + $prev_notified = $m['last_notified_status']; + + // Update monitor + $upd = db()->prepare("UPDATE monitors SET last_status = ?, last_latency = ? WHERE id = ?"); + $upd->execute([$http_code, $latency, $id]); + + // Log entry + $log = db()->prepare("INSERT INTO monitor_logs (monitor_id, status_code, latency) VALUES (?, ?, ?)"); + $log->execute([$id, $http_code, $latency]); + + // Notification logic: only notify on transition + if ($prev_notified !== null) { + $prev_was_up = ($prev_notified >= 200 && $prev_notified < 400); + if ($is_up !== $prev_was_up) { + $status_str = $is_up ? "✅ UP" : "❌ DOWN"; + $msg = "Monitor *{$m['name']}* is now $status_str\nURL: {$m['url']}\nStatus: $http_code\nLatency: {$latency}ms"; + sendTelegramMessage($msg); + + // Update last notified status + $upd_notify = db()->prepare("UPDATE monitors SET last_notified_status = ? WHERE id = ?"); + $upd_notify->execute([$http_code, $id]); + } + } else { + // Initial notification status set + $upd_notify = db()->prepare("UPDATE monitors SET last_notified_status = ? WHERE id = ?"); + $upd_notify->execute([$http_code, $id]); + } + } + curl_multi_close($mh); + } + + // Record heartbeat in settings for UI to check + db()->prepare("INSERT INTO settings (setting_key, setting_value) VALUES ('worker_heartbeat', UNIX_TIMESTAMP()) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)")->execute(); + + $end_time = microtime(true); + $execution_time = ($end_time - $start_time); + $sleep_time = max(0.1, 1 - $execution_time); + usleep($sleep_time * 1000000); + + } catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + sleep(2); + } +} \ No newline at end of file diff --git a/yumee_panels_android_source.tar.gz b/yumee_panels_android_source.tar.gz new file mode 100644 index 0000000..04c517a Binary files /dev/null and b/yumee_panels_android_source.tar.gz differ