Compare commits

...

7 commits

Author SHA1 Message Date
d2df3ee929 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>
2026-05-19 23:12:26 +02:00
9ee9c954b2 Neues Werkzeug: IP-Konflikt-Prüfung
- arpConflictScan (Plugin): pingt das Subnetz über mehrere Runden und liest
  je Runde die ARP-Tabelle; mehrere MACs pro IP = Konflikt. Kein Root nötig
- Erkennt den Fall, dass /proc/net/arp nicht lesbar ist (Android-Limit) und
  meldet das ehrlich, statt fälschlich Entwarnung zu geben
- ipconflict.ts: neues Protokoll-Tool, in der Tool-Registry eingetragen —
  listet betroffene IPs samt der konkurrierenden MAC-Adressen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:00:32 +02:00
fd75748cb9 IP-Scans als benannte Snapshots speichern
- "Scan speichern" friert den aktuellen Geräte-Stand als benannten
  Snapshot ein (saveScan in protocols.ts, tiefe Kopie)
- Abschnitt "Gespeicherte Scans" auf der Protokollseite: aufklappbare
  Liste mit Name, Subnetz, Datum und Gerätezahl; Snapshot zeigt die
  damals gefundenen Geräte (DeviceCard read-only)
- Scan löschen per ConfirmDialog
- Snapshots bleiben am Auftrag erhalten, auch über spätere Re-Scans hinweg

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:56:05 +02:00
484b5f96fa Geräte als Favoriten markieren und benennen
- Favoriten-Stern je Gerät; favorisierte Geräte werden in der Liste oben
  einsortiert. Favorit + eigener Name bleiben im Protokoll erhalten, auch
  ohne gespeicherten Scan-Snapshot
- Gerät umbenennen (customName) über neuen TextPromptDialog
- toggleFavorite / renameDevice in protocols.ts
- TextPromptDialog + ConfirmDialog: schlanke Modal-Komponenten als Ersatz
  für die verbotenen Browser-prompt()/confirm(); melden sich als Overlay an
  (Hardware-Backbutton schließt sie)
- Protokoll-Löschen nutzt jetzt ConfirmDialog statt confirm()

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:52:23 +02:00
50793e4e5d App-Lifecycle: Back-Button, Resume letzte Position, sicheres Speichern
- Resume: jede Navigation wird gemerkt; nach App-Neustart (oder wenn Android
  den Prozess beim App-Wechsel beendet hat) öffnet die App wieder genau dort,
  wo der Benutzer war — inklusive offenem Protokoll
- Protokoll wird beim Verlassen der Seite (Back-Tap/Navigation) und beim
  Wechsel in den Hintergrund automatisch gesichert — auch Eingaben, die noch
  nicht per onblur gespeichert wurden, gehen nicht mehr verloren
- Hardware-Backbutton schließt einen offenen Werkzeug-Dialog, statt gleich
  die Seite zu verlassen (neue Overlay-Registry overlay.svelte.ts)
- backButton.svelte.ts: PluginListenerHandle korrekt aus @capacitor/core
  importiert (war fälschlich @capacitor/app — Svelte-Check-Fehler behoben)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:47:32 +02:00
1a0f1dc5ca IP-Scan: Geräte deutlich umfassender erkennen
- NetBIOS-Namensabfrage (UDP 137) je Host integriert
- mdnsScan: neue Plugin-Methode, mDNS/Bonjour via NsdManager — findet
  Drucker, Kameras, Chromecast, AirPlay; liefert Namen + Diensttypen
- Quick-Port-Probe (22/80/443/554/9100 …) speist eine deviceType-Heuristik
  (Kamera, Drucker, Router, Switch, NAS, Wallbox, Server …)
- OUI-Vendor-Tabelle von 6 auf ~150 kuratierte Einträge erweitert
- ipscan.ts führt IP-Scan + mDNS pro IP zusammen, mDNS-only-Geräte ergänzt
- neue DeviceCard-Komponente: zeigt Geräteart-Badge, offene Ports,
  mDNS-Dienste, mac, NetBIOS-Name; ersetzt die Inline-Geräteliste
- upsertDevice überschreibt vorhandene Daten nicht mehr mit undefined
  (Favorit/eigener Name bleiben bei magerem Re-Scan erhalten)
- Manifest: CHANGE_WIFI_MULTICAST_STATE für die mDNS-Suche

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:42:25 +02:00
2a75ad96b2 Auftragsliste: lokale Scan-Tätigkeit nach oben + Geräte-Feld-Fundament
- Auftragsliste sortiert Aufträge mit lokalen Protokollen/Scans nach oben
  (lokale updatedAt), da Dolibarr-tms bei reiner App-Tätigkeit unverändert
  bleibt; Standard wieder "nur aktive Aufträge"
- Datumszeile zeigt "zuletzt bearb." aus lokalem Protokoll, sonst Server-tms
- Protokollzähler-Badge berücksichtigt auch lokale (noch nicht gesyncte) Protokolle
- types.ts: neue optionale Felder für kommende Geräte-Features
  (Device: isFavorite/customName/openPorts/netbiosName/mdnsName/mdnsServices;
  neu SavedScan, MonitorEvent, DeviceMonitorSession; Protocol.savedScans/monitorSessions)
- db.ts: normalizeProtocol ergänzt fehlende Arrays beim Laden alter Protokolle

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:30:30 +02:00
23 changed files with 2576 additions and 119 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 -->
@ -41,7 +47,12 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Multicast-Lock für die mDNS-/Bonjour-Dienstsuche (NsdManager) -->
<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,13 +1,17 @@
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
import android.net.Uri
import android.net.nsd.NsdManager
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
@ -27,6 +31,8 @@ import java.io.BufferedReader
import java.io.File
import java.io.FileOutputStream
import java.io.FileReader
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.HttpURLConnection
import java.net.Inet4Address
import java.net.InetAddress
@ -34,6 +40,9 @@ import java.net.InetSocketAddress
import java.net.Socket
import java.net.URL
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* NetDiagScanner natives Scan-Plugin der NetDiag-App.
@ -150,14 +159,36 @@ class NetDiagScannerPlugin : Plugin() {
}.awaitAll().filterNotNull()
}
val arp = readArpTable()
// Pro lebendem Host parallel anreichern: Reverse-DNS, NetBIOS-Name,
// Quick-Port-Probe (für die Geräteart-Heuristik).
val enriched = withContext(Dispatchers.IO) {
alive.map { ip ->
async {
val hostname = try {
val n = InetAddress.getByName(ip).canonicalHostName
if (n != ip) n else ""
} catch (_: Exception) { "" }
EnrichedHost(ip, hostname, netbiosName(ip), quickPortProbe(ip))
}
}.awaitAll()
}
val devices = JSArray()
for (ip in alive) {
val dev = JSObject().put("ip", ip)
arp[ip]?.let { dev.put("mac", it).put("vendor", ouiVendor(it)) }
try {
val name = InetAddress.getByName(ip).canonicalHostName
if (name != ip) dev.put("hostname", name)
} catch (_: Exception) { }
for (h in enriched) {
val dev = JSObject().put("ip", h.ip)
val mac = arp[h.ip]
val vendor = mac?.let { ouiVendor(it) } ?: ""
if (mac != null) dev.put("mac", mac)
if (vendor.isNotEmpty()) dev.put("vendor", vendor)
if (h.hostname.isNotEmpty()) dev.put("hostname", h.hostname)
if (!h.netbios.isNullOrEmpty()) dev.put("netbiosName", h.netbios)
if (h.openPorts.isNotEmpty()) {
val pa = JSArray()
h.openPorts.sorted().forEach { pa.put(it) }
dev.put("openPorts", pa)
}
val nameHint = h.hostname.ifEmpty { h.netbios ?: "" }
val type = guessDeviceType(vendor, nameHint, h.openPorts)
if (type.isNotEmpty()) dev.put("deviceType", type)
devices.put(dev)
}
resolve(call, JSObject().put("devices", devices))
@ -167,6 +198,215 @@ class NetDiagScannerPlugin : Plugin() {
}
}
/** Zwischenergebnis der parallelen Geräte-Anreicherung im IP-Scan */
private data class EnrichedHost(
val ip: String,
val hostname: String,
val netbios: String?,
val openPorts: List<Int>,
)
/**
* Schneller TCP-Connect-Test auf einige Schlüsselports speist die
* Geräteart-Heuristik (z.B. 554 Kamera, 9100 Drucker). 500 ms Timeout.
*/
private suspend fun quickPortProbe(ip: String): List<Int> {
val probe = listOf(22, 23, 80, 443, 445, 554, 1883, 3389, 8000, 9100)
return withContext(Dispatchers.IO) {
probe.map { port ->
async {
try {
Socket().use { it.connect(InetSocketAddress(ip, port), 500) }
port
} catch (_: Exception) {
null
}
}
}.awaitAll().filterNotNull()
}
}
/**
* NetBIOS-Namensabfrage (NBSTAT, UDP 137) liefert den Workstation-Namen
* vieler Windows-Rechner, NAS-Geräte und Kameras. Kein Root nötig.
*/
private fun netbiosName(ip: String): String? {
// NBSTAT-„Node Status Request" für den Wildcard-Namen "*" (50 Byte).
val query = byteArrayOf(
0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x20,
0x43, 0x4B, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x00, 0x00, 0x21, 0x00, 0x01,
)
return try {
DatagramSocket().use { sock ->
sock.soTimeout = 600
sock.send(DatagramPacket(query, query.size, InetAddress.getByName(ip), 137))
val buf = ByteArray(512)
val resp = DatagramPacket(buf, buf.size)
sock.receive(resp)
val data = resp.data
if (resp.length < 57) return null
val numNames = data[56].toInt() and 0xFF
for (i in 0 until numNames) {
val base = 57 + i * 18
if (base + 18 > resp.length) break
val suffix = data[base + 15].toInt() and 0xFF
val isGroup = (data[base + 16].toInt() and 0x80) != 0
// Suffix 0x00 + kein Gruppen-Flag = der eigentliche Gerätename
if (suffix == 0x00 && !isGroup) {
val name = String(data, base, 15, Charsets.US_ASCII).trim()
if (name.isNotEmpty()) return name
}
}
null
}
} catch (_: Exception) {
null
}
}
/**
* Geräteart aus Hersteller, Name und offenen Ports schätzen.
* Best-Effort-Heuristik leerer String, wenn nichts Eindeutiges erkennbar.
*/
private fun guessDeviceType(vendor: String, name: String, ports: List<Int>): String {
val v = vendor.lowercase()
val n = name.lowercase()
// 1. Eindeutige Hersteller
if (v.contains("axis") || v.contains("hikvision") || v.contains("dahua")) return "Kamera"
if (v.contains("avm")) return "Router"
if (v.contains("sonos")) return "Lautsprecher"
if (v.contains("synology") || v.contains("qnap")) return "NAS"
if (v.contains("raspberry")) return "Raspberry Pi"
if (v.contains("espressif")) return "IoT-Gerät"
// 2. Namensmuster
if (n.contains("camera") || n.contains("kamera") || n.contains("ipcam") ||
n.contains("nvr") || n.contains("axis") || n.contains("hikvision")) return "Kamera"
if (n.contains("printer") || n.contains("drucker")) return "Drucker"
if (n.contains("fritz") || n.contains("router") || n.contains("gateway")) return "Router"
if (n.contains("switch")) return "Switch"
if (n.contains("wallbox") || n.contains("keba") || n.contains("charger")) return "Wallbox"
if (n.contains("nas") || n.contains("synology") || n.contains("diskstation")) return "NAS"
// 3. Portmuster
return when {
554 in ports -> "Kamera"
9100 in ports || 515 in ports -> "Drucker"
3389 in ports || (445 in ports && 1883 !in ports) -> "Windows-PC"
1883 in ports || 502 in ports -> "IoT/SPS"
22 in ports && (80 in ports || 443 in ports) -> "Server"
22 in ports -> "Linux-Gerät"
else -> ""
}
}
/* --------------------------------------------------------------------- */
/* mDNS / Bonjour — Drucker, Kameras, Chromecast, AirPlay … */
/* --------------------------------------------------------------------- */
/**
* mDNS-Dienstsuche über NsdManager. Liefert pro IP den Bonjour-Namen und
* die angebotenen Diensttypen. Kein Root, keine Berechtigung nötig nur
* ein Multicast-Lock fürs WLAN. Ergebnis ist Best-Effort (Timeout-begrenzt).
*/
@PluginMethod
fun mdnsScan(call: PluginCall) {
val timeoutMs = (call.getInt("timeoutMs") ?: 4000).toLong()
io.launch {
try {
val found = discoverMdns(timeoutMs)
val arr = JSArray()
for ((ip, info) in found) {
val services = JSArray()
info.services.forEach { services.put(it) }
arr.put(JSObject()
.put("ip", ip)
.put("name", info.name)
.put("services", services))
}
resolve(call, JSObject().put("devices", arr))
} catch (e: Exception) {
call.reject("mdnsScan: ${e.message}")
}
}
}
private class MdnsInfo {
var name: String = ""
val services: MutableSet<String> = ConcurrentHashMap.newKeySet()
}
@Suppress("DEPRECATION")
private fun discoverMdns(timeoutMs: Long): Map<String, MdnsInfo> {
val nsd = context.applicationContext
.getSystemService(Context.NSD_SERVICE) as NsdManager
val wifi = context.applicationContext
.getSystemService(Context.WIFI_SERVICE) as WifiManager
val types = listOf(
"_printer._tcp.", "_ipp._tcp.", "_pdl-datastream._tcp.", "_googlecast._tcp.",
"_airplay._tcp.", "_raop._tcp.", "_http._tcp.", "_workstation._tcp.",
"_smb._tcp.", "_rtsp._tcp.", "_axis-video._tcp.", "_hap._tcp.", "_ssh._tcp.",
)
val result = ConcurrentHashMap<String, MdnsInfo>()
val pending = ConcurrentLinkedQueue<NsdServiceInfo>()
val listeners = ArrayList<NsdManager.DiscoveryListener>()
val mlock = wifi.createMulticastLock("netdiag-mdns").apply {
setReferenceCounted(true)
try { acquire() } catch (_: Exception) { }
}
try {
for (type in types) {
val l = object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(s: String?, e: Int) {}
override fun onStopDiscoveryFailed(s: String?, e: Int) {}
override fun onDiscoveryStarted(s: String?) {}
override fun onDiscoveryStopped(s: String?) {}
override fun onServiceFound(info: NsdServiceInfo) { pending.add(info) }
override fun onServiceLost(info: NsdServiceInfo) {}
}
try {
nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, l)
listeners.add(l)
} catch (_: Exception) { }
}
// Gefundene Dienste seriell auflösen — NsdManager.resolveService
// verträgt keine parallelen Aufrufe.
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val info = pending.poll()
if (info == null) {
Thread.sleep(100)
continue
}
val lock = CountDownLatch(1)
try {
nsd.resolveService(info, object : NsdManager.ResolveListener {
override fun onResolveFailed(s: NsdServiceInfo?, e: Int) { lock.countDown() }
override fun onServiceResolved(s: NsdServiceInfo) {
val host = s.host?.hostAddress
if (host != null) {
val mi = result.getOrPut(host) { MdnsInfo() }
if (mi.name.isEmpty()) mi.name = s.serviceName ?: ""
val t = (s.serviceType ?: "").trim('.', ' ')
if (t.isNotEmpty()) mi.services.add(t)
}
lock.countDown()
}
})
lock.await(1500, TimeUnit.MILLISECONDS)
} catch (_: Exception) { }
}
} finally {
for (l in listeners) {
try { nsd.stopServiceDiscovery(l) } catch (_: Exception) { }
}
try { if (mlock.isHeld) mlock.release() } catch (_: Exception) { }
}
return result
}
/**
* Alle Host-IPs (als Int) eines CIDR-Subnetzes.
* "192.168.1.0/24" -> .1 bis .254, "10.0.0.0/22" -> 1022 Hosts usw.
@ -212,6 +452,73 @@ class NetDiagScannerPlugin : Plugin() {
private fun intToIpv4(i: Int): String =
"${(i shr 24) and 0xFF}.${(i shr 16) and 0xFF}.${(i shr 8) and 0xFF}.${i and 0xFF}"
/* --------------------------------------------------------------------- */
/* IP-Konflikt-Prüfung — eine IP, von zwei Geräten gleichzeitig benutzt */
/* --------------------------------------------------------------------- */
/**
* Sucht IP-Adressen, die im Netz von mehr als einem Gerät benutzt werden.
*
* Ohne Root lässt sich kein Roh-ARP mitschneiden der praktikable Weg:
* über mehrere Runden das Subnetz anpingen (das erzwingt jeweils eine
* ARP-Auflösung) und nach jeder Runde /proc/net/arp auslesen. Erscheint für
* dieselbe IP über die Runden hinweg mehr als eine MAC, nutzen zwei Geräte
* diese Adresse Konflikt.
*
* Risiko: /proc/net/arp kann auf neueren Android-Versionen leer sein
* dann meldet das Ergebnis `arpAvailable = false`.
*/
@PluginMethod
fun arpConflictScan(call: PluginCall) {
val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt")
val rounds = (call.getInt("rounds") ?: 4).coerceIn(2, 10)
val delayMs = (call.getInt("delayMs") ?: 600).coerceIn(0, 5000).toLong()
val hosts = hostsInSubnet(subnet)
if (hosts.isEmpty()) {
return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet")
}
io.launch {
try {
val seen = HashMap<String, MutableSet<String>>()
var arpEverFilled = false
for (r in 0 until rounds) {
withContext(Dispatchers.IO) {
hosts.map { ipInt ->
async {
try {
InetAddress.getByName(intToIpv4(ipInt)).isReachable(300)
} catch (_: Exception) {
false
}
}
}.awaitAll()
}
val arp = readArpTable()
if (arp.isNotEmpty()) arpEverFilled = true
for ((ip, mac) in arp) {
seen.getOrPut(ip) { HashSet() }.add(mac)
}
if (r < rounds - 1 && delayMs > 0) Thread.sleep(delayMs)
}
val conflicts = JSArray()
for ((ip, macs) in seen) {
if (macs.size > 1) {
val macArr = JSArray()
macs.forEach { macArr.put(it) }
conflicts.put(JSObject().put("ip", ip).put("macs", macArr))
}
}
resolve(call, JSObject()
.put("conflicts", conflicts)
.put("checked", seen.size)
.put("rounds", rounds)
.put("arpAvailable", arpEverFilled))
} catch (e: Exception) {
call.reject("arpConflictScan: ${e.message}")
}
}
}
/* --------------------------------------------------------------------- */
/* Port-Scan */
/* --------------------------------------------------------------------- */
@ -520,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 */
/* --------------------------------------------------------------------- */
@ -692,10 +1133,86 @@ class NetDiagScannerPlugin : Plugin() {
private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0
companion object {
/** Kleiner OUI-Auszug — bei Bedarf vollständige IEEE-OUI-Datei einbinden. */
private val OUI = mapOf(
"3810D5" to "AVM", "DCA632" to "Raspberry Pi", "B827EB" to "Raspberry Pi",
"001CC0" to "Intel", "F0B479" to "Apple", "D8EB97" to "TP-Link"
)
/**
* Kuratierter OUI-Auszug der gängigsten Hersteller im Handwerksumfeld
* (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf
* Vollständigkeit unbekannte MACs liefern einfach einen leeren Vendor.
*/
private val OUI: Map<String, String> = buildMap {
// AVM / FRITZ!Box
listOf("00040E", "3810D5", "5C4979", "C80E14", "E0286D", "3410F4")
.forEach { put(it, "AVM") }
// TP-Link
listOf("003192", "50C7BF", "D8EB97", "EC086B", "A42BB0", "1CFA68",
"14CC20", "B0487A", "6032B1", "5091E3").forEach { put(it, "TP-Link") }
// Netgear
listOf("000FB5", "20E52A", "00146C", "001B2F", "001E2A", "00223F",
"0024B2", "28C68E", "A040A0", "3C3786", "6CB0CE").forEach { put(it, "Netgear") }
// Ubiquiti
listOf("00156D", "0418D6", "24A43C", "44D9E7", "687251", "788A20",
"802AA8", "B4FB0E", "DC9FDB", "F09FC2", "FCECDA", "74ACB9",
"18E829", "944A0C", "E063DA").forEach { put(it, "Ubiquiti") }
// Cisco
listOf("00000C", "001AA1", "0023AC", "F09E63").forEach { put(it, "Cisco") }
// MikroTik
listOf("000C42", "4C5E0C", "6C3B6B", "CC2DE0", "E48D8C", "64D154",
"B869F4", "18FD74", "2CC81B", "DC2C6E", "744D28", "488F5A")
.forEach { put(it, "MikroTik") }
// D-Link
listOf("001195", "1CBDB9", "001B11", "001CF0", "14D64D", "28107B",
"78542E", "B8A386", "C8BE19").forEach { put(it, "D-Link") }
// Hikvision (Kameras)
listOf("4419B6", "BCAD28", "C05627", "2857BE", "4CBD8F", "54C415",
"A41437", "B4A382", "18680F").forEach { put(it, "Hikvision") }
// Dahua (Kameras)
listOf("3CEF8C", "9002A9", "14A78B", "E0508B", "08EDED", "24526A",
"6C1C71").forEach { put(it, "Dahua") }
// Axis (Kameras)
listOf("00408C", "ACCC8E", "B8A44F", "E82725").forEach { put(it, "Axis") }
// Drucker
listOf("001E0B", "3CD92B", "9457A5", "001321", "A0481C", "308D99",
"380025", "00215A", "9C8E99", "EC8EB5", "705A0F", "B499BA")
.forEach { put(it, "HP") }
listOf("008077", "30055C", "001BA9").forEach { put(it, "Brother") }
listOf("002673", "88873D", "F48139", "2C9EFC", "001E8F", "B08E1A")
.forEach { put(it, "Canon") }
listOf("000048", "0026AB", "A4EE57", "64EB8C", "44D244", "381A52")
.forEach { put(it, "Epson") }
// Apple
listOf("F0B479", "3C0754", "A4B197", "DC2B2A", "040CCE", "7CD1C3",
"F0DBF8", "88665A", "28CFE9", "001EC2", "002500", "D8A25E")
.forEach { put(it, "Apple") }
// Samsung
listOf("002566", "8425DB", "5CF6DC", "0017C9", "001A8A", "3423BA",
"781FDB", "8C7712", "BC1485", "5C0A5B").forEach { put(it, "Samsung") }
// Espressif (ESP32/ESP8266 — Shelly, Tasmota, viele IoT-Geräte)
listOf("240AC4", "30AEA4", "246F28", "84CCA8", "A020A6", "7C9EBD",
"8CAAB5", "3C6105", "24B2DE", "DC4F22", "84F3EB", "BCDDC2",
"A4CF12", "CC50E3", "2462AB", "18FE34", "5CCF7F", "600194",
"2C3AE8", "ECFABC", "B4E62D", "9038C9").forEach { put(it, "Espressif") }
// Raspberry Pi
listOf("B827EB", "DCA632", "E45F01", "28CDC1", "D83ADD", "2CCF67")
.forEach { put(it, "Raspberry Pi") }
// Intel
listOf("001CC0", "3CA9F4", "A0A8CD", "8C1645", "7CB27D", "9C305B",
"0013E8", "5C514F", "94659C").forEach { put(it, "Intel") }
// Sonos
listOf("000E58", "5CAAFD", "949F3E", "B8E937", "347E5C", "48A6B8",
"542A1B").forEach { put(it, "Sonos") }
// NAS
listOf("001132", "9009D0").forEach { put(it, "Synology") }
listOf("00089B", "245EBE").forEach { put(it, "QNAP") }
// Amazon (Echo / Fire)
listOf("8871E5", "FCA183", "44650D", "F0272D", "68DBF5", "50DCE7",
"AC63BE", "40B4CD", "0C47C9", "74C246").forEach { put(it, "Amazon") }
// Google / Nest / Chromecast
listOf("F4F5D8", "F4F5E8", "30FD38", "6CADF8", "546009", "A47733",
"1CF29A", "3C5AB4", "D86C63", "48D6D5").forEach { put(it, "Google") }
// Industrie / Gebäudetechnik
listOf("000E8C", "001B1B", "286336", "001C06", "8CF319")
.forEach { put(it, "Siemens") }
put("00A057", "Lancom")
put("001A22", "eQ-3 / Homematic")
}
}
}

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,13 +1,17 @@
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
import android.net.Uri
import android.net.nsd.NsdManager
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
@ -27,6 +31,8 @@ import java.io.BufferedReader
import java.io.File
import java.io.FileOutputStream
import java.io.FileReader
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.HttpURLConnection
import java.net.Inet4Address
import java.net.InetAddress
@ -34,6 +40,9 @@ import java.net.InetSocketAddress
import java.net.Socket
import java.net.URL
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* NetDiagScanner natives Scan-Plugin der NetDiag-App.
@ -150,14 +159,36 @@ class NetDiagScannerPlugin : Plugin() {
}.awaitAll().filterNotNull()
}
val arp = readArpTable()
// Pro lebendem Host parallel anreichern: Reverse-DNS, NetBIOS-Name,
// Quick-Port-Probe (für die Geräteart-Heuristik).
val enriched = withContext(Dispatchers.IO) {
alive.map { ip ->
async {
val hostname = try {
val n = InetAddress.getByName(ip).canonicalHostName
if (n != ip) n else ""
} catch (_: Exception) { "" }
EnrichedHost(ip, hostname, netbiosName(ip), quickPortProbe(ip))
}
}.awaitAll()
}
val devices = JSArray()
for (ip in alive) {
val dev = JSObject().put("ip", ip)
arp[ip]?.let { dev.put("mac", it).put("vendor", ouiVendor(it)) }
try {
val name = InetAddress.getByName(ip).canonicalHostName
if (name != ip) dev.put("hostname", name)
} catch (_: Exception) { }
for (h in enriched) {
val dev = JSObject().put("ip", h.ip)
val mac = arp[h.ip]
val vendor = mac?.let { ouiVendor(it) } ?: ""
if (mac != null) dev.put("mac", mac)
if (vendor.isNotEmpty()) dev.put("vendor", vendor)
if (h.hostname.isNotEmpty()) dev.put("hostname", h.hostname)
if (!h.netbios.isNullOrEmpty()) dev.put("netbiosName", h.netbios)
if (h.openPorts.isNotEmpty()) {
val pa = JSArray()
h.openPorts.sorted().forEach { pa.put(it) }
dev.put("openPorts", pa)
}
val nameHint = h.hostname.ifEmpty { h.netbios ?: "" }
val type = guessDeviceType(vendor, nameHint, h.openPorts)
if (type.isNotEmpty()) dev.put("deviceType", type)
devices.put(dev)
}
resolve(call, JSObject().put("devices", devices))
@ -167,6 +198,215 @@ class NetDiagScannerPlugin : Plugin() {
}
}
/** Zwischenergebnis der parallelen Geräte-Anreicherung im IP-Scan */
private data class EnrichedHost(
val ip: String,
val hostname: String,
val netbios: String?,
val openPorts: List<Int>,
)
/**
* Schneller TCP-Connect-Test auf einige Schlüsselports speist die
* Geräteart-Heuristik (z.B. 554 Kamera, 9100 Drucker). 500 ms Timeout.
*/
private suspend fun quickPortProbe(ip: String): List<Int> {
val probe = listOf(22, 23, 80, 443, 445, 554, 1883, 3389, 8000, 9100)
return withContext(Dispatchers.IO) {
probe.map { port ->
async {
try {
Socket().use { it.connect(InetSocketAddress(ip, port), 500) }
port
} catch (_: Exception) {
null
}
}
}.awaitAll().filterNotNull()
}
}
/**
* NetBIOS-Namensabfrage (NBSTAT, UDP 137) liefert den Workstation-Namen
* vieler Windows-Rechner, NAS-Geräte und Kameras. Kein Root nötig.
*/
private fun netbiosName(ip: String): String? {
// NBSTAT-„Node Status Request" für den Wildcard-Namen "*" (50 Byte).
val query = byteArrayOf(
0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x20,
0x43, 0x4B, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x00, 0x00, 0x21, 0x00, 0x01,
)
return try {
DatagramSocket().use { sock ->
sock.soTimeout = 600
sock.send(DatagramPacket(query, query.size, InetAddress.getByName(ip), 137))
val buf = ByteArray(512)
val resp = DatagramPacket(buf, buf.size)
sock.receive(resp)
val data = resp.data
if (resp.length < 57) return null
val numNames = data[56].toInt() and 0xFF
for (i in 0 until numNames) {
val base = 57 + i * 18
if (base + 18 > resp.length) break
val suffix = data[base + 15].toInt() and 0xFF
val isGroup = (data[base + 16].toInt() and 0x80) != 0
// Suffix 0x00 + kein Gruppen-Flag = der eigentliche Gerätename
if (suffix == 0x00 && !isGroup) {
val name = String(data, base, 15, Charsets.US_ASCII).trim()
if (name.isNotEmpty()) return name
}
}
null
}
} catch (_: Exception) {
null
}
}
/**
* Geräteart aus Hersteller, Name und offenen Ports schätzen.
* Best-Effort-Heuristik leerer String, wenn nichts Eindeutiges erkennbar.
*/
private fun guessDeviceType(vendor: String, name: String, ports: List<Int>): String {
val v = vendor.lowercase()
val n = name.lowercase()
// 1. Eindeutige Hersteller
if (v.contains("axis") || v.contains("hikvision") || v.contains("dahua")) return "Kamera"
if (v.contains("avm")) return "Router"
if (v.contains("sonos")) return "Lautsprecher"
if (v.contains("synology") || v.contains("qnap")) return "NAS"
if (v.contains("raspberry")) return "Raspberry Pi"
if (v.contains("espressif")) return "IoT-Gerät"
// 2. Namensmuster
if (n.contains("camera") || n.contains("kamera") || n.contains("ipcam") ||
n.contains("nvr") || n.contains("axis") || n.contains("hikvision")) return "Kamera"
if (n.contains("printer") || n.contains("drucker")) return "Drucker"
if (n.contains("fritz") || n.contains("router") || n.contains("gateway")) return "Router"
if (n.contains("switch")) return "Switch"
if (n.contains("wallbox") || n.contains("keba") || n.contains("charger")) return "Wallbox"
if (n.contains("nas") || n.contains("synology") || n.contains("diskstation")) return "NAS"
// 3. Portmuster
return when {
554 in ports -> "Kamera"
9100 in ports || 515 in ports -> "Drucker"
3389 in ports || (445 in ports && 1883 !in ports) -> "Windows-PC"
1883 in ports || 502 in ports -> "IoT/SPS"
22 in ports && (80 in ports || 443 in ports) -> "Server"
22 in ports -> "Linux-Gerät"
else -> ""
}
}
/* --------------------------------------------------------------------- */
/* mDNS / Bonjour — Drucker, Kameras, Chromecast, AirPlay … */
/* --------------------------------------------------------------------- */
/**
* mDNS-Dienstsuche über NsdManager. Liefert pro IP den Bonjour-Namen und
* die angebotenen Diensttypen. Kein Root, keine Berechtigung nötig nur
* ein Multicast-Lock fürs WLAN. Ergebnis ist Best-Effort (Timeout-begrenzt).
*/
@PluginMethod
fun mdnsScan(call: PluginCall) {
val timeoutMs = (call.getInt("timeoutMs") ?: 4000).toLong()
io.launch {
try {
val found = discoverMdns(timeoutMs)
val arr = JSArray()
for ((ip, info) in found) {
val services = JSArray()
info.services.forEach { services.put(it) }
arr.put(JSObject()
.put("ip", ip)
.put("name", info.name)
.put("services", services))
}
resolve(call, JSObject().put("devices", arr))
} catch (e: Exception) {
call.reject("mdnsScan: ${e.message}")
}
}
}
private class MdnsInfo {
var name: String = ""
val services: MutableSet<String> = ConcurrentHashMap.newKeySet()
}
@Suppress("DEPRECATION")
private fun discoverMdns(timeoutMs: Long): Map<String, MdnsInfo> {
val nsd = context.applicationContext
.getSystemService(Context.NSD_SERVICE) as NsdManager
val wifi = context.applicationContext
.getSystemService(Context.WIFI_SERVICE) as WifiManager
val types = listOf(
"_printer._tcp.", "_ipp._tcp.", "_pdl-datastream._tcp.", "_googlecast._tcp.",
"_airplay._tcp.", "_raop._tcp.", "_http._tcp.", "_workstation._tcp.",
"_smb._tcp.", "_rtsp._tcp.", "_axis-video._tcp.", "_hap._tcp.", "_ssh._tcp.",
)
val result = ConcurrentHashMap<String, MdnsInfo>()
val pending = ConcurrentLinkedQueue<NsdServiceInfo>()
val listeners = ArrayList<NsdManager.DiscoveryListener>()
val mlock = wifi.createMulticastLock("netdiag-mdns").apply {
setReferenceCounted(true)
try { acquire() } catch (_: Exception) { }
}
try {
for (type in types) {
val l = object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(s: String?, e: Int) {}
override fun onStopDiscoveryFailed(s: String?, e: Int) {}
override fun onDiscoveryStarted(s: String?) {}
override fun onDiscoveryStopped(s: String?) {}
override fun onServiceFound(info: NsdServiceInfo) { pending.add(info) }
override fun onServiceLost(info: NsdServiceInfo) {}
}
try {
nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, l)
listeners.add(l)
} catch (_: Exception) { }
}
// Gefundene Dienste seriell auflösen — NsdManager.resolveService
// verträgt keine parallelen Aufrufe.
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val info = pending.poll()
if (info == null) {
Thread.sleep(100)
continue
}
val lock = CountDownLatch(1)
try {
nsd.resolveService(info, object : NsdManager.ResolveListener {
override fun onResolveFailed(s: NsdServiceInfo?, e: Int) { lock.countDown() }
override fun onServiceResolved(s: NsdServiceInfo) {
val host = s.host?.hostAddress
if (host != null) {
val mi = result.getOrPut(host) { MdnsInfo() }
if (mi.name.isEmpty()) mi.name = s.serviceName ?: ""
val t = (s.serviceType ?: "").trim('.', ' ')
if (t.isNotEmpty()) mi.services.add(t)
}
lock.countDown()
}
})
lock.await(1500, TimeUnit.MILLISECONDS)
} catch (_: Exception) { }
}
} finally {
for (l in listeners) {
try { nsd.stopServiceDiscovery(l) } catch (_: Exception) { }
}
try { if (mlock.isHeld) mlock.release() } catch (_: Exception) { }
}
return result
}
/**
* Alle Host-IPs (als Int) eines CIDR-Subnetzes.
* "192.168.1.0/24" -> .1 bis .254, "10.0.0.0/22" -> 1022 Hosts usw.
@ -212,6 +452,73 @@ class NetDiagScannerPlugin : Plugin() {
private fun intToIpv4(i: Int): String =
"${(i shr 24) and 0xFF}.${(i shr 16) and 0xFF}.${(i shr 8) and 0xFF}.${i and 0xFF}"
/* --------------------------------------------------------------------- */
/* IP-Konflikt-Prüfung — eine IP, von zwei Geräten gleichzeitig benutzt */
/* --------------------------------------------------------------------- */
/**
* Sucht IP-Adressen, die im Netz von mehr als einem Gerät benutzt werden.
*
* Ohne Root lässt sich kein Roh-ARP mitschneiden der praktikable Weg:
* über mehrere Runden das Subnetz anpingen (das erzwingt jeweils eine
* ARP-Auflösung) und nach jeder Runde /proc/net/arp auslesen. Erscheint für
* dieselbe IP über die Runden hinweg mehr als eine MAC, nutzen zwei Geräte
* diese Adresse Konflikt.
*
* Risiko: /proc/net/arp kann auf neueren Android-Versionen leer sein
* dann meldet das Ergebnis `arpAvailable = false`.
*/
@PluginMethod
fun arpConflictScan(call: PluginCall) {
val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt")
val rounds = (call.getInt("rounds") ?: 4).coerceIn(2, 10)
val delayMs = (call.getInt("delayMs") ?: 600).coerceIn(0, 5000).toLong()
val hosts = hostsInSubnet(subnet)
if (hosts.isEmpty()) {
return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet")
}
io.launch {
try {
val seen = HashMap<String, MutableSet<String>>()
var arpEverFilled = false
for (r in 0 until rounds) {
withContext(Dispatchers.IO) {
hosts.map { ipInt ->
async {
try {
InetAddress.getByName(intToIpv4(ipInt)).isReachable(300)
} catch (_: Exception) {
false
}
}
}.awaitAll()
}
val arp = readArpTable()
if (arp.isNotEmpty()) arpEverFilled = true
for ((ip, mac) in arp) {
seen.getOrPut(ip) { HashSet() }.add(mac)
}
if (r < rounds - 1 && delayMs > 0) Thread.sleep(delayMs)
}
val conflicts = JSArray()
for ((ip, macs) in seen) {
if (macs.size > 1) {
val macArr = JSArray()
macs.forEach { macArr.put(it) }
conflicts.put(JSObject().put("ip", ip).put("macs", macArr))
}
}
resolve(call, JSObject()
.put("conflicts", conflicts)
.put("checked", seen.size)
.put("rounds", rounds)
.put("arpAvailable", arpEverFilled))
} catch (e: Exception) {
call.reject("arpConflictScan: ${e.message}")
}
}
}
/* --------------------------------------------------------------------- */
/* Port-Scan */
/* --------------------------------------------------------------------- */
@ -520,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 */
/* --------------------------------------------------------------------- */
@ -692,10 +1133,86 @@ class NetDiagScannerPlugin : Plugin() {
private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0
companion object {
/** Kleiner OUI-Auszug — bei Bedarf vollständige IEEE-OUI-Datei einbinden. */
private val OUI = mapOf(
"3810D5" to "AVM", "DCA632" to "Raspberry Pi", "B827EB" to "Raspberry Pi",
"001CC0" to "Intel", "F0B479" to "Apple", "D8EB97" to "TP-Link"
)
/**
* Kuratierter OUI-Auszug der gängigsten Hersteller im Handwerksumfeld
* (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf
* Vollständigkeit unbekannte MACs liefern einfach einen leeren Vendor.
*/
private val OUI: Map<String, String> = buildMap {
// AVM / FRITZ!Box
listOf("00040E", "3810D5", "5C4979", "C80E14", "E0286D", "3410F4")
.forEach { put(it, "AVM") }
// TP-Link
listOf("003192", "50C7BF", "D8EB97", "EC086B", "A42BB0", "1CFA68",
"14CC20", "B0487A", "6032B1", "5091E3").forEach { put(it, "TP-Link") }
// Netgear
listOf("000FB5", "20E52A", "00146C", "001B2F", "001E2A", "00223F",
"0024B2", "28C68E", "A040A0", "3C3786", "6CB0CE").forEach { put(it, "Netgear") }
// Ubiquiti
listOf("00156D", "0418D6", "24A43C", "44D9E7", "687251", "788A20",
"802AA8", "B4FB0E", "DC9FDB", "F09FC2", "FCECDA", "74ACB9",
"18E829", "944A0C", "E063DA").forEach { put(it, "Ubiquiti") }
// Cisco
listOf("00000C", "001AA1", "0023AC", "F09E63").forEach { put(it, "Cisco") }
// MikroTik
listOf("000C42", "4C5E0C", "6C3B6B", "CC2DE0", "E48D8C", "64D154",
"B869F4", "18FD74", "2CC81B", "DC2C6E", "744D28", "488F5A")
.forEach { put(it, "MikroTik") }
// D-Link
listOf("001195", "1CBDB9", "001B11", "001CF0", "14D64D", "28107B",
"78542E", "B8A386", "C8BE19").forEach { put(it, "D-Link") }
// Hikvision (Kameras)
listOf("4419B6", "BCAD28", "C05627", "2857BE", "4CBD8F", "54C415",
"A41437", "B4A382", "18680F").forEach { put(it, "Hikvision") }
// Dahua (Kameras)
listOf("3CEF8C", "9002A9", "14A78B", "E0508B", "08EDED", "24526A",
"6C1C71").forEach { put(it, "Dahua") }
// Axis (Kameras)
listOf("00408C", "ACCC8E", "B8A44F", "E82725").forEach { put(it, "Axis") }
// Drucker
listOf("001E0B", "3CD92B", "9457A5", "001321", "A0481C", "308D99",
"380025", "00215A", "9C8E99", "EC8EB5", "705A0F", "B499BA")
.forEach { put(it, "HP") }
listOf("008077", "30055C", "001BA9").forEach { put(it, "Brother") }
listOf("002673", "88873D", "F48139", "2C9EFC", "001E8F", "B08E1A")
.forEach { put(it, "Canon") }
listOf("000048", "0026AB", "A4EE57", "64EB8C", "44D244", "381A52")
.forEach { put(it, "Epson") }
// Apple
listOf("F0B479", "3C0754", "A4B197", "DC2B2A", "040CCE", "7CD1C3",
"F0DBF8", "88665A", "28CFE9", "001EC2", "002500", "D8A25E")
.forEach { put(it, "Apple") }
// Samsung
listOf("002566", "8425DB", "5CF6DC", "0017C9", "001A8A", "3423BA",
"781FDB", "8C7712", "BC1485", "5C0A5B").forEach { put(it, "Samsung") }
// Espressif (ESP32/ESP8266 — Shelly, Tasmota, viele IoT-Geräte)
listOf("240AC4", "30AEA4", "246F28", "84CCA8", "A020A6", "7C9EBD",
"8CAAB5", "3C6105", "24B2DE", "DC4F22", "84F3EB", "BCDDC2",
"A4CF12", "CC50E3", "2462AB", "18FE34", "5CCF7F", "600194",
"2C3AE8", "ECFABC", "B4E62D", "9038C9").forEach { put(it, "Espressif") }
// Raspberry Pi
listOf("B827EB", "DCA632", "E45F01", "28CDC1", "D83ADD", "2CCF67")
.forEach { put(it, "Raspberry Pi") }
// Intel
listOf("001CC0", "3CA9F4", "A0A8CD", "8C1645", "7CB27D", "9C305B",
"0013E8", "5C514F", "94659C").forEach { put(it, "Intel") }
// Sonos
listOf("000E58", "5CAAFD", "949F3E", "B8E937", "347E5C", "48A6B8",
"542A1B").forEach { put(it, "Sonos") }
// NAS
listOf("001132", "9009D0").forEach { put(it, "Synology") }
listOf("00089B", "245EBE").forEach { put(it, "QNAP") }
// Amazon (Echo / Fire)
listOf("8871E5", "FCA183", "44650D", "F0272D", "68DBF5", "50DCE7",
"AC63BE", "40B4CD", "0C47C9", "74C246").forEach { put(it, "Amazon") }
// Google / Nest / Chromecast
listOf("F4F5D8", "F4F5E8", "30FD38", "6CADF8", "546009", "A47733",
"1CF29A", "3C5AB4", "D86C63", "48D6D5").forEach { put(it, "Google") }
// Industrie / Gebäudetechnik
listOf("000E8C", "001B1B", "286336", "001C06", "8CF319")
.forEach { put(it, "Siemens") }
put("00A057", "Lancom")
put("001A22", "eQ-3 / Homematic")
}
}
}

View file

@ -11,8 +11,8 @@
* 3. Auf der Hauptroute: 1. Tap = Hinweis, 2. Tap binnen 1,8 s = App beenden
*/
import { App, type PluginListenerHandle } from '@capacitor/app';
import { Capacitor } from '@capacitor/core';
import { App } from '@capacitor/app';
import { Capacitor, type PluginListenerHandle } from '@capacitor/core';
interface BackConfig {
/** Schließt einen offenen Overlay-Zustand. true = verarbeitet, nichts weiter tun. */

View file

@ -0,0 +1,56 @@
<script lang="ts">
/**
* Bestätigungs-Modal — ersetzt das im Projekt verbotene `confirm()`.
* Meldet sich als Overlay an, damit der Hardware-Backbutton es schließt.
*/
import { onMount, onDestroy } from 'svelte';
import { pushOverlay } from '$lib/overlay.svelte';
let {
title,
message = '',
confirmLabel = 'OK',
danger = false,
onconfirm,
oncancel,
}: {
title: string;
message?: string;
confirmLabel?: string;
danger?: boolean;
onconfirm: () => void;
oncancel: () => void;
} = $props();
let off: (() => void) | undefined;
onMount(() => {
off = pushOverlay(oncancel);
});
onDestroy(() => off?.());
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget) oncancel();
}}
>
<div class="w-full max-w-sm rounded-lg bg-zinc-900 p-4">
<h2 class="text-sm font-semibold text-zinc-200">{title}</h2>
{#if message}<p class="mt-1 text-xs text-zinc-400">{message}</p>{/if}
<div class="mt-3 flex justify-end gap-2">
<button class="rounded px-3 py-1.5 text-sm text-zinc-400" onclick={oncancel}>
Abbrechen
</button>
<button
class="rounded px-3 py-1.5 text-sm font-medium text-white {danger
? 'bg-red-600'
: 'bg-sky-600'}"
onclick={onconfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,128 @@
<script lang="ts">
/**
* Gerätekarte — zeigt ein gefundenes Netzwerkgerät mit allen ermittelten
* Bezeichnungen (Hersteller, Geräteart, mDNS-/NetBIOS-Name, offene Ports),
* seinen Messungen und den verfügbaren Geräte-Werkzeugen.
*
* Wird auf der Protokoll-Detailseite, der Geräte-/Favoritenseite und in
* gespeicherten Scans wiederverwendet. Stern- und Umbenennen-Steuerung
* erscheinen nur, wenn die jeweiligen Callbacks gesetzt sind.
*/
import { Star, Pencil } from 'lucide-svelte';
import MeasurementResult from './MeasurementResult.svelte';
import type { Device, Measurement } from '$lib/types';
import type { Tool } from '$lib/tools/types';
let {
device,
measurements = [],
tools = [],
onrun,
onfavorite,
onrename,
}: {
device: Device;
measurements?: Measurement[];
tools?: Tool[];
onrun?: (tool: Tool) => void;
onfavorite?: () => void;
onrename?: () => void;
} = $props();
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500'];
/** Anzeigename: eigener Name vor mDNS-/Host-/NetBIOS-Name, sonst IP */
const title = $derived(
device.customName ||
device.mdnsName ||
device.hostname ||
device.netbiosName ||
device.ip,
);
/** Zweitzeile mit Bezeichnern, die nicht schon im Titel stehen */
const detail = $derived(
[
device.ip !== title ? device.ip : '',
device.netbiosName && device.netbiosName !== title ? 'NB: ' + device.netbiosName : '',
device.mac,
]
.filter(Boolean)
.join(' · '),
);
</script>
<div class="mb-2 rounded-lg bg-zinc-900 p-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<span class="truncate font-medium">{title}</span>
{#if onrename}
<button
class="shrink-0 text-zinc-500 active:text-zinc-300"
onclick={onrename}
aria-label="Gerät umbenennen"
>
<Pencil size={13} />
</button>
{/if}
</div>
{#if detail}<div class="truncate text-xs text-zinc-500">{detail}</div>{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
{#if device.vendor}<span class="text-xs text-zinc-500">{device.vendor}</span>{/if}
{#if onfavorite}
<button
class="active:scale-90 {device.isFavorite ? 'text-amber-400' : 'text-zinc-600'}"
onclick={onfavorite}
aria-label="Als Favorit markieren"
>
<Star size={18} fill={device.isFavorite ? 'currentColor' : 'none'} />
</button>
{/if}
</div>
</div>
{#if device.deviceType || device.openPorts?.length || device.mdnsServices?.length}
<div class="mt-1.5 flex flex-wrap gap-1">
{#if device.deviceType}
<span class="rounded bg-sky-900/60 px-1.5 py-0.5 text-[10px] text-sky-300">
{device.deviceType}
</span>
{/if}
{#each device.openPorts ?? [] as port (port)}
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-400">:{port}</span>
{/each}
{#each device.mdnsServices ?? [] as svc (svc)}
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">{svc}</span>
{/each}
</div>
{/if}
{#if device.note}
<p class="mt-1 text-xs text-zinc-400">{device.note}</p>
{/if}
{#each measurements as m (m.clientId)}
<div class="mt-1.5 flex items-start gap-2 border-t border-zinc-800 pt-1.5">
<span class="mt-1 h-2 w-2 shrink-0 rounded-full {ampelDot[m.measureStatus]}"></span>
<div class="min-w-0">
<p class="text-xs {ampel[m.measureStatus]}">{m.label}</p>
<MeasurementResult result={m.result} />
</div>
</div>
{/each}
{#if tools.length && onrun}
<div class="mt-2 flex flex-wrap gap-1.5">
{#each tools as tool (tool.id)}
<button
class="rounded bg-zinc-800 px-2 py-1 text-xs text-sky-300 active:bg-zinc-700"
onclick={() => onrun?.(tool)}
>
{tool.name}
</button>
{/each}
</div>
{/if}
</div>

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

@ -0,0 +1,73 @@
<script lang="ts">
/**
* Schlankes Eingabe-Modal für eine einzelne Textzeile (Gerät benennen,
* Scan benennen …). Ersetzt das im Projekt verbotene `prompt()`.
*
* Meldet sich als Overlay an, damit der Hardware-Backbutton es schließt.
*/
import { onMount, onDestroy } from 'svelte';
import { pushOverlay } from '$lib/overlay.svelte';
let {
title,
label = '',
value = '',
placeholder = '',
submitLabel = 'Speichern',
onsubmit,
oncancel,
}: {
title: string;
label?: string;
value?: string;
placeholder?: string;
submitLabel?: string;
onsubmit: (value: string) => void;
oncancel: () => void;
} = $props();
let text = $state(value);
let off: (() => void) | undefined;
onMount(() => {
off = pushOverlay(oncancel);
});
onDestroy(() => off?.());
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget) oncancel();
}}
>
<div class="w-full max-w-sm rounded-lg bg-zinc-900 p-4">
<h2 class="mb-2 text-sm font-semibold text-zinc-200">{title}</h2>
{#if label}
<label class="mb-1 block text-xs text-zinc-400" for="tp-input">{label}</label>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<input
id="tp-input"
class="w-full rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
bind:value={text}
{placeholder}
autofocus
onkeydown={(e) => {
if (e.key === 'Enter') onsubmit(text.trim());
}}
/>
<div class="mt-3 flex justify-end gap-2">
<button class="rounded px-3 py-1.5 text-sm text-zinc-400" onclick={oncancel}>
Abbrechen
</button>
<button
class="rounded bg-sky-600 px-3 py-1.5 text-sm font-medium text-white"
onclick={() => onsubmit(text.trim())}
>
{submitLabel}
</button>
</div>
</div>
</div>

View file

@ -17,6 +17,20 @@ import type { Protocol } from './types';
const DB_NAME = 'netdiag';
const LS_PREFIX = 'netdiag.protocol.';
/**
* Fehlende optionale Felder eines geladenen Protokolls defensiv ergänzen.
* Ältere Protokolle (vor den Geräte-Features) haben weder `savedScans` noch
* `monitorSessions` ohne diese Normalisierung liefen Komponenten auf
* `undefined.map`. Mutiert das Objekt und gibt es zurück.
*/
function normalizeProtocol(p: Protocol): Protocol {
p.devices ??= [];
p.measurements ??= [];
p.savedScans ??= [];
p.monitorSessions ??= [];
return p;
}
let useSqlite = false;
let db: SQLiteDBConnection | null = null;
@ -66,10 +80,10 @@ export async function getProtocol(uuid: string): Promise<Protocol | null> {
if (useSqlite && db) {
const res = await db.query('SELECT json FROM protocols WHERE uuid = ?', [uuid]);
const row = res.values?.[0];
return row ? (JSON.parse(row.json) as Protocol) : null;
return row ? normalizeProtocol(JSON.parse(row.json) as Protocol) : null;
}
const raw = localStorage.getItem(LS_PREFIX + uuid);
return raw ? (JSON.parse(raw) as Protocol) : null;
return raw ? normalizeProtocol(JSON.parse(raw) as Protocol) : null;
}
/** Alle Protokolle laden (neueste zuerst) */
@ -77,12 +91,12 @@ export async function getAllProtocols(): Promise<Protocol[]> {
let list: Protocol[] = [];
if (useSqlite && db) {
const res = await db.query('SELECT json FROM protocols ORDER BY updated_at DESC');
list = (res.values ?? []).map((r) => JSON.parse(r.json) as Protocol);
list = (res.values ?? []).map((r) => normalizeProtocol(JSON.parse(r.json) as Protocol));
} else {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(LS_PREFIX)) {
list.push(JSON.parse(localStorage.getItem(key)!) as Protocol);
list.push(normalizeProtocol(JSON.parse(localStorage.getItem(key)!) as Protocol));
}
}
list.sort((a, b) => b.updatedAt - a.updatedAt);

32
src/lib/overlay.svelte.ts Normal file
View file

@ -0,0 +1,32 @@
/**
* Registry offener Overlays (Dialoge, Sheets, Bottom-Sheets).
*
* Damit der Hardware-Backbutton zuerst ein offenes Overlay schließt, statt
* gleich die Seite zu verlassen, meldet jedes Overlay beim Öffnen einen
* Schließen-Callback an. Der Backbutton-Handler (backButton.svelte.ts) ruft
* `closeTopOverlay()` und navigiert nur, wenn kein Overlay offen war.
*
* Modul-Scope, kein Svelte-State Mehrfachregistrierung ist sicher.
*/
const closers: Array<() => void> = [];
/**
* Overlay anmelden, solange es geöffnet ist.
* Gibt die Abmeldefunktion zurück (im Svelte-$effect-Cleanup aufrufen).
*/
export function pushOverlay(close: () => void): () => void {
closers.push(close);
return () => {
const i = closers.lastIndexOf(close);
if (i >= 0) closers.splice(i, 1);
};
}
/** Oberstes Overlay schließen. Liefert true, wenn es eines zu schließen gab. */
export function closeTopOverlay(): boolean {
const close = closers.pop();
if (!close) return false;
close();
return true;
}

View file

@ -3,7 +3,7 @@
*/
import { saveProtocol } from './db';
import type { Device, Measurement, Protocol } from './types';
import type { Device, Measurement, Protocol, SavedScan } from './types';
/** Eindeutige ID erzeugen */
export function uid(): string {
@ -44,11 +44,16 @@ export async function createProtocol(init: {
/** Gerät zum Protokoll hinzufügen oder per IP aktualisieren */
export function upsertDevice(
protocol: Protocol,
dev: Omit<Device, 'clientId'> & { clientId?: string },
dev: Partial<Device> & { ip: string },
): Device {
const existing = protocol.devices.find((d) => d.ip === dev.ip);
if (existing) {
Object.assign(existing, { ...dev, clientId: existing.clientId });
// Nur gesetzte Felder übernehmen — ein magerer Re-Scan darf zuvor
// gefundene Daten (mDNS-Name, Favorit, eigener Name) nicht überschreiben.
const target = existing as unknown as Record<string, unknown>;
for (const [k, v] of Object.entries(dev)) {
if (k !== 'clientId' && v !== undefined) target[k] = v;
}
return existing;
}
const created: Device = { ...dev, clientId: dev.clientId ?? uid() };
@ -56,6 +61,42 @@ export function upsertDevice(
return created;
}
/** Favoriten-Markierung eines Geräts umschalten */
export function toggleFavorite(protocol: Protocol, clientId: string): void {
const d = protocol.devices.find((x) => x.clientId === clientId);
if (d) d.isFavorite = !d.isFavorite;
}
/** Einem Gerät einen eigenen Namen geben (leerer Name = zurücksetzen) */
export function renameDevice(protocol: Protocol, clientId: string, name: string): void {
const d = protocol.devices.find((x) => x.clientId === clientId);
if (d) d.customName = name.trim() || undefined;
}
/**
* Den aktuellen Geräte-Stand als benannten Scan-Snapshot einfrieren.
* Der Snapshot ist eine tiefe Kopie spätere Re-Scans verändern ihn nicht.
*/
export function saveScan(protocol: Protocol, name: string): SavedScan {
const scan: SavedScan = {
id: uid(),
name: name.trim() || new Date().toLocaleString('de-DE'),
createdAt: Date.now(),
subnet: protocol.subnet,
// JSON-Roundtrip löst den Svelte-State-Proxy und friert die Daten ein
devices: JSON.parse(JSON.stringify(protocol.devices)) as Device[],
};
(protocol.savedScans ??= []).push(scan);
return scan;
}
/** Gespeicherten Scan-Snapshot löschen */
export function deleteScan(protocol: Protocol, scanId: string): void {
if (protocol.savedScans) {
protocol.savedScans = protocol.savedScans.filter((s) => s.id !== scanId);
}
}
/** Messung zum Protokoll hinzufügen */
export function addMeasurement(
protocol: Protocol,

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 --- */
@ -15,6 +15,40 @@ export interface ScannedDevice {
mac?: string;
hostname?: string;
vendor?: string;
/** geschätzte Geräteart (Kamera, Drucker, Router …) */
deviceType?: string;
/** NetBIOS-Name (UDP-137-Abfrage) */
netbiosName?: string;
/** offene Ports aus der Quick-Port-Probe */
openPorts?: number[];
}
/** Ein per mDNS/Bonjour gefundenes Gerät */
export interface MdnsDevice {
ip: string;
/** Bonjour-Anzeigename */
name: string;
/** angebotene Diensttypen, z.B. ['_googlecast._tcp', '_printer._tcp'] */
services: string[];
}
/** Ergebnis der IP-Konflikt-Prüfung */
export interface ConflictScanResult {
/** IP-Adressen, die von mehr als einem Gerät benutzt werden */
conflicts: { ip: string; macs: string[] }[];
/** Anzahl der Adressen, für die überhaupt eine MAC gesehen wurde */
checked: number;
rounds: number;
/** 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;
@ -62,6 +96,14 @@ export interface NetDiagScannerPlugin {
getLocalSubnet(): Promise<{ subnet: string; ip: string; gateway: string }>;
/** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */
ipScan(opts: { subnet: string }): Promise<{ devices: ScannedDevice[] }>;
/** mDNS/Bonjour-Dienstsuche: Drucker, Kameras, Chromecast, AirPlay … */
mdnsScan(opts: { timeoutMs?: number }): Promise<{ devices: MdnsDevice[] }>;
/** IP-Konflikt-Prüfung: findet IP-Adressen, die zwei Geräte gleichzeitig benutzen */
arpConflictScan(opts: {
subnet: string;
rounds?: number;
delayMs?: number;
}): Promise<ConflictScanResult>;
/** Port-Scan eines Geräts */
portScan(opts: { ip: string; ports: number[] }): Promise<{ open: OpenPort[] }>;
/** Ping-Qualität (Latenz, Jitter, Paketverlust) */
@ -87,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');
@ -99,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' };
@ -106,13 +164,67 @@ const mock: NetDiagScannerPlugin = {
async ipScan() {
return {
devices: [
{ ip: '192.168.1.1', mac: 'AA:BB:CC:00:00:01', hostname: 'fritzbox', vendor: 'AVM' },
{ ip: '192.168.1.10', mac: 'AA:BB:CC:00:00:0A', hostname: 'switch-keller', vendor: 'TP-Link' },
{ ip: '192.168.1.50', mac: 'AA:BB:CC:00:00:32', hostname: 'handy', vendor: 'Samsung' },
{ ip: '192.168.1.77', mac: 'AA:BB:CC:00:00:4D', hostname: 'wallbox', vendor: 'Keba' },
{
ip: '192.168.1.1',
mac: 'AA:BB:CC:00:00:01',
hostname: 'fritzbox',
vendor: 'AVM',
deviceType: 'Router',
openPorts: [53, 80, 443],
},
{
ip: '192.168.1.10',
mac: 'AA:BB:CC:00:00:0A',
hostname: 'switch-keller',
vendor: 'TP-Link',
deviceType: 'Switch',
openPorts: [80],
},
{
ip: '192.168.1.40',
mac: 'AA:BB:CC:00:00:28',
hostname: 'ipcam-hof',
vendor: 'Hikvision',
deviceType: 'Kamera',
openPorts: [80, 554],
},
{
ip: '192.168.1.50',
mac: 'AA:BB:CC:00:00:32',
hostname: 'handy',
vendor: 'Samsung',
deviceType: '',
openPorts: [],
},
{
ip: '192.168.1.77',
mac: 'AA:BB:CC:00:00:4D',
hostname: 'wallbox',
vendor: '',
netbiosName: 'WALLBOX',
deviceType: 'Wallbox',
openPorts: [80, 502],
},
],
};
},
async mdnsScan() {
return {
devices: [
{ ip: '192.168.1.20', name: 'Brother HL-L2350DW', services: ['_printer._tcp', '_ipp._tcp'] },
{ ip: '192.168.1.30', name: 'Wohnzimmer-TV', services: ['_googlecast._tcp'] },
{ ip: '192.168.1.40', name: 'IP-Kamera Hof', services: ['_rtsp._tcp'] },
],
};
},
async arpConflictScan() {
return {
conflicts: [{ ip: '192.168.1.40', macs: ['AA:BB:CC:00:00:28', 'AA:BB:CC:00:00:99'] }],
checked: 12,
rounds: 4,
arpAvailable: true,
};
},
async portScan(opts) {
const all: OpenPort[] = [
{ port: 80, service: 'http' },
@ -174,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

@ -10,6 +10,7 @@
import type { Tool, ToolCategory } from './types';
import { dhcpCheckTool } from './netzwerk/dhcpcheck';
import { ipConflictTool } from './netzwerk/ipconflict';
import { ipScanTool } from './netzwerk/ipscan';
import { pingTool } from './netzwerk/ping';
import { portScanTool } from './netzwerk/portscan';
@ -28,6 +29,7 @@ export const TOOLS: Tool[] = [
pingTool,
wifiScanTool,
dhcpCheckTool,
ipConflictTool,
snmpTool,
tracerouteTool,
stressTestTool,

View file

@ -0,0 +1,90 @@
/**
* Tool: IP-Konflikt-Prüfung findet IP-Adressen, die von zwei Geräten
* gleichzeitig benutzt werden und so das Netz durcheinanderbringen.
*
* Verfahren ohne Root: Das Subnetz wird über mehrere Runden angepingt und
* jeweils die ARP-Tabelle ausgelesen. Tauchen für eine IP mehrere MAC-Adressen
* auf, nutzen mehrere Geräte dieselbe Adresse ein Konflikt.
*/
import { scanner } from '../../scanner';
import type { MeasureStatus, Tool } from '../types';
export const ipConflictTool: Tool = {
id: 'ipconflict',
category: 'netzwerk',
name: 'IP-Konflikt',
icon: 'alert-triangle',
description: 'Findet IP-Adressen, die zwei Geräte gleichzeitig benutzen.',
scope: 'protocol',
params: [
{
key: 'subnet',
label: 'Netzbereich (CIDR) — leer = aktiver Adapter',
type: 'text',
placeholder: 'leer lassen → automatisch über WLAN/LAN',
},
{
key: 'rounds',
label: 'Prüfrunden (mehr = zuverlässiger, dauert länger)',
type: 'number',
default: 4,
},
],
async run(ctx) {
// Netzbereich: Dialog → Protokoll → aktiver Adapter
let subnet =
String(ctx.params.subnet ?? '').trim() || String(ctx.protocol.subnet ?? '').trim();
if (!subnet) {
try {
subnet = String((await scanner.getLocalSubnet()).subnet ?? '').trim();
} catch {
/* unten abgefangen */
}
}
if (!subnet) {
return {
label: 'Kein Netzbereich — WLAN/LAN nicht aktiv?',
result: { error: 'Netzbereich konnte nicht ermittelt werden' },
measureStatus: 2,
};
}
const rounds = Number(ctx.params.rounds) || 4;
const res = await scanner.arpConflictScan({ subnet, rounds });
// ARP-Tabelle nicht lesbar → ehrliche Rückmeldung statt falscher Entwarnung
if (!res.arpAvailable) {
return {
label: 'ARP-Tabelle nicht lesbar — Konfliktprüfung nicht möglich',
result: {
subnet,
hinweis:
'Android gibt /proc/net/arp auf diesem Gerät nicht frei. Eine ' +
'zuverlässige IP-Konflikt-Erkennung ist ohne Root hier leider nicht möglich.',
},
measureStatus: 1,
};
}
const n = res.conflicts.length;
const status: MeasureStatus = n > 0 ? 2 : 0;
return {
label:
n > 0
? `${n} IP-Konflikt${n > 1 ? 'e' : ''} gefunden!`
: `Kein Konflikt — ${res.checked} Adressen geprüft`,
result: {
subnet,
geprueft: res.checked,
runden: res.rounds,
konflikte: res.conflicts.map((c) => `${c.ip}${c.macs.join(' / ')}`),
hinweis:
n > 0
? 'Mehrere MAC-Adressen pro IP — diese Geräte stören sich gegenseitig.'
: 'Jede gefundene IP wird von genau einem Gerät benutzt.',
},
measureStatus: status,
};
},
};

View file

@ -8,10 +8,24 @@
* dessen Subnetz direkt.
*/
import { scanner } from '../../scanner';
import { scanner, type MdnsDevice } from '../../scanner';
import { debugLog } from '../../debuglog.svelte';
import type { Device } from '../../types';
import type { Tool } from '../types';
/** Geräteart aus den angebotenen mDNS-Diensten ableiten */
function typeFromMdns(services: string[]): string {
const s = services.join(' ');
if (s.includes('_printer') || s.includes('_ipp') || s.includes('_pdl-datastream'))
return 'Drucker';
if (s.includes('_googlecast')) return 'Chromecast/TV';
if (s.includes('_airplay') || s.includes('_raop')) return 'AirPlay-Gerät';
if (s.includes('_rtsp') || s.includes('_axis-video')) return 'Kamera';
if (s.includes('_hap')) return 'HomeKit-Gerät';
if (s.includes('_smb')) return 'NAS';
return '';
}
export const ipScanTool: Tool = {
id: 'ipscan',
category: 'netzwerk',
@ -66,18 +80,51 @@ export const ipScanTool: Tool = {
`gescannt wird "${subnet}" (Quelle: ${source})`,
);
const { devices } = await scanner.ipScan({ subnet });
debugLog.add('info', `IP-Scan Ergebnis: ${devices.length} Geräte in ${subnet}`);
// mDNS/Bonjour zusätzlich abfragen — liefert sprechende Namen und findet
// Geräte, die nicht auf Ping antworten (manche Kameras/Drucker). Best-Effort.
let mdns: MdnsDevice[] = [];
try {
mdns = (await scanner.mdnsScan({ timeoutMs: 4000 })).devices;
} catch {
/* mDNS fehlgeschlagen — IP-Scan bleibt trotzdem gültig */
}
const mdnsByIp = new Map(mdns.map((m) => [m.ip, m]));
// Beide Quellen per IP zusammenführen
const merged: (Partial<Device> & { ip: string })[] = devices.map((d) => {
const m = mdnsByIp.get(d.ip);
if (!m) return d;
return {
...d,
mdnsName: m.name,
mdnsServices: m.services,
deviceType: d.deviceType || typeFromMdns(m.services),
};
});
// Geräte, die nur per mDNS auftauchten, ergänzen
for (const m of mdns) {
if (merged.some((d) => d.ip === m.ip)) continue;
merged.push({
ip: m.ip,
hostname: m.name,
mdnsName: m.name,
mdnsServices: m.services,
deviceType: typeFromMdns(m.services),
});
}
debugLog.add(
'info',
`IP-Scan Ergebnis: ${merged.length} Geräte in ${subnet} ` +
`(${devices.length} per Ping/ARP, ${mdns.length} per mDNS)`,
);
const via = source === 'adapter' ? ' (Adapter erkannt)' : '';
return {
label: `${devices.length} Geräte im Netz ${subnet}${via}`,
result: { subnet, count: devices.length, quelle: source },
measureStatus: devices.length > 0 ? 0 : 1,
devices: devices.map((d) => ({
ip: d.ip,
mac: d.mac,
hostname: d.hostname,
vendor: d.vendor,
})),
label: `${merged.length} Geräte im Netz ${subnet}${via}`,
result: { subnet, count: merged.length, quelle: source },
measureStatus: merged.length > 0 ? 0 : 1,
devices: merged,
};
},
};

View file

@ -43,15 +43,9 @@ export interface ToolRunResult {
measureStatus: MeasureStatus;
/**
* Optional: im Netzwerk gefundene Geräte. Werden vom Protokoll
* übernommen (z.B. beim IP-Scan).
* übernommen (z.B. beim IP-Scan). `ip` ist Pflicht, alles Weitere optional.
*/
devices?: Array<{
ip: string;
mac?: string;
hostname?: string;
vendor?: string;
deviceType?: string;
}>;
devices?: Array<Partial<Device> & { ip: string }>;
}
/** Ein Diagnose-Werkzeug */

View file

@ -54,6 +54,56 @@ export interface Device {
vendor?: string;
deviceType?: string;
note?: string;
/** vom Benutzer als Favorit markiert (bleibt auch ohne gespeicherten Scan erhalten) */
isFavorite?: boolean;
/** benutzervergebener Name, überschreibt hostname in der Anzeige */
customName?: string;
/** Unix-Zeit der letzten Sichtung im Netz */
lastSeen?: number;
/** zuletzt beim Port-Scan gefundene offene Ports */
openPorts?: number[];
/** NetBIOS-Name (UDP-137-Abfrage) */
netbiosName?: string;
/** mDNS/Bonjour-Name */
mdnsName?: string;
/** angebotene mDNS-Dienste, z.B. ['_printer._tcp', '_googlecast._tcp'] */
mdnsServices?: string[];
}
/** Eingefrorener Snapshot eines IP-Scans (wieder aufrufbar) */
export interface SavedScan {
id: string;
name: string;
/** Unix-Zeit der Speicherung */
createdAt: number;
subnet: string;
devices: Device[];
}
/** Ein Ereignis der Geräte-Überwachung */
export interface MonitorEvent {
/** Unix-Zeit des Ereignisses */
ts: number;
ip: string;
type: 'down' | 'up';
/** Dauer des vorangegangenen Ausfalls in Sekunden (nur bei 'up') */
durationSec?: number;
}
/** Überwachungs-Sitzung des Geräte-Monitors (Kamera-Problem) */
export interface DeviceMonitorSession {
id: string;
name: string;
startedAt: number;
endedAt?: number;
/** Prüfintervall in Sekunden */
intervalSec: number;
/** überwachte Geräte */
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 */
@ -92,6 +142,10 @@ export interface Protocol {
note: string;
devices: Device[];
measurements: Measurement[];
/** gespeicherte IP-Scan-Snapshots (nur lokal, wird nicht synchronisiert) */
savedScans?: SavedScan[];
/** Geräte-Überwachungs-Sitzungen (nur lokal, wird nicht synchronisiert) */
monitorSessions?: DeviceMonitorSession[];
/** true solange noch nicht zum Server synchronisiert */
dirty: boolean;
updatedAt: number;

View file

@ -1,13 +1,15 @@
<script lang="ts">
import '../app.css';
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { goto, afterNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { Preferences } from '@capacitor/preferences';
import { auth } from '$lib/auth.svelte';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import { initDb } from '$lib/db';
import { registerBackListener, removeBackListener } from '$lib/backButton.svelte';
import { closeTopOverlay } from '$lib/overlay.svelte';
import { checkForUpdate, installUpdate, type UpdateInfo } from '$lib/updater';
import { initDebugLog } from '$lib/debuglog.svelte';
import Toast from '$lib/components/Toast.svelte';
@ -21,6 +23,18 @@
let updatePercent = $state(0);
const HOME = '/auftraege/';
/** Preferences-Schlüssel für die zuletzt besuchte Seite (Resume nach App-Neustart) */
const LAST_ROUTE_KEY = 'nd_last_route';
// Jede Navigation merken — damit die App nach einem Neustart (oder Wechsel
// aus einer anderen App, bei dem Android den Prozess beendet hat) wieder
// genau dort öffnet, wo der Benutzer aufgehört hat.
afterNavigate(({ to }) => {
const path = to?.url.pathname;
if (path && !path.startsWith('/login')) {
void Preferences.set({ key: LAST_ROUTE_KEY, value: path });
}
});
// Update herunterladen und Installer öffnen — Fortschritt im Banner
async function runUpdate() {
@ -44,7 +58,7 @@
// Hardware-Backbutton (Modul-Scope, Single-Instance — KB #480/#549)
registerBackListener({
handleOverlay: () => false,
handleOverlay: () => closeTopOverlay(),
isHomeRoute: () => {
const p = $page.url.pathname;
return p === HOME || p === '/' || p === '/login/';
@ -53,6 +67,17 @@
showExitHint: () => toast.show('Nochmal drücken zum Beenden'),
});
// Letzte Position wiederherstellen: nur beim echten Kaltstart (App öffnet
// auf "/" oder der Auftragsliste), nicht wenn gezielt woandershin navigiert
// wurde. Ungültige/gelöschte Protokolle fängt die Zielseite selbst ab.
if (auth.loggedIn) {
const last = (await Preferences.get({ key: LAST_ROUTE_KEY })).value;
const here = $page.url.pathname;
if (last && last !== here && (here === '/' || here === HOME)) {
await goto(last);
}
}
// Auf neue APK prüfen — beim Start still (kein Toast), nur Banner bei Erfolg
try {
updateInfo = await checkForUpdate();

View file

@ -12,12 +12,15 @@
let orders = $state<Order[]>([]);
let search = $state('');
// Standard: alle Aufträge, zuletzt bearbeitete zuerst (Server sortiert nach tms).
// Haken setzen blendet abgeschlossene aus und zeigt nur aktive Aufträge.
let onlyActive = $state(false);
// Standard: nur aktive Aufträge. Haken entfernen zeigt auch abgeschlossene.
// Sortierung: Aufträge mit lokaler Scan-Tätigkeit zuerst, dann Server-Reihenfolge (tms).
let onlyActive = $state(true);
let loading = $state(false);
let loadError = $state('');
/** orderId → letzte lokale Protokoll-Bearbeitung + Anzahl (für Sortierung/Anzeige) */
let localActivity = $state(new Map<number, { updatedAt: number; count: number }>());
let searchTimer: ReturnType<typeof setTimeout>;
/** Kunde + Ort als eine Zeile — das, was man im Kopf hat */
@ -43,7 +46,23 @@
loadError = '';
try {
const res = await listOrders({ open: onlyActive, q: search.trim() || undefined });
orders = res.orders;
// Lokale Protokoll-Tätigkeit erfassen — ein Scan auf dem Handy ändert die
// Dolibarr-tms NICHT, daher hier clientseitig nach oben sortieren.
const activity = new Map<number, { updatedAt: number; count: number }>();
for (const p of await getAllProtocols()) {
if (p.orderId == null) continue;
const cur = activity.get(p.orderId);
activity.set(p.orderId, {
updatedAt: Math.max(cur?.updatedAt ?? 0, p.updatedAt),
count: (cur?.count ?? 0) + 1,
});
}
localActivity = activity;
// Aufträge mit lokaler Tätigkeit zuerst (jüngste oben), Rest in
// Server-Reihenfolge (tms DESC) — Array.sort ist stabil.
orders = res.orders
.slice()
.sort((a, b) => (activity.get(b.id)?.updatedAt ?? 0) - (activity.get(a.id)?.updatedAt ?? 0));
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'Laden fehlgeschlagen';
} finally {
@ -51,6 +70,15 @@
}
}
/** Datums-Zusatz für die Auftragszeile: lokale Bearbeitung bevorzugt */
function editedInfo(order: Order): string {
const local = localActivity.get(order.id);
if (local) return ' · zuletzt bearb. ' + fmtDate(local.updatedAt);
if (order.tms) return ' · bearb. ' + fmtDate(order.tms);
if (order.date) return ' · ' + fmtDate(order.date);
return '';
}
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(load, 300);
@ -84,7 +112,8 @@
}
onMount(async () => {
onlyActive = (await Preferences.get({ key: 'nd_only_active' })).value === '1';
const pref = (await Preferences.get({ key: 'nd_only_active' })).value;
onlyActive = pref == null ? true : pref === '1';
load();
});
</script>
@ -121,6 +150,7 @@
<p class="p-6 text-center text-sm text-zinc-500">Keine Aufträge gefunden.</p>
{:else}
{#each orders as order (order.id)}
{@const pc = Math.max(order.protocolCount ?? 0, localActivity.get(order.id)?.count ?? 0)}
<button
class="flex w-full items-start gap-3 border-b border-zinc-800/60 px-3 py-3 text-left active:bg-zinc-800"
onclick={() => openOrder(order)}
@ -145,16 +175,12 @@
{/if}
<!-- Auftragsnummer + Bearbeitungsdatum: nur Kleingedrucktes -->
<div class="truncate text-[11px] text-zinc-500">
{order.ref}{order.refClient ? ' · ' + order.refClient : ''}{order.tms
? ' · bearb. ' + fmtDate(order.tms)
: order.date
? ' · ' + fmtDate(order.date)
: ''}
{order.ref}{order.refClient ? ' · ' + order.refClient : ''}{editedInfo(order)}
</div>
</div>
{#if order.protocolCount && order.protocolCount > 0}
{#if pc > 0}
<span class="flex shrink-0 items-center gap-1 text-xs text-sky-400">
<FileStack size={14} />{order.protocolCount}
<FileStack size={14} />{pc}
</span>
{/if}
</button>

View file

@ -1,14 +1,27 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { App } from '@capacitor/app';
import type { PluginListenerHandle } from '@capacitor/core';
import AppHeader from '$lib/components/AppHeader.svelte';
import ToolDialog from '$lib/components/ToolDialog.svelte';
import MeasurementResult from '$lib/components/MeasurementResult.svelte';
import DeviceCard from '$lib/components/DeviceCard.svelte';
import TextPromptDialog from '$lib/components/TextPromptDialog.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { getProtocol, saveProtocol, deleteProtocol } from '$lib/db';
import { addMeasurement, upsertDevice } from '$lib/protocols';
import {
addMeasurement,
upsertDevice,
toggleFavorite,
renameDevice,
saveScan,
deleteScan,
} from '$lib/protocols';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import { pushOverlay } from '$lib/overlay.svelte';
import { TOOLS, getTool } from '$lib/tools';
import type { Tool } from '$lib/tools/types';
import type { Device, Protocol } from '$lib/types';
@ -18,15 +31,29 @@
let activeTool = $state<Tool | null>(null);
let activeDevice = $state<Device | undefined>(undefined);
let saving = $state(false);
let renameTarget = $state<Device | null>(null);
let confirmDelete = $state(false);
let saveScanOpen = $state(false);
let expandedScan = $state<string | null>(null);
let deleteScanId = $state<string | null>(null);
let appStateListener: PluginListenerHandle | null = null;
const protocolTools = TOOLS.filter((t) => t.scope === 'protocol');
const deviceTools = TOOLS.filter((t) => t.scope === 'device');
/** Geräte mit Favoriten zuerst */
const sortedDevices = $derived(
[...(protocol?.devices ?? [])].sort(
(a, b) => Number(b.isFavorite ?? false) - Number(a.isFavorite ?? false),
),
);
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500'];
onMount(async () => {
const uuid = $page.params.id;
const uuid = $page.params.id ?? '';
const p = await getProtocol(uuid);
if (!p) {
toast.show('Protokoll nicht gefunden', 'error');
@ -34,6 +61,19 @@
return;
}
protocol = p;
// App wechselt in den Hintergrund (anderer App-Wechsel, Display aus) →
// sofort sichern, bevor Android den Prozess evtl. beendet.
appStateListener = await App.addListener('appStateChange', ({ isActive }) => {
if (!isActive) void persist();
});
});
onDestroy(() => {
// Beim Verlassen der Seite (Back-Tap, Navigation) final sichern — fängt
// auch Eingaben ab, die noch nicht per onblur gespeichert wurden.
void persist();
appStateListener?.remove();
});
/** Protokoll als geändert markieren und lokal speichern */
@ -44,6 +84,13 @@
await sync.refreshPending();
}
// Offenen Werkzeug-Dialog beim Hardware-Backbutton schließen, statt
// gleich die Seite zu verlassen.
$effect(() => {
if (!activeTool) return;
return pushOverlay(() => (activeTool = null));
});
/** Lucide-Icon dynamisch holen (Tool-Icon-Name ist kebab-case) */
function icon(name: string) {
const pascal = name
@ -65,16 +112,11 @@
const tool = activeTool;
const result = await tool.run({ params, protocol, device: activeDevice });
// Neu gefundene Geräte übernehmen (z.B. IP-Scan)
// Neu gefundene Geräte übernehmen (z.B. IP-Scan) — alle gelieferten
// Felder durchreichen (mac, hostname, vendor, deviceType, mDNS, Ports …)
if (result.devices) {
for (const d of result.devices) {
upsertDevice(protocol, {
ip: d.ip,
mac: d.mac,
hostname: d.hostname,
vendor: d.vendor,
deviceType: d.deviceType,
});
upsertDevice(protocol, { ...d, lastSeen: Date.now() });
}
}
@ -106,14 +148,63 @@
);
}
async function removeProtocol() {
/** Favoriten-Stern eines Geräts umschalten */
function doToggleFav(device: Device) {
if (!protocol) return;
toggleFavorite(protocol, device.clientId);
void persist();
}
/** Gerät umbenennen (aus dem Namens-Dialog) */
function doRename(name: string) {
if (protocol && renameTarget) {
renameDevice(protocol, renameTarget.clientId, name);
void persist();
}
renameTarget = null;
}
async function doDelete() {
confirmDelete = false;
if (!protocol) return;
if (!confirm('Dieses Protokoll wirklich löschen?')) return;
await deleteProtocol(protocol.clientUuid);
await sync.refreshPending();
goto('/auftraege/');
}
/** Datum + Uhrzeit kurz */
function fmtDateTime(ts: number): string {
return new Date(ts).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
/** Vorschlag für den Scan-Namen */
function defaultScanName(): string {
return 'Scan ' + fmtDateTime(Date.now());
}
/** Aktuellen Geräte-Stand als Snapshot speichern */
function doSaveScan(name: string) {
if (protocol) {
saveScan(protocol, name);
void persist();
toast.show('Scan gespeichert', 'success');
}
saveScanOpen = false;
}
function doDeleteScan() {
if (protocol && deleteScanId) {
deleteScan(protocol, deleteScanId);
void persist();
}
deleteScanId = null;
}
function measurementsFor(deviceClientId: string) {
return protocol?.measurements.filter((m) => m.deviceClientId === deviceClientId) ?? [];
}
@ -176,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>
@ -198,50 +300,74 @@
<!-- Geräte -->
<section class="px-3 pb-3">
<h2 class="mb-2 text-sm font-semibold text-zinc-300">
Geräte ({protocol.devices.length})
</h2>
<div class="mb-2 flex items-center justify-between">
<h2 class="text-sm font-semibold text-zinc-300">
Geräte ({protocol.devices.length})
</h2>
{#if protocol.devices.length > 0}
<button class="text-xs text-sky-300 active:text-sky-200" onclick={() => (saveScanOpen = true)}>
Scan speichern
</button>
{/if}
</div>
{#if protocol.devices.length === 0}
<p class="text-xs text-zinc-500">
Noch keine Geräte — IP-Scanner ausführen, um das Netz zu erfassen.
</p>
{/if}
{#each protocol.devices as device (device.clientId)}
<div class="mb-2 rounded-lg bg-zinc-900 p-3">
<div class="flex items-baseline justify-between">
<span class="font-medium">{device.ip}</span>
<span class="text-xs text-zinc-500">{device.vendor ?? ''}</span>
</div>
<div class="text-xs text-zinc-500">
{device.hostname ?? ''}{device.mac ? ' · ' + device.mac : ''}
</div>
{#each measurementsFor(device.clientId) as m (m.clientId)}
<div class="mt-1.5 flex items-start gap-2 border-t border-zinc-800 pt-1.5">
<span class="mt-1 h-2 w-2 shrink-0 rounded-full {ampelDot[m.measureStatus]}"></span>
<div class="min-w-0">
<p class="text-xs {ampel[m.measureStatus]}">{m.label}</p>
<MeasurementResult result={m.result} />
</div>
</div>
{/each}
<div class="mt-2 flex flex-wrap gap-1.5">
{#each deviceTools as tool (tool.id)}
<button
class="rounded bg-zinc-800 px-2 py-1 text-xs text-sky-300 active:bg-zinc-700"
onclick={() => openTool(tool, device)}
>
{tool.name}
</button>
{/each}
</div>
</div>
{#each sortedDevices as device (device.clientId)}
<DeviceCard
{device}
measurements={measurementsFor(device.clientId)}
tools={deviceTools}
onrun={(tool) => openTool(tool, device)}
onfavorite={() => doToggleFav(device)}
onrename={() => (renameTarget = device)}
/>
{/each}
</section>
<!-- Gespeicherte Scans -->
{#if protocol.savedScans && protocol.savedScans.length > 0}
<section class="px-3 pb-3">
<h2 class="mb-2 text-sm font-semibold text-zinc-300">Gespeicherte Scans</h2>
{#each protocol.savedScans as scan (scan.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={() => (expandedScan = expandedScan === scan.id ? null : scan.id)}
>
<div class="min-w-0">
<div class="truncate text-sm font-medium">{scan.name}</div>
<div class="text-[11px] text-zinc-500">
{scan.devices.length} Geräte · {scan.subnet || '—'} · {fmtDateTime(scan.createdAt)}
</div>
</div>
<Icons.ChevronDown
size={16}
class="shrink-0 text-zinc-500 {expandedScan === scan.id ? 'rotate-180' : ''}"
/>
</button>
{#if expandedScan === scan.id}
<div class="border-t border-zinc-800 p-2.5">
{#each scan.devices as d (d.clientId)}
<DeviceCard device={d} />
{/each}
<button
class="mt-1 text-xs text-red-400 underline"
onclick={() => (deleteScanId = scan.id)}
>
Scan löschen
</button>
</div>
{/if}
</div>
{/each}
</section>
{/if}
<div class="px-3">
<button class="text-xs text-red-400 underline" onclick={removeProtocol}>
<button class="text-xs text-red-400 underline" onclick={() => (confirmDelete = true)}>
Protokoll löschen
</button>
</div>
@ -267,6 +393,50 @@
onrun={runTool}
/>
{/if}
{#if renameTarget}
<TextPromptDialog
title="Gerät benennen"
label="Eigener Name"
value={renameTarget.customName ?? ''}
placeholder={renameTarget.hostname ?? renameTarget.ip}
onsubmit={doRename}
oncancel={() => (renameTarget = null)}
/>
{/if}
{#if confirmDelete}
<ConfirmDialog
title="Protokoll löschen?"
message="Das Protokoll und alle Messungen werden lokal entfernt."
confirmLabel="Löschen"
danger
onconfirm={doDelete}
oncancel={() => (confirmDelete = false)}
/>
{/if}
{#if saveScanOpen}
<TextPromptDialog
title="Scan speichern"
label="Name des Scans"
value={defaultScanName()}
placeholder="z.B. Erdgeschoss"
onsubmit={doSaveScan}
oncancel={() => (saveScanOpen = false)}
/>
{/if}
{#if deleteScanId}
<ConfirmDialog
title="Scan löschen?"
message="Der gespeicherte Scan-Snapshot wird entfernt."
confirmLabel="Löschen"
danger
onconfirm={doDeleteScan}
oncancel={() => (deleteScanId = null)}
/>
{/if}
{:else}
<div class="flex min-h-screen items-center justify-center text-zinc-500">Lädt …</div>
{/if}

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}