Neues Werkzeug: Geräte-Monitor (Dauerüberwachung) [apk]
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:
Eduard Wisch 2026-05-19 23:10:54 +02:00
parent 9ee9c954b2
commit d2df3ee929
10 changed files with 869 additions and 1 deletions

View file

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

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

View file

@ -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 erreichbarnicht 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 */
/* --------------------------------------------------------------------- */

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

View file

@ -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 erreichbarnicht 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 */
/* --------------------------------------------------------------------- */

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

View file

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

View file

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

View file

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

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