Compare commits

..

3 Commits

Author SHA1 Message Date
Flatlogic Bot
41f618b155 Auto commit: 2026-02-25T07:05:45.288Z 2026-02-25 07:05:45 +00:00
Flatlogic Bot
c16e47476d save 2026-02-25 06:50:45 +00:00
Flatlogic Bot
cde29a9feb Uptime 2026-02-25 06:46:57 +00:00
27 changed files with 1700 additions and 441 deletions

View File

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

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".YumeeApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.YumeePanels"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.YumeePanels">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.MonitorService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="health" />
<receiver
android:name=".service.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
<receiver
android:name=".widget.MonitorWidget"
android:exported="true"
android:label="Yumee Panels Status">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="com.yumee.panels.UPDATE_WIDGET" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info" />
</receiver>
</application>
</manifest>

View File

@ -0,0 +1,170 @@
package com.yumee.panels
import android.content.*
import android.os.*
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontWeight
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.*
class MainActivity : ComponentActivity() {
private lateinit var db: YumeeDatabase
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
db = YumeeDatabase.getDatabase(applicationContext)
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())
var isVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
isVisible = true
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.padding(24.dp)) {
Text(
text = "YUMEE PANELS",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Bold),
color = NeonWhite,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "REALTIME DASHBOARD",
style = MaterialTheme.typography.labelMedium.copy(letterSpacing = 2.dp),
color = NeonWhite.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onStartService,
colors = ButtonDefaults.buttonColors(containerColor = NeonWhite, contentColor = DarkPastelBlue),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.shadow(8.dp, RoundedCornerShape(12.dp), spotColor = NeonWhiteGlow, ambientColor = NeonWhiteGlow)
) {
Text("START MONITORING", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold))
}
Spacer(modifier = Modifier.height(32.dp))
LazyColumn(
contentPadding = PaddingValues(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(monitors.size) { index ->
AnimatedVisibility(
visible = isVisible,
enter = fadeIn(animationSpec = tween(500, delayMillis = index * 100)) +
slideInVertically(initialOffsetY = { 50 }, animationSpec = tween(500, delayMillis = index * 100))
) {
MonitorItem(monitors[index], db)
}
}
}
}
}
}
@Composable
fun MonitorItem(monitor: Monitor, db: YumeeDatabase) {
val logs by db.monitorDao().getLogsForMonitor(monitor.id).collectAsState(initial = emptyList())
Card(
modifier = Modifier
.fillMaxWidth()
.shadow(6.dp, RoundedCornerShape(16.dp), spotColor = NeonWhiteGlow, ambientColor = NeonWhiteGlow),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = PanelBackground)
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
BatteryHealthBar(
isOnline = monitor.isOnline,
health = monitor.uptimePercent.toInt().coerceIn(0, 100),
modifier = Modifier.size(width = 44.dp, height = 84.dp)
)
Spacer(modifier = Modifier.width(24.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = monitor.name,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = NeonWhite
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = monitor.url,
style = MaterialTheme.typography.bodySmall,
color = NeonWhite.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(12.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (monitor.isOnline) "ONLINE" else "OFFLINE",
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold),
color = if (monitor.isOnline) NeonWhite else NeonRed
)
Text(
text = "${monitor.lastResponseTime} ms",
style = MaterialTheme.typography.labelLarge,
color = NeonWhite.copy(alpha = 0.8f)
)
}
}
}
// Add CandlestickChart if we have logs
if (logs.isNotEmpty()) {
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "RESPONSE HISTORY",
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.dp),
color = NeonWhite.copy(alpha = 0.4f),
modifier = Modifier.padding(bottom = 8.dp)
)
CandlestickChart(
logs = logs.reversed(), // Render chronological since DB query is DESC
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
)
}
}
}
}

View File

@ -0,0 +1,15 @@
package com.yumee.panels
import android.app.Application
import com.yumee.panels.data.local.YumeeDatabase
class YumeeApp : Application() {
companion object {
lateinit var database: YumeeDatabase
}
override fun onCreate() {
super.onCreate()
database = YumeeDatabase.getDatabase(this)
}
}

View File

@ -0,0 +1,60 @@
package com.yumee.panels.data.local
import android.content.Context
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface MonitorDao {
@Query("SELECT * FROM monitors")
fun getAllMonitors(): Flow<List<Monitor>>
@Query("SELECT * FROM monitors")
suspend fun getAllMonitorsSync(): List<Monitor>
@Query("SELECT * FROM monitors WHERE isActive = 1")
suspend fun getActiveMonitors(): List<Monitor>
@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<List<MonitorLog>>
@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
companion object {
@Volatile
private var INSTANCE: YumeeDatabase? = null
fun getDatabase(context: Context): YumeeDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
YumeeDatabase::class.java,
"yumee_database"
).build()
INSTANCE = instance
instance
}
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,161 @@
package com.yumee.panels.service
import android.app.*
import android.content.*
import android.os.*
import androidx.core.app.NotificationCompat
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 = YumeeDatabase.getDatabase(applicationContext)
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()
var updated = false
monitors.forEach { monitor ->
if (System.currentTimeMillis() - monitor.lastChecked >= monitor.intervalSeconds * 1000) {
pingMonitor(monitor)
updated = true
}
}
if (updated) {
updateWidget()
}
delay(1000)
}
}
}
private fun updateWidget() {
val intent = Intent("com.yumee.panels.UPDATE_WIDGET")
intent.setPackage(packageName)
sendBroadcast(intent)
}
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
}
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.mipmap.ic_launcher)
.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.mipmap.ic_launcher)
.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()
}
}

View File

@ -0,0 +1,103 @@
package com.yumee.panels.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.unit.dp
import com.yumee.panels.ui.theme.*
import androidx.compose.foundation.Canvas
@Composable
fun BatteryHealthBar(
isOnline: Boolean,
health: Int, // 0 to 100
modifier: Modifier = Modifier
) {
val targetColor = if (isOnline) NeonWhite else NeonRed
val targetGlowColor = if (isOnline) NeonWhiteGlow else NeonRedGlow
val color by animateColorAsState(targetValue = targetColor, animationSpec = tween(500))
val glowColor by animateColorAsState(targetValue = targetGlowColor, animationSpec = tween(500))
val animatedHealth by animateFloatAsState(targetValue = health / 100f, animationSpec = tween(800))
Box(modifier = modifier, contentAlignment = Alignment.Center) {
// Outer Glow
Box(
modifier = Modifier
.width(44.dp)
.height(84.dp)
.background(glowColor, RoundedCornerShape(8.dp))
)
// Battery Container
Box(
modifier = Modifier
.width(40.dp)
.height(80.dp)
.background(DarkPastelBlue, RoundedCornerShape(6.dp))
.border(2.dp, color, RoundedCornerShape(6.dp))
.padding(3.dp)
) {
// Battery Level with smooth animation
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(animatedHealth)
.align(Alignment.BottomCenter)
.background(color, RoundedCornerShape(3.dp))
)
}
}
}
@Composable
fun CandlestickChart(
logs: List<com.yumee.panels.data.local.MonitorLog>,
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 glowColor = if (log.isOnline) NeonWhiteGlow else NeonRedGlow
val barHeight = (log.responseTime.toFloat() / maxResponseTime) * height
val x = index * barWidth
// Draw glow
drawRoundRect(
color = glowColor,
topLeft = Offset(x + 1f, height - barHeight - 4f),
size = Size(barWidth - 2f, barHeight + 8f),
cornerRadius = CornerRadius(4f, 4f),
alpha = 0.5f,
style = Fill
)
// Draw core
drawRoundRect(
color = color,
topLeft = Offset(x + 2f, height - barHeight),
size = Size(barWidth - 4f, barHeight),
cornerRadius = CornerRadius(2f, 2f),
alpha = 1.0f,
style = Fill
)
}
}
}

View File

@ -0,0 +1,37 @@
package com.yumee.panels.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
val DarkPastelBlue = Color(0xFF131B2F) // Background biru pastel gelap
val PanelBackground = Color(0xFF1B2640) // Background panel sedikit lebih terang
val NeonWhite = Color(0xFFFFFFFF) // Putih neon
val NeonWhiteGlow = Color(0x66FFFFFF) // Glow putih neon
val NeonRed = Color(0xFFFF4D4D) // Merah neon
val NeonRedGlow = Color(0x66FF4D4D)
private val DarkColorScheme = darkColorScheme(
primary = NeonWhite,
secondary = NeonWhite,
tertiary = NeonWhite,
background = DarkPastelBlue,
surface = PanelBackground,
onPrimary = DarkPastelBlue,
onSecondary = DarkPastelBlue,
onTertiary = DarkPastelBlue,
onBackground = NeonWhite,
onSurface = NeonWhite,
)
@Composable
fun YumeePanelsTheme(
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = DarkColorScheme,
// Using default typography, can be customized later
content = content
)
}

View File

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

View File

@ -0,0 +1,56 @@
package com.yumee.panels.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import com.yumee.panels.MainActivity
import com.yumee.panels.R
import com.yumee.panels.data.local.YumeeDatabase
import kotlinx.coroutines.*
class MonitorWidget : AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId)
}
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == "com.yumee.panels.UPDATE_WIDGET") {
val appWidgetManager = AppWidgetManager.getInstance(context)
val appWidgetIds = appWidgetManager.getAppWidgetIds(ComponentName(context, MonitorWidget::class.java))
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId)
}
}
}
private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
val views = RemoteViews(context.packageName, R.layout.widget_layout)
// Launch app on click
val intent = Intent(context, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
views.setOnClickPendingIntent(R.id.widget_title, pendingIntent)
// Fetch counts from DB
CoroutineScope(Dispatchers.IO).launch {
val db = YumeeDatabase.getDatabase(context)
val allMonitors = db.monitorDao().getAllMonitorsSync()
val onlineCount = allMonitors.count { it.isOnline }
val offlineCount = allMonitors.count { !it.isOnline }
withContext(Dispatchers.Main) {
views.setTextViewText(R.id.widget_online_count, "Online: $onlineCount")
views.setTextViewText(R.id.widget_offline_count, "Offline: $offlineCount")
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#131B2F" />
<corners android:radius="16dp" />
<stroke android:width="2dp" android:color="#FFFFFF" />
</shape>

View File

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/widget_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="YUMEE PANELS"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:textSize="12sp"
android:shadowColor="#66FFFFFF"
android:shadowDx="0"
android:shadowDy="0"
android:shadowRadius="10" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="8dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/widget_online_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Online: 0"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:textSize="14sp"
android:shadowColor="#66FFFFFF"
android:shadowDx="0"
android:shadowDy="0"
android:shadowRadius="10" />
<TextView
android:id="@+id/widget_offline_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Offline: 0"
android:textColor="#FF4D4D"
android:textStyle="bold"
android:textSize="14sp"
android:shadowColor="#66FF4D4D"
android:shadowDx="0"
android:shadowDy="0"
android:shadowRadius="10" />
</LinearLayout>
<ImageView
android:id="@+id/widget_refresh"
android:layout_width="28dp"
android:layout_height="28dp"
android:src="@android:drawable/stat_notify_sync"
android:contentDescription="Refresh"
android:tint="#FFFFFF" />
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Yumee Panels</string>
</resources>

View File

@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.YumeePanels" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">#1B263B</item>
<item name="android:navigationBarColor">#0D1B2A</item>
</style>
</resources>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="40dp"
android:updatePeriodMillis="1800000"
android:initialLayout="@layout/widget_layout"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen">
</appwidget-provider>

View File

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

View File

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

77
api/monitor.php Normal file
View File

@ -0,0 +1,77 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../db/config.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list':
case 'ping_all':
try {
// Worker status check
$stmtStatus = db()->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;
}

View File

@ -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,<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2" viewBox="0 0 2 2"><circle cx="1" cy="1" r="0.5" fill="white" opacity="0.3"/></svg>');
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));
}

View File

@ -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 = '<div class="col-12 text-center opacity-50 p-5">No URLs added yet. Start by adding one!</div>';
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('<div class="health-segment segment-none"></div>');
history.forEach((h, idx) => {
const isUp = h.status_code >= 200 && h.status_code < 400;
const segIdx = segments.length - history.length + idx;
segments[segIdx] = `<div class="health-segment ${isUp ? 'segment-up' : 'segment-down'}"></div>`;
});
return `
<div class="col-md-6 col-lg-4 mb-4">
<div class="card monitor-card h-100" id="monitor-${m.id}">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0 text-truncate" style="max-width: 70%; font-weight: 700;">${m.name}</h5>
<div class="d-flex align-items-center">
<span class="status-indicator ${getStatusClass(m.last_status)}"></span>
<small class="status-text opacity-75">${m.last_status || '---'}</small>
</div>
</div>
<p class="card-text small text-muted text-truncate mb-3">${m.url}</p>
<div class="stats-label mb-1">Health Status (Real-time)</div>
<div class="health-bars mb-3">
${segments.join('')}
</div>
<div class="sparkline-container mb-3">
<canvas class="sparkline-canvas" id="sparkline-${m.id}" width="400" height="100" style="width: 100%; height: 60px;"></canvas>
</div>
<div class="row g-2 mb-3 text-center">
<div class="col-6">
<div class="stats-label">Latency</div>
<div class="stats-value latency-value">${m.last_latency || 0}ms</div>
</div>
<div class="col-6">
<div class="stats-label">Live Feed</div>
<div class="stats-value"><span class="badge bg-primary pulse-mini">LIVE</span></div>
</div>
</div>
<div class="d-flex justify-content-between gap-2 mt-auto">
<button class="neon-btn flex-grow-1 ping-btn" data-id="${m.id}">
<i class="bi bi-play-fill"></i> Test
</button>
<button class="btn btn-outline-danger btn-sm border-0 opacity-50 delete-btn" data-id="${m.id}">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
`;
};
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('<div class="health-segment segment-none"></div>');
history.forEach((h, idx) => {
const isUp = h.status_code >= 200 && h.status_code < 400;
const segIdx = segments.length - history.length + idx;
segments[segIdx] = `<div class="health-segment ${isUp ? 'segment-up' : 'segment-down'}"></div>`;
});
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 = '<span class="spinner-border spinner-border-sm"></span>';
try {
// Using list to refresh since worker is active
await fetchMonitors('list');
} finally {
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-play-fill"></i> 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);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

247
index.php
View File

@ -1,150 +1,109 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
?>
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<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:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Yumee Panels - Real-time Uptime Monitor</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<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:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
<nav class="navbar navbar-expand-lg navbar-dark sticky-top">
<div class="container">
<a class="navbar-brand" href="#">
<i class="bi bi-activity"></i> Yumee Panels
</a>
<div class="d-flex align-items-center flex-wrap gap-2">
<span class="badge rounded-pill bg-dark border border-magenta py-2 px-3">
<span class="pulse-dot"></span> REAL-TIME PERDETIK
</span>
<span id="service-status" class="badge rounded-pill bg-dark border border-cyan py-2 px-3">
<i class="bi bi-cpu"></i> Monitoring Service: <span class="status-text">Checking...</span>
</span>
<button class="neon-btn ms-lg-3" data-bs-toggle="modal" data-bs-target="#addMonitorModal">
<i class="bi bi-plus-lg"></i> Add URL
</button>
</div>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
</nav>
<div class="container my-5">
<!-- Summary Cards -->
<div class="row mb-5 summary-card">
<div class="col-md-4">
<div class="card p-4 text-center mb-3">
<div class="stats-label">Online Monitors</div>
<div class="stats-value text-success" id="summary-online">0</div>
</div>
</div>
<div class="col-md-4">
<div class="card p-4 text-center mb-3">
<div class="stats-label">Offline / Error</div>
<div class="stats-value text-danger" id="summary-offline">0</div>
</div>
</div>
<div class="col-md-4">
<div class="card p-4 text-center mb-3">
<div class="stats-label">Total URLs</div>
<div class="stats-value text-info" id="summary-total">0</div>
</div>
</div>
</div>
<!-- Monitor List -->
<div id="monitor-list" class="row">
<!-- JS will populate this -->
<div class="col-12 text-center p-5">
<div class="spinner-border text-cyan" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
<!-- Add Monitor Modal -->
<div class="modal fade" id="addMonitorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark border-cyan text-light">
<div class="modal-header border-secondary">
<h5 class="modal-title">Monitor New URL</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="add-monitor-form">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Friendly Name</label>
<input type="text" id="mon-name" class="form-control" placeholder="e.g. My Website" required>
</div>
<div class="mb-3">
<label class="form-label">URL</label>
<input type="url" id="mon-url" class="form-control" placeholder="https://example.com" required>
</div>
<div class="mb-3">
<label class="form-label">Check Interval</label>
<select id="mon-interval" class="form-control">
<option value="1">Real-time (1s)</option>
<option value="5">5 Minutes</option>
<option value="15">15 Minutes</option>
</select>
<small class="text-muted mt-1 d-block">All URLs are currently monitored per second by global pinger.</small>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="neon-btn">Start Monitoring</button>
</div>
</form>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
</body>
</html>
</html>

0
worker.lock Normal file
View File

130
worker.php Normal file
View File

@ -0,0 +1,130 @@
<?php
/**
* Yumee Panels - Persistent Background Worker (Foreground Service Equivalent)
*/
require_once __DIR__ . '/db/config.php';
// Prevent multiple instances
$lockFile = __DIR__ . '/worker.lock';
$lock = fopen($lockFile, 'c');
if (!flock($lock, LOCK_EX | LOCK_NB)) {
die("Worker is already running.\n");
}
function sendTelegramMessage($text) {
global $pdo; // Assume db() uses a global or similar, let's fetch from settings
try {
$stmt = db()->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);
}
}

Binary file not shown.