Neues Werkzeug: Geräte-Monitor (Dauerüberwachung) [apk]
All checks were successful
Build APK / build-apk (push) Successful in 1m47s
All checks were successful
Build APK / build-apk (push) Successful in 1m47s
Für das Kamera-Problem: mehrere Geräte auswählen und ihre Erreichbarkeit über längere Zeit überwachen — jeder Ausfall wird mit Uhrzeit protokolliert. - MonitorService: schlanker Vordergrund-Dienst, hält den Prozess am Leben, damit die Überwachung bei Display aus / App-Wechsel weiterläuft - Plugin startMonitor/stopMonitor/getMonitorStatus: pingt die Geräte im gewählten Intervall, Wechsel erreichbar↔weg erzeugt ein monitorEvent; WifiLock gegen WLAN-Schlaf, Heads-up-Benachrichtigung bei Ausfall - Monitor-Seite (protokoll/[id]/monitor): Geräte-Mehrfachauswahl, Intervallwahl, Live-Ereignisliste, frühere Überwachungen mit Ausfallzahl - Überwachung läuft beim Verlassen der Seite weiter; Rückkehr nimmt den Stand wieder auf (getMonitorStatus) - Manifest: MonitorService + FOREGROUND_SERVICE_DATA_SYNC, POST_NOTIFICATIONS - Kachel "Geräte-Monitor" im Werkzeuge-Raster Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9ee9c954b2
commit
d2df3ee929
10 changed files with 869 additions and 1 deletions
|
|
@ -33,6 +33,12 @@
|
|||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
|
||||
<!-- Vordergrund-Dienst des Geräte-Monitors -->
|
||||
<service
|
||||
android:name=".MonitorService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
|
@ -45,5 +51,8 @@
|
|||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!-- Geräte-Monitor: Vordergrund-Dienst + Ausfall-Benachrichtigungen -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
package de.data_it_solution.netdiag
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* Schlanker Vordergrund-Dienst für den Geräte-Monitor.
|
||||
*
|
||||
* Er hält den App-Prozess am Leben, solange die Überwachung läuft — damit
|
||||
* Android die Mess-Schleife bei ausgeschaltetem Display oder App-Wechsel nicht
|
||||
* beendet. Die eigentliche Ping-Logik läuft im NetDiagScannerPlugin; dieser
|
||||
* Dienst zeigt nur die dauerhafte Benachrichtigung.
|
||||
*/
|
||||
class MonitorService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val text = intent?.getStringExtra(EXTRA_TEXT) ?: "Geräte-Überwachung läuft"
|
||||
val notification = buildNotification(text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, notification)
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun buildNotification(text: String): Notification {
|
||||
ensureChannel(this)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("NetDiag — Geräte-Monitor")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_compass)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "netdiag-monitor"
|
||||
const val NOTIF_ID = 4711
|
||||
const val EXTRA_TEXT = "text"
|
||||
|
||||
/** Benachrichtigungskanal anlegen (idempotent) */
|
||||
fun ensureChannel(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val mgr = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
mgr.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Geräte-Monitor",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun start(ctx: Context, text: String) {
|
||||
val i = Intent(ctx, MonitorService::class.java).putExtra(EXTRA_TEXT, text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.startForegroundService(i)
|
||||
} else {
|
||||
ctx.startService(i)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(ctx: Context) {
|
||||
ctx.stopService(Intent(ctx, MonitorService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package de.data_it_solution.netdiag
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
|
|
@ -10,6 +11,7 @@ import android.net.nsd.NsdServiceInfo
|
|||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
|
|
@ -825,6 +827,140 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
var maxMs = 0.0
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- */
|
||||
/* Geräte-Monitor — Dauerüberwachung mehrerer Geräte (Kamera-Problem) */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
||||
private val monitorRuns = ConcurrentHashMap<String, MonitorRun>()
|
||||
|
||||
private class MonitorRun(
|
||||
val targets: List<Pair<String, String>>,
|
||||
val intervalSec: Int,
|
||||
) {
|
||||
@Volatile var active = true
|
||||
/** je IP: true = erreichbar */
|
||||
val state = ConcurrentHashMap<String, Boolean>()
|
||||
/** je IP: Beginn des aktuellen Ausfalls (für die Ausfalldauer) */
|
||||
val downSince = ConcurrentHashMap<String, Long>()
|
||||
/** alle bisher erzeugten Ereignisse — für die UI-Wiederaufnahme */
|
||||
val events = java.util.concurrent.CopyOnWriteArrayList<JSObject>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Überwacht die Erreichbarkeit mehrerer Geräte im festen Intervall.
|
||||
* Jeder Wechsel erreichbar↔nicht erreichbar erzeugt ein `monitorEvent`.
|
||||
* Ein Vordergrund-Dienst hält den Prozess am Leben (Display aus / App-Wechsel),
|
||||
* ein WifiLock verhindert, dass das WLAN zwischen den Messungen schläft.
|
||||
*/
|
||||
@PluginMethod
|
||||
fun startMonitor(call: PluginCall) {
|
||||
val arr = call.getArray("hosts") ?: return call.reject("hosts fehlt")
|
||||
val intervalSec = (call.getInt("intervalSec") ?: 30).coerceIn(5, 600)
|
||||
val targets = ArrayList<Pair<String, String>>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.optJSONObject(i) ?: continue
|
||||
val ip = o.optString("ip")
|
||||
if (ip.isNotEmpty()) targets.add(ip to o.optString("label", ip))
|
||||
}
|
||||
if (targets.isEmpty()) return call.reject("keine Geräte gewählt")
|
||||
|
||||
val runId = "mon-${System.currentTimeMillis()}"
|
||||
val run = MonitorRun(targets, intervalSec)
|
||||
monitorRuns[runId] = run
|
||||
MonitorService.start(context, "${targets.size} Geräte · alle ${intervalSec}s")
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val wifiLock = (context.applicationContext
|
||||
.getSystemService(Context.WIFI_SERVICE) as WifiManager)
|
||||
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "netdiag-monitor")
|
||||
try { wifiLock.acquire() } catch (_: Exception) { }
|
||||
|
||||
io.launch {
|
||||
try {
|
||||
// Ausgangslage erfassen — erzeugt noch kein Ereignis
|
||||
for ((ip, _) in targets) run.state[ip] = isReachable(ip)
|
||||
while (run.active) {
|
||||
Thread.sleep(intervalSec * 1000L)
|
||||
if (!run.active) break
|
||||
for ((ip, label) in targets) {
|
||||
if (!run.active) break
|
||||
val up = isReachable(ip)
|
||||
val prev = run.state[ip] ?: up
|
||||
if (up == prev) continue
|
||||
run.state[ip] = up
|
||||
val now = System.currentTimeMillis()
|
||||
val ev = JSObject()
|
||||
.put("runId", runId)
|
||||
.put("ip", ip)
|
||||
.put("label", label)
|
||||
.put("ts", now)
|
||||
if (up) {
|
||||
val since = run.downSince.remove(ip)
|
||||
ev.put("type", "up")
|
||||
ev.put(
|
||||
"durationSec",
|
||||
if (since != null) ((now - since) / 1000L).toInt() else 0,
|
||||
)
|
||||
} else {
|
||||
run.downSince[ip] = now
|
||||
ev.put("type", "down")
|
||||
notifyDown(label, ip)
|
||||
}
|
||||
run.events.add(ev)
|
||||
notifyListeners("monitorEvent", ev)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { if (wifiLock.isHeld) wifiLock.release() } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
resolve(call, JSObject().put("runId", runId))
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun stopMonitor(call: PluginCall) {
|
||||
val runId = call.getString("runId") ?: return call.reject("runId fehlt")
|
||||
val run = monitorRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden")
|
||||
run.active = false
|
||||
if (monitorRuns.isEmpty()) MonitorService.stop(context)
|
||||
val events = JSArray()
|
||||
run.events.forEach { events.put(it) }
|
||||
resolve(call, JSObject().put("stopped", true).put("events", events))
|
||||
}
|
||||
|
||||
/** Status eines Monitor-Laufs abfragen (UI-Wiederaufnahme nach Seitenwechsel) */
|
||||
@PluginMethod
|
||||
fun getMonitorStatus(call: PluginCall) {
|
||||
val runId = call.getString("runId") ?: return call.reject("runId fehlt")
|
||||
val run = monitorRuns[runId]
|
||||
val events = JSArray()
|
||||
run?.events?.forEach { events.put(it) }
|
||||
resolve(call, JSObject()
|
||||
.put("running", run != null && run.active)
|
||||
.put("events", events))
|
||||
}
|
||||
|
||||
private fun isReachable(ip: String): Boolean = try {
|
||||
InetAddress.getByName(ip).isReachable(1500)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/** Heads-up-Benachrichtigung, wenn ein überwachtes Gerät ausfällt */
|
||||
private fun notifyDown(label: String, ip: String) {
|
||||
try {
|
||||
MonitorService.ensureChannel(context)
|
||||
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val n = NotificationCompat.Builder(context, MonitorService.CHANNEL_ID)
|
||||
.setContentTitle("Gerät nicht erreichbar")
|
||||
.setContentText("$label ($ip) antwortet nicht mehr")
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
mgr.notify(ip.hashCode(), n)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- */
|
||||
/* App-Update: APK herunterladen und Paketinstaller öffnen */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
|
|
|||
81
native-plugin/MonitorService.kt
Normal file
81
native-plugin/MonitorService.kt
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package de.data_it_solution.netdiag
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* Schlanker Vordergrund-Dienst für den Geräte-Monitor.
|
||||
*
|
||||
* Er hält den App-Prozess am Leben, solange die Überwachung läuft — damit
|
||||
* Android die Mess-Schleife bei ausgeschaltetem Display oder App-Wechsel nicht
|
||||
* beendet. Die eigentliche Ping-Logik läuft im NetDiagScannerPlugin; dieser
|
||||
* Dienst zeigt nur die dauerhafte Benachrichtigung.
|
||||
*/
|
||||
class MonitorService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val text = intent?.getStringExtra(EXTRA_TEXT) ?: "Geräte-Überwachung läuft"
|
||||
val notification = buildNotification(text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, notification)
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun buildNotification(text: String): Notification {
|
||||
ensureChannel(this)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("NetDiag — Geräte-Monitor")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_compass)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "netdiag-monitor"
|
||||
const val NOTIF_ID = 4711
|
||||
const val EXTRA_TEXT = "text"
|
||||
|
||||
/** Benachrichtigungskanal anlegen (idempotent) */
|
||||
fun ensureChannel(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val mgr = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
mgr.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Geräte-Monitor",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun start(ctx: Context, text: String) {
|
||||
val i = Intent(ctx, MonitorService::class.java).putExtra(EXTRA_TEXT, text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.startForegroundService(i)
|
||||
} else {
|
||||
ctx.startService(i)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(ctx: Context) {
|
||||
ctx.stopService(Intent(ctx, MonitorService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package de.data_it_solution.netdiag
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
|
|
@ -10,6 +11,7 @@ import android.net.nsd.NsdServiceInfo
|
|||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
|
|
@ -825,6 +827,140 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
var maxMs = 0.0
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- */
|
||||
/* Geräte-Monitor — Dauerüberwachung mehrerer Geräte (Kamera-Problem) */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
||||
private val monitorRuns = ConcurrentHashMap<String, MonitorRun>()
|
||||
|
||||
private class MonitorRun(
|
||||
val targets: List<Pair<String, String>>,
|
||||
val intervalSec: Int,
|
||||
) {
|
||||
@Volatile var active = true
|
||||
/** je IP: true = erreichbar */
|
||||
val state = ConcurrentHashMap<String, Boolean>()
|
||||
/** je IP: Beginn des aktuellen Ausfalls (für die Ausfalldauer) */
|
||||
val downSince = ConcurrentHashMap<String, Long>()
|
||||
/** alle bisher erzeugten Ereignisse — für die UI-Wiederaufnahme */
|
||||
val events = java.util.concurrent.CopyOnWriteArrayList<JSObject>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Überwacht die Erreichbarkeit mehrerer Geräte im festen Intervall.
|
||||
* Jeder Wechsel erreichbar↔nicht erreichbar erzeugt ein `monitorEvent`.
|
||||
* Ein Vordergrund-Dienst hält den Prozess am Leben (Display aus / App-Wechsel),
|
||||
* ein WifiLock verhindert, dass das WLAN zwischen den Messungen schläft.
|
||||
*/
|
||||
@PluginMethod
|
||||
fun startMonitor(call: PluginCall) {
|
||||
val arr = call.getArray("hosts") ?: return call.reject("hosts fehlt")
|
||||
val intervalSec = (call.getInt("intervalSec") ?: 30).coerceIn(5, 600)
|
||||
val targets = ArrayList<Pair<String, String>>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.optJSONObject(i) ?: continue
|
||||
val ip = o.optString("ip")
|
||||
if (ip.isNotEmpty()) targets.add(ip to o.optString("label", ip))
|
||||
}
|
||||
if (targets.isEmpty()) return call.reject("keine Geräte gewählt")
|
||||
|
||||
val runId = "mon-${System.currentTimeMillis()}"
|
||||
val run = MonitorRun(targets, intervalSec)
|
||||
monitorRuns[runId] = run
|
||||
MonitorService.start(context, "${targets.size} Geräte · alle ${intervalSec}s")
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val wifiLock = (context.applicationContext
|
||||
.getSystemService(Context.WIFI_SERVICE) as WifiManager)
|
||||
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "netdiag-monitor")
|
||||
try { wifiLock.acquire() } catch (_: Exception) { }
|
||||
|
||||
io.launch {
|
||||
try {
|
||||
// Ausgangslage erfassen — erzeugt noch kein Ereignis
|
||||
for ((ip, _) in targets) run.state[ip] = isReachable(ip)
|
||||
while (run.active) {
|
||||
Thread.sleep(intervalSec * 1000L)
|
||||
if (!run.active) break
|
||||
for ((ip, label) in targets) {
|
||||
if (!run.active) break
|
||||
val up = isReachable(ip)
|
||||
val prev = run.state[ip] ?: up
|
||||
if (up == prev) continue
|
||||
run.state[ip] = up
|
||||
val now = System.currentTimeMillis()
|
||||
val ev = JSObject()
|
||||
.put("runId", runId)
|
||||
.put("ip", ip)
|
||||
.put("label", label)
|
||||
.put("ts", now)
|
||||
if (up) {
|
||||
val since = run.downSince.remove(ip)
|
||||
ev.put("type", "up")
|
||||
ev.put(
|
||||
"durationSec",
|
||||
if (since != null) ((now - since) / 1000L).toInt() else 0,
|
||||
)
|
||||
} else {
|
||||
run.downSince[ip] = now
|
||||
ev.put("type", "down")
|
||||
notifyDown(label, ip)
|
||||
}
|
||||
run.events.add(ev)
|
||||
notifyListeners("monitorEvent", ev)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { if (wifiLock.isHeld) wifiLock.release() } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
resolve(call, JSObject().put("runId", runId))
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun stopMonitor(call: PluginCall) {
|
||||
val runId = call.getString("runId") ?: return call.reject("runId fehlt")
|
||||
val run = monitorRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden")
|
||||
run.active = false
|
||||
if (monitorRuns.isEmpty()) MonitorService.stop(context)
|
||||
val events = JSArray()
|
||||
run.events.forEach { events.put(it) }
|
||||
resolve(call, JSObject().put("stopped", true).put("events", events))
|
||||
}
|
||||
|
||||
/** Status eines Monitor-Laufs abfragen (UI-Wiederaufnahme nach Seitenwechsel) */
|
||||
@PluginMethod
|
||||
fun getMonitorStatus(call: PluginCall) {
|
||||
val runId = call.getString("runId") ?: return call.reject("runId fehlt")
|
||||
val run = monitorRuns[runId]
|
||||
val events = JSArray()
|
||||
run?.events?.forEach { events.put(it) }
|
||||
resolve(call, JSObject()
|
||||
.put("running", run != null && run.active)
|
||||
.put("events", events))
|
||||
}
|
||||
|
||||
private fun isReachable(ip: String): Boolean = try {
|
||||
InetAddress.getByName(ip).isReachable(1500)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/** Heads-up-Benachrichtigung, wenn ein überwachtes Gerät ausfällt */
|
||||
private fun notifyDown(label: String, ip: String) {
|
||||
try {
|
||||
MonitorService.ensureChannel(context)
|
||||
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val n = NotificationCompat.Builder(context, MonitorService.CHANNEL_ID)
|
||||
.setContentTitle("Gerät nicht erreichbar")
|
||||
.setContentText("$label ($ip) antwortet nicht mehr")
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
mgr.notify(ip.hashCode(), n)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- */
|
||||
/* App-Update: APK herunterladen und Paketinstaller öffnen */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
|
|
|||
36
src/lib/components/DeviceMultiSelect.svelte
Normal file
36
src/lib/components/DeviceMultiSelect.svelte
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Mehrfachauswahl von Geräten per Checkbox — z.B. um auszuwählen, welche
|
||||
* Geräte der Monitor überwachen soll. `selected` enthält die clientIds.
|
||||
*/
|
||||
import type { Device } from '$lib/types';
|
||||
|
||||
let {
|
||||
devices,
|
||||
selected = $bindable([]),
|
||||
}: { devices: Device[]; selected: string[] } = $props();
|
||||
|
||||
function toggle(id: string) {
|
||||
selected = selected.includes(id)
|
||||
? selected.filter((x) => x !== id)
|
||||
: [...selected, id];
|
||||
}
|
||||
|
||||
function label(d: Device): string {
|
||||
return d.customName || d.mdnsName || d.hostname || d.netbiosName || d.ip;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each devices as d (d.clientId)}
|
||||
<label class="flex items-center gap-2 rounded bg-zinc-800 px-2 py-1.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(d.clientId)}
|
||||
onchange={() => toggle(d.clientId)}
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate">{label(d)}</span>
|
||||
<span class="shrink-0 text-xs text-zinc-500">{d.ip}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
* Beispieldaten, damit die Oberfläche ohne Gerät entwickelt werden kann.
|
||||
*/
|
||||
|
||||
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||
|
||||
/* --- Datentypen der Plugin-Antworten --- */
|
||||
|
||||
|
|
@ -40,6 +40,16 @@ export interface ConflictScanResult {
|
|||
/** false = ARP-Tabelle nicht lesbar (Android-Einschränkung, dann keine Aussage möglich) */
|
||||
arpAvailable: boolean;
|
||||
}
|
||||
/** Ein Ereignis des Geräte-Monitors (vom nativen Plugin geliefert) */
|
||||
export interface MonitorEventData {
|
||||
runId: string;
|
||||
ip: string;
|
||||
label: string;
|
||||
ts: number;
|
||||
type: 'up' | 'down';
|
||||
/** Dauer des vorangegangenen Ausfalls in Sekunden (nur bei 'up') */
|
||||
durationSec?: number;
|
||||
}
|
||||
export interface OpenPort {
|
||||
port: number;
|
||||
service?: string;
|
||||
|
|
@ -119,6 +129,17 @@ export interface NetDiagScannerPlugin {
|
|||
avgMs: number;
|
||||
maxMs: number;
|
||||
}>;
|
||||
/** Geräte-Monitor starten: mehrere Geräte im Intervall auf Erreichbarkeit prüfen */
|
||||
startMonitor(opts: {
|
||||
hosts: { ip: string; label: string }[];
|
||||
intervalSec: number;
|
||||
}): Promise<{ runId: string }>;
|
||||
/** Geräte-Monitor beenden — liefert alle gesammelten Ereignisse */
|
||||
stopMonitor(opts: { runId: string }): Promise<{ stopped: boolean; events: MonitorEventData[] }>;
|
||||
/** Status eines Monitor-Laufs abfragen (Wiederaufnahme nach Seitenwechsel) */
|
||||
getMonitorStatus(opts: {
|
||||
runId: string;
|
||||
}): Promise<{ running: boolean; events: MonitorEventData[] }>;
|
||||
}
|
||||
|
||||
const native = registerPlugin<NetDiagScannerPlugin>('NetDiagScanner');
|
||||
|
|
@ -131,6 +152,11 @@ function rnd(min: number, max: number): number {
|
|||
return Math.round((min + Math.random() * (max - min)) * 10) / 10;
|
||||
}
|
||||
|
||||
/* --- Geräte-Monitor: Ereignis-Verteilung + Mock-Simulation --- */
|
||||
const monitorListeners = new Set<(e: MonitorEventData) => void>();
|
||||
let mockMonitorTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let mockMonitorEvents: MonitorEventData[] = [];
|
||||
|
||||
const mock: NetDiagScannerPlugin = {
|
||||
async getLocalSubnet() {
|
||||
return { subnet: '192.168.1.0/24', ip: '192.168.1.50', gateway: '192.168.1.1' };
|
||||
|
|
@ -260,7 +286,59 @@ const mock: NetDiagScannerPlugin = {
|
|||
async stopStressTest() {
|
||||
return { samples: 120, lossPct: rnd(0, 2), avgMs: rnd(3, 10), maxMs: rnd(20, 90) };
|
||||
},
|
||||
async startMonitor(opts) {
|
||||
const runId = 'mock-mon-' + Date.now();
|
||||
mockMonitorEvents = [];
|
||||
mockMonitorTimer = setInterval(() => {
|
||||
const host = opts.hosts[Math.floor(Math.random() * opts.hosts.length)];
|
||||
if (!host) return;
|
||||
const type: 'up' | 'down' = Math.random() < 0.5 ? 'down' : 'up';
|
||||
const ev: MonitorEventData = {
|
||||
runId,
|
||||
ip: host.ip,
|
||||
label: host.label,
|
||||
ts: Date.now(),
|
||||
type,
|
||||
durationSec: type === 'up' ? Math.floor(rnd(20, 180)) : undefined,
|
||||
};
|
||||
mockMonitorEvents.push(ev);
|
||||
monitorListeners.forEach((cb) => cb(ev));
|
||||
}, 6000);
|
||||
return { runId };
|
||||
},
|
||||
async stopMonitor() {
|
||||
if (mockMonitorTimer) clearInterval(mockMonitorTimer);
|
||||
mockMonitorTimer = undefined;
|
||||
return { stopped: true, events: mockMonitorEvents };
|
||||
},
|
||||
async getMonitorStatus() {
|
||||
return { running: mockMonitorTimer !== undefined, events: mockMonitorEvents };
|
||||
},
|
||||
};
|
||||
|
||||
/** Aktive Scanner-Implementierung: nativ auf dem Gerät, Mock im Browser */
|
||||
export const scanner: NetDiagScannerPlugin = Capacitor.isNativePlatform() ? native : mock;
|
||||
|
||||
/**
|
||||
* Auf Geräte-Monitor-Ereignisse hören. Gibt die Abmeldefunktion zurück.
|
||||
* Nativ: Listener am Plugin; im Browser-Dev: simulierte Mock-Ereignisse.
|
||||
*/
|
||||
export function onMonitorEvent(cb: (e: MonitorEventData) => void): () => void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
const handle = (
|
||||
native as unknown as {
|
||||
addListener(
|
||||
name: string,
|
||||
cb: (e: MonitorEventData) => void,
|
||||
): Promise<PluginListenerHandle>;
|
||||
}
|
||||
).addListener('monitorEvent', cb);
|
||||
return () => {
|
||||
void handle.then((h) => h.remove());
|
||||
};
|
||||
}
|
||||
monitorListeners.add(cb);
|
||||
return () => {
|
||||
monitorListeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,8 @@ export interface DeviceMonitorSession {
|
|||
targets: { ip: string; label: string }[];
|
||||
events: MonitorEvent[];
|
||||
status: 'running' | 'stopped';
|
||||
/** laufende Plugin-Lauf-ID (für Wiederaufnahme nach Seitenwechsel) */
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
/** Ampel-Bewertung einer Messung */
|
||||
|
|
|
|||
|
|
@ -267,6 +267,17 @@
|
|||
<span class="text-[11px] leading-tight text-zinc-500">{tool.description}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<!-- Geräte-Monitor: eigene Seite (Mehrfachauswahl + Dauerlauf) -->
|
||||
<a
|
||||
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
|
||||
href="/protokoll/{protocol.clientUuid}/monitor/"
|
||||
>
|
||||
<Icons.Activity size={20} class="text-sky-400" />
|
||||
<span class="text-sm font-medium">Geräte-Monitor</span>
|
||||
<span class="text-[11px] leading-tight text-zinc-500">
|
||||
Erreichbarkeit mehrerer Geräte dauerhaft überwachen.
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
298
src/routes/protokoll/[id]/monitor/+page.svelte
Normal file
298
src/routes/protokoll/[id]/monitor/+page.svelte
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Geräte-Monitor — überwacht die Erreichbarkeit ausgewählter Geräte über
|
||||
* längere Zeit und protokolliert jeden Ausfall mit Uhrzeit. Gedacht, um
|
||||
* sporadisch ausfallende Geräte (z.B. Kameras) einzukreisen.
|
||||
*
|
||||
* Die Überwachung läuft über einen Vordergrund-Dienst auch bei
|
||||
* ausgeschaltetem Display weiter. Verlässt man die Seite, läuft sie weiter;
|
||||
* beim Zurückkehren wird der Stand wieder aufgenommen.
|
||||
*/
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ChevronDown } from 'lucide-svelte';
|
||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||
import DeviceMultiSelect from '$lib/components/DeviceMultiSelect.svelte';
|
||||
import { getProtocol, saveProtocol } from '$lib/db';
|
||||
import { uid } from '$lib/protocols';
|
||||
import { scanner, onMonitorEvent, type MonitorEventData } from '$lib/scanner';
|
||||
import { sync } from '$lib/sync.svelte';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
import type { DeviceMonitorSession, MonitorEvent, Protocol } from '$lib/types';
|
||||
|
||||
let protocol = $state<Protocol | null>(null);
|
||||
let selected = $state<string[]>([]);
|
||||
let intervalSec = $state(30);
|
||||
let session = $state<DeviceMonitorSession | null>(null);
|
||||
let busy = $state(false);
|
||||
let expanded = $state<string | null>(null);
|
||||
let offEvent: (() => void) | undefined;
|
||||
|
||||
const running = $derived(session?.status === 'running');
|
||||
const pastSessions = $derived(
|
||||
(protocol?.monitorSessions ?? []).filter((s) => s.status === 'stopped'),
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
const p = await getProtocol($page.params.id ?? '');
|
||||
if (!p) {
|
||||
toast.show('Protokoll nicht gefunden', 'error');
|
||||
goto('/auftraege/');
|
||||
return;
|
||||
}
|
||||
protocol = p;
|
||||
|
||||
// Läuft bereits eine Überwachung? → wieder andocken
|
||||
const live = p.monitorSessions?.find((s) => s.status === 'running');
|
||||
if (live?.runId) {
|
||||
try {
|
||||
const st = await scanner.getMonitorStatus({ runId: live.runId });
|
||||
if (st.running) {
|
||||
live.events = st.events.map(toStored);
|
||||
session = live;
|
||||
attachListener(live.runId);
|
||||
} else {
|
||||
live.status = 'stopped';
|
||||
live.endedAt = Date.now();
|
||||
await persist();
|
||||
}
|
||||
} catch {
|
||||
live.status = 'stopped';
|
||||
await persist();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Listener lösen, aber die Überwachung NICHT stoppen — sie läuft weiter
|
||||
offEvent?.();
|
||||
void persist();
|
||||
});
|
||||
|
||||
async function persist() {
|
||||
if (!protocol) return;
|
||||
protocol.dirty = true;
|
||||
await saveProtocol($state.snapshot(protocol) as Protocol);
|
||||
await sync.refreshPending();
|
||||
}
|
||||
|
||||
function toStored(e: MonitorEventData): MonitorEvent {
|
||||
return { ts: e.ts, ip: e.ip, type: e.type, durationSec: e.durationSec };
|
||||
}
|
||||
|
||||
function attachListener(runId: string) {
|
||||
offEvent?.();
|
||||
offEvent = onMonitorEvent((e) => {
|
||||
if (e.runId !== runId || !session) return;
|
||||
session.events.push(toStored(e));
|
||||
void persist();
|
||||
});
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (!protocol || busy) return;
|
||||
const devices = protocol.devices.filter((d) => selected.includes(d.clientId));
|
||||
if (devices.length === 0) {
|
||||
toast.show('Bitte mindestens ein Gerät wählen', 'info');
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const hosts = devices.map((d) => ({
|
||||
ip: d.ip,
|
||||
label: d.customName || d.hostname || d.ip,
|
||||
}));
|
||||
const { runId } = await scanner.startMonitor({ hosts, intervalSec });
|
||||
const sessions = (protocol.monitorSessions ??= []);
|
||||
sessions.push({
|
||||
id: uid(),
|
||||
name: 'Überwachung ' + fmtDateTime(Date.now()),
|
||||
startedAt: Date.now(),
|
||||
intervalSec,
|
||||
targets: hosts,
|
||||
events: [],
|
||||
status: 'running',
|
||||
runId,
|
||||
});
|
||||
session = sessions[sessions.length - 1];
|
||||
attachListener(runId);
|
||||
await persist();
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : 'Monitor-Start fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!session?.runId || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const res = await scanner.stopMonitor({ runId: session.runId });
|
||||
session.events = res.events.map(toStored);
|
||||
session.status = 'stopped';
|
||||
session.endedAt = Date.now();
|
||||
offEvent?.();
|
||||
offEvent = undefined;
|
||||
await persist();
|
||||
toast.show('Überwachung beendet', 'success');
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : 'Monitor-Stopp fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
function fmtDateTime(ts: number): string {
|
||||
return new Date(ts).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
function fmtDuration(sec: number): string {
|
||||
if (sec < 60) return sec + ' s';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return s ? `${m} min ${s} s` : `${m} min`;
|
||||
}
|
||||
function labelFor(s: DeviceMonitorSession, ip: string): string {
|
||||
return s.targets.find((t) => t.ip === ip)?.label || ip;
|
||||
}
|
||||
function downCount(s: DeviceMonitorSession): number {
|
||||
return s.events.filter((e) => e.type === 'down').length;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet eventRow(s: DeviceMonitorSession, e: MonitorEvent)}
|
||||
<div class="mb-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
class="h-2 w-2 shrink-0 rounded-full {e.type === 'down' ? 'bg-red-500' : 'bg-emerald-500'}"
|
||||
></span>
|
||||
<span class="w-16 shrink-0 text-zinc-500">{fmtTime(e.ts)}</span>
|
||||
<span class="min-w-0 flex-1 truncate">{labelFor(s, e.ip)}</span>
|
||||
<span class="shrink-0 {e.type === 'down' ? 'text-red-400' : 'text-emerald-400'}">
|
||||
{e.type === 'down'
|
||||
? 'Ausfall'
|
||||
: 'wieder da' + (e.durationSec ? ' (' + fmtDuration(e.durationSec) + ')' : '')}
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if protocol}
|
||||
<AppHeader title="Geräte-Monitor" subtitle={protocol.label} back />
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
{#if running && session}
|
||||
<!-- Laufende Überwachung -->
|
||||
<div class="rounded-lg bg-zinc-900 p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2.5 w-2.5 animate-pulse rounded-full bg-emerald-500"></span>
|
||||
<span class="text-sm font-medium">Überwachung läuft</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-zinc-500">
|
||||
{session.targets.length} Geräte · alle {session.intervalSec}s · seit
|
||||
{fmtTime(session.startedAt)}
|
||||
</p>
|
||||
<button
|
||||
class="mt-3 w-full rounded-lg bg-red-600 py-2 text-sm font-semibold text-white active:bg-red-700 disabled:opacity-50"
|
||||
onclick={stop}
|
||||
disabled={busy}
|
||||
>
|
||||
Überwachung beenden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-1 mt-3 text-sm font-semibold text-zinc-300">
|
||||
Ereignisse ({session.events.length})
|
||||
</h2>
|
||||
{#if session.events.length === 0}
|
||||
<p class="text-xs text-zinc-500">Noch kein Ausfall — alle Geräte erreichbar.</p>
|
||||
{/if}
|
||||
{#each [...session.events].reverse() as e (e.ts + '-' + e.ip)}
|
||||
{@render eventRow(session, e)}
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Einrichtung -->
|
||||
{#if protocol.devices.length === 0}
|
||||
<p class="text-sm text-zinc-500">
|
||||
Noch keine Geräte — bitte zuerst einen IP-Scan ausführen.
|
||||
</p>
|
||||
{:else}
|
||||
<h2 class="mb-1 text-sm font-semibold text-zinc-300">Geräte zum Überwachen wählen</h2>
|
||||
<DeviceMultiSelect devices={protocol.devices} bind:selected />
|
||||
|
||||
<label class="mt-3 flex flex-col gap-1 text-xs text-zinc-400">
|
||||
Prüfintervall
|
||||
<select
|
||||
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
|
||||
bind:value={intervalSec}
|
||||
>
|
||||
<option value={15}>alle 15 Sekunden</option>
|
||||
<option value={30}>alle 30 Sekunden</option>
|
||||
<option value={60}>alle 1 Minute</option>
|
||||
<option value={120}>alle 2 Minuten</option>
|
||||
<option value={300}>alle 5 Minuten</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="mt-3 w-full rounded-lg bg-emerald-600 py-2 text-sm font-semibold text-white active:bg-emerald-700 disabled:opacity-50"
|
||||
onclick={start}
|
||||
disabled={busy || selected.length === 0}
|
||||
>
|
||||
Überwachung starten ({selected.length})
|
||||
</button>
|
||||
<p class="mt-2 text-[11px] leading-tight text-zinc-500">
|
||||
Läuft auch bei ausgeschaltetem Display weiter und meldet jeden Ausfall mit
|
||||
Uhrzeit — gut, um sporadisch ausfallende Geräte einzukreisen.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Frühere Überwachungen -->
|
||||
{#if pastSessions.length > 0}
|
||||
<h2 class="mb-1 mt-4 text-sm font-semibold text-zinc-300">Frühere Überwachungen</h2>
|
||||
{#each pastSessions as s (s.id)}
|
||||
<div class="mb-1.5 rounded-lg bg-zinc-900">
|
||||
<button
|
||||
class="flex w-full items-center justify-between gap-2 p-2.5 text-left"
|
||||
onclick={() => (expanded = expanded === s.id ? null : s.id)}
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">{s.name}</div>
|
||||
<div class="text-[11px] text-zinc-500">
|
||||
{s.targets.length} Geräte · {downCount(s)} Ausfälle · {fmtDateTime(s.startedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
class="shrink-0 text-zinc-500 {expanded === s.id ? 'rotate-180' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if expanded === s.id}
|
||||
<div class="border-t border-zinc-800 p-2.5">
|
||||
{#if s.events.length === 0}
|
||||
<p class="text-xs text-zinc-500">Kein Ausfall während der Überwachung.</p>
|
||||
{/if}
|
||||
{#each [...s.events].reverse() as e (e.ts + '-' + e.ip)}
|
||||
{@render eventRow(s, e)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-screen items-center justify-center text-zinc-500">Lädt …</div>
|
||||
{/if}
|
||||
Loading…
Reference in a new issue