Compare commits
7 commits
53d91d1526
...
d2df3ee929
| Author | SHA1 | Date | |
|---|---|---|---|
| d2df3ee929 | |||
| 9ee9c954b2 | |||
| fd75748cb9 | |||
| 484b5f96fa | |||
| 50793e4e5d | |||
| 1a0f1dc5ca | |||
| 2a75ad96b2 |
23 changed files with 2576 additions and 119 deletions
|
|
@ -33,6 +33,12 @@
|
|||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
|
||||
<!-- Vordergrund-Dienst des Geräte-Monitors -->
|
||||
<service
|
||||
android:name=".MonitorService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
package de.data_it_solution.netdiag
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* Schlanker Vordergrund-Dienst für den Geräte-Monitor.
|
||||
*
|
||||
* Er hält den App-Prozess am Leben, solange die Überwachung läuft — damit
|
||||
* Android die Mess-Schleife bei ausgeschaltetem Display oder App-Wechsel nicht
|
||||
* beendet. Die eigentliche Ping-Logik läuft im NetDiagScannerPlugin; dieser
|
||||
* Dienst zeigt nur die dauerhafte Benachrichtigung.
|
||||
*/
|
||||
class MonitorService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val text = intent?.getStringExtra(EXTRA_TEXT) ?: "Geräte-Überwachung läuft"
|
||||
val notification = buildNotification(text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, notification)
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun buildNotification(text: String): Notification {
|
||||
ensureChannel(this)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("NetDiag — Geräte-Monitor")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_compass)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "netdiag-monitor"
|
||||
const val NOTIF_ID = 4711
|
||||
const val EXTRA_TEXT = "text"
|
||||
|
||||
/** Benachrichtigungskanal anlegen (idempotent) */
|
||||
fun ensureChannel(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val mgr = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
mgr.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Geräte-Monitor",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun start(ctx: Context, text: String) {
|
||||
val i = Intent(ctx, MonitorService::class.java).putExtra(EXTRA_TEXT, text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.startForegroundService(i)
|
||||
} else {
|
||||
ctx.startService(i)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(ctx: Context) {
|
||||
ctx.stopService(Intent(ctx, MonitorService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,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 erreichbar↔nicht erreichbar erzeugt ein `monitorEvent`.
|
||||
* Ein Vordergrund-Dienst hält den Prozess am Leben (Display aus / App-Wechsel),
|
||||
* ein WifiLock verhindert, dass das WLAN zwischen den Messungen schläft.
|
||||
*/
|
||||
@PluginMethod
|
||||
fun startMonitor(call: PluginCall) {
|
||||
val arr = call.getArray("hosts") ?: return call.reject("hosts fehlt")
|
||||
val intervalSec = (call.getInt("intervalSec") ?: 30).coerceIn(5, 600)
|
||||
val targets = ArrayList<Pair<String, String>>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.optJSONObject(i) ?: continue
|
||||
val ip = o.optString("ip")
|
||||
if (ip.isNotEmpty()) targets.add(ip to o.optString("label", ip))
|
||||
}
|
||||
if (targets.isEmpty()) return call.reject("keine Geräte gewählt")
|
||||
|
||||
val runId = "mon-${System.currentTimeMillis()}"
|
||||
val run = MonitorRun(targets, intervalSec)
|
||||
monitorRuns[runId] = run
|
||||
MonitorService.start(context, "${targets.size} Geräte · alle ${intervalSec}s")
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val wifiLock = (context.applicationContext
|
||||
.getSystemService(Context.WIFI_SERVICE) as WifiManager)
|
||||
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "netdiag-monitor")
|
||||
try { wifiLock.acquire() } catch (_: Exception) { }
|
||||
|
||||
io.launch {
|
||||
try {
|
||||
// Ausgangslage erfassen — erzeugt noch kein Ereignis
|
||||
for ((ip, _) in targets) run.state[ip] = isReachable(ip)
|
||||
while (run.active) {
|
||||
Thread.sleep(intervalSec * 1000L)
|
||||
if (!run.active) break
|
||||
for ((ip, label) in targets) {
|
||||
if (!run.active) break
|
||||
val up = isReachable(ip)
|
||||
val prev = run.state[ip] ?: up
|
||||
if (up == prev) continue
|
||||
run.state[ip] = up
|
||||
val now = System.currentTimeMillis()
|
||||
val ev = JSObject()
|
||||
.put("runId", runId)
|
||||
.put("ip", ip)
|
||||
.put("label", label)
|
||||
.put("ts", now)
|
||||
if (up) {
|
||||
val since = run.downSince.remove(ip)
|
||||
ev.put("type", "up")
|
||||
ev.put(
|
||||
"durationSec",
|
||||
if (since != null) ((now - since) / 1000L).toInt() else 0,
|
||||
)
|
||||
} else {
|
||||
run.downSince[ip] = now
|
||||
ev.put("type", "down")
|
||||
notifyDown(label, ip)
|
||||
}
|
||||
run.events.add(ev)
|
||||
notifyListeners("monitorEvent", ev)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { if (wifiLock.isHeld) wifiLock.release() } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
resolve(call, JSObject().put("runId", runId))
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun stopMonitor(call: PluginCall) {
|
||||
val runId = call.getString("runId") ?: return call.reject("runId fehlt")
|
||||
val run = monitorRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden")
|
||||
run.active = false
|
||||
if (monitorRuns.isEmpty()) MonitorService.stop(context)
|
||||
val events = JSArray()
|
||||
run.events.forEach { events.put(it) }
|
||||
resolve(call, JSObject().put("stopped", true).put("events", events))
|
||||
}
|
||||
|
||||
/** Status eines Monitor-Laufs abfragen (UI-Wiederaufnahme nach Seitenwechsel) */
|
||||
@PluginMethod
|
||||
fun getMonitorStatus(call: PluginCall) {
|
||||
val runId = call.getString("runId") ?: return call.reject("runId fehlt")
|
||||
val run = monitorRuns[runId]
|
||||
val events = JSArray()
|
||||
run?.events?.forEach { events.put(it) }
|
||||
resolve(call, JSObject()
|
||||
.put("running", run != null && run.active)
|
||||
.put("events", events))
|
||||
}
|
||||
|
||||
private fun isReachable(ip: String): Boolean = try {
|
||||
InetAddress.getByName(ip).isReachable(1500)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/** Heads-up-Benachrichtigung, wenn ein überwachtes Gerät ausfällt */
|
||||
private fun notifyDown(label: String, ip: String) {
|
||||
try {
|
||||
MonitorService.ensureChannel(context)
|
||||
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val n = NotificationCompat.Builder(context, MonitorService.CHANNEL_ID)
|
||||
.setContentTitle("Gerät nicht erreichbar")
|
||||
.setContentText("$label ($ip) antwortet nicht mehr")
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
mgr.notify(ip.hashCode(), n)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- */
|
||||
/* App-Update: APK herunterladen und Paketinstaller öffnen */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
native-plugin/MonitorService.kt
Normal file
81
native-plugin/MonitorService.kt
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package de.data_it_solution.netdiag
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* Schlanker Vordergrund-Dienst für den Geräte-Monitor.
|
||||
*
|
||||
* Er hält den App-Prozess am Leben, solange die Überwachung läuft — damit
|
||||
* Android die Mess-Schleife bei ausgeschaltetem Display oder App-Wechsel nicht
|
||||
* beendet. Die eigentliche Ping-Logik läuft im NetDiagScannerPlugin; dieser
|
||||
* Dienst zeigt nur die dauerhafte Benachrichtigung.
|
||||
*/
|
||||
class MonitorService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val text = intent?.getStringExtra(EXTRA_TEXT) ?: "Geräte-Überwachung läuft"
|
||||
val notification = buildNotification(text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, notification)
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun buildNotification(text: String): Notification {
|
||||
ensureChannel(this)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("NetDiag — Geräte-Monitor")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_compass)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "netdiag-monitor"
|
||||
const val NOTIF_ID = 4711
|
||||
const val EXTRA_TEXT = "text"
|
||||
|
||||
/** Benachrichtigungskanal anlegen (idempotent) */
|
||||
fun ensureChannel(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val mgr = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
mgr.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Geräte-Monitor",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun start(ctx: Context, text: String) {
|
||||
val i = Intent(ctx, MonitorService::class.java).putExtra(EXTRA_TEXT, text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.startForegroundService(i)
|
||||
} else {
|
||||
ctx.startService(i)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(ctx: Context) {
|
||||
ctx.stopService(Intent(ctx, MonitorService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,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 erreichbar↔nicht erreichbar erzeugt ein `monitorEvent`.
|
||||
* Ein Vordergrund-Dienst hält den Prozess am Leben (Display aus / App-Wechsel),
|
||||
* ein WifiLock verhindert, dass das WLAN zwischen den Messungen schläft.
|
||||
*/
|
||||
@PluginMethod
|
||||
fun startMonitor(call: PluginCall) {
|
||||
val arr = call.getArray("hosts") ?: return call.reject("hosts fehlt")
|
||||
val intervalSec = (call.getInt("intervalSec") ?: 30).coerceIn(5, 600)
|
||||
val targets = ArrayList<Pair<String, String>>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.optJSONObject(i) ?: continue
|
||||
val ip = o.optString("ip")
|
||||
if (ip.isNotEmpty()) targets.add(ip to o.optString("label", ip))
|
||||
}
|
||||
if (targets.isEmpty()) return call.reject("keine Geräte gewählt")
|
||||
|
||||
val runId = "mon-${System.currentTimeMillis()}"
|
||||
val run = MonitorRun(targets, intervalSec)
|
||||
monitorRuns[runId] = run
|
||||
MonitorService.start(context, "${targets.size} Geräte · alle ${intervalSec}s")
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val wifiLock = (context.applicationContext
|
||||
.getSystemService(Context.WIFI_SERVICE) as WifiManager)
|
||||
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "netdiag-monitor")
|
||||
try { wifiLock.acquire() } catch (_: Exception) { }
|
||||
|
||||
io.launch {
|
||||
try {
|
||||
// Ausgangslage erfassen — erzeugt noch kein Ereignis
|
||||
for ((ip, _) in targets) run.state[ip] = isReachable(ip)
|
||||
while (run.active) {
|
||||
Thread.sleep(intervalSec * 1000L)
|
||||
if (!run.active) break
|
||||
for ((ip, label) in targets) {
|
||||
if (!run.active) break
|
||||
val up = isReachable(ip)
|
||||
val prev = run.state[ip] ?: up
|
||||
if (up == prev) continue
|
||||
run.state[ip] = up
|
||||
val now = System.currentTimeMillis()
|
||||
val ev = JSObject()
|
||||
.put("runId", runId)
|
||||
.put("ip", ip)
|
||||
.put("label", label)
|
||||
.put("ts", now)
|
||||
if (up) {
|
||||
val since = run.downSince.remove(ip)
|
||||
ev.put("type", "up")
|
||||
ev.put(
|
||||
"durationSec",
|
||||
if (since != null) ((now - since) / 1000L).toInt() else 0,
|
||||
)
|
||||
} else {
|
||||
run.downSince[ip] = now
|
||||
ev.put("type", "down")
|
||||
notifyDown(label, ip)
|
||||
}
|
||||
run.events.add(ev)
|
||||
notifyListeners("monitorEvent", ev)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { if (wifiLock.isHeld) wifiLock.release() } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
resolve(call, JSObject().put("runId", runId))
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun stopMonitor(call: PluginCall) {
|
||||
val runId = call.getString("runId") ?: return call.reject("runId fehlt")
|
||||
val run = monitorRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden")
|
||||
run.active = false
|
||||
if (monitorRuns.isEmpty()) MonitorService.stop(context)
|
||||
val events = JSArray()
|
||||
run.events.forEach { events.put(it) }
|
||||
resolve(call, JSObject().put("stopped", true).put("events", events))
|
||||
}
|
||||
|
||||
/** Status eines Monitor-Laufs abfragen (UI-Wiederaufnahme nach Seitenwechsel) */
|
||||
@PluginMethod
|
||||
fun getMonitorStatus(call: PluginCall) {
|
||||
val runId = call.getString("runId") ?: return call.reject("runId fehlt")
|
||||
val run = monitorRuns[runId]
|
||||
val events = JSArray()
|
||||
run?.events?.forEach { events.put(it) }
|
||||
resolve(call, JSObject()
|
||||
.put("running", run != null && run.active)
|
||||
.put("events", events))
|
||||
}
|
||||
|
||||
private fun isReachable(ip: String): Boolean = try {
|
||||
InetAddress.getByName(ip).isReachable(1500)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/** Heads-up-Benachrichtigung, wenn ein überwachtes Gerät ausfällt */
|
||||
private fun notifyDown(label: String, ip: String) {
|
||||
try {
|
||||
MonitorService.ensureChannel(context)
|
||||
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val n = NotificationCompat.Builder(context, MonitorService.CHANNEL_ID)
|
||||
.setContentTitle("Gerät nicht erreichbar")
|
||||
.setContentText("$label ($ip) antwortet nicht mehr")
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
mgr.notify(ip.hashCode(), n)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- */
|
||||
/* App-Update: APK herunterladen und Paketinstaller öffnen */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
56
src/lib/components/ConfirmDialog.svelte
Normal file
56
src/lib/components/ConfirmDialog.svelte
Normal 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>
|
||||
128
src/lib/components/DeviceCard.svelte
Normal file
128
src/lib/components/DeviceCard.svelte
Normal 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>
|
||||
36
src/lib/components/DeviceMultiSelect.svelte
Normal file
36
src/lib/components/DeviceMultiSelect.svelte
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Mehrfachauswahl von Geräten per Checkbox — z.B. um auszuwählen, welche
|
||||
* Geräte der Monitor überwachen soll. `selected` enthält die clientIds.
|
||||
*/
|
||||
import type { Device } from '$lib/types';
|
||||
|
||||
let {
|
||||
devices,
|
||||
selected = $bindable([]),
|
||||
}: { devices: Device[]; selected: string[] } = $props();
|
||||
|
||||
function toggle(id: string) {
|
||||
selected = selected.includes(id)
|
||||
? selected.filter((x) => x !== id)
|
||||
: [...selected, id];
|
||||
}
|
||||
|
||||
function label(d: Device): string {
|
||||
return d.customName || d.mdnsName || d.hostname || d.netbiosName || d.ip;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each devices as d (d.clientId)}
|
||||
<label class="flex items-center gap-2 rounded bg-zinc-800 px-2 py-1.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(d.clientId)}
|
||||
onchange={() => toggle(d.clientId)}
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate">{label(d)}</span>
|
||||
<span class="shrink-0 text-xs text-zinc-500">{d.ip}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
73
src/lib/components/TextPromptDialog.svelte
Normal file
73
src/lib/components/TextPromptDialog.svelte
Normal 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>
|
||||
|
|
@ -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
32
src/lib/overlay.svelte.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
90
src/lib/tools/netzwerk/ipconflict.ts
Normal file
90
src/lib/tools/netzwerk/ipconflict.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
298
src/routes/protokoll/[id]/monitor/+page.svelte
Normal file
298
src/routes/protokoll/[id]/monitor/+page.svelte
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Geräte-Monitor — überwacht die Erreichbarkeit ausgewählter Geräte über
|
||||
* längere Zeit und protokolliert jeden Ausfall mit Uhrzeit. Gedacht, um
|
||||
* sporadisch ausfallende Geräte (z.B. Kameras) einzukreisen.
|
||||
*
|
||||
* Die Überwachung läuft über einen Vordergrund-Dienst auch bei
|
||||
* ausgeschaltetem Display weiter. Verlässt man die Seite, läuft sie weiter;
|
||||
* beim Zurückkehren wird der Stand wieder aufgenommen.
|
||||
*/
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ChevronDown } from 'lucide-svelte';
|
||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||
import DeviceMultiSelect from '$lib/components/DeviceMultiSelect.svelte';
|
||||
import { getProtocol, saveProtocol } from '$lib/db';
|
||||
import { uid } from '$lib/protocols';
|
||||
import { scanner, onMonitorEvent, type MonitorEventData } from '$lib/scanner';
|
||||
import { sync } from '$lib/sync.svelte';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
import type { DeviceMonitorSession, MonitorEvent, Protocol } from '$lib/types';
|
||||
|
||||
let protocol = $state<Protocol | null>(null);
|
||||
let selected = $state<string[]>([]);
|
||||
let intervalSec = $state(30);
|
||||
let session = $state<DeviceMonitorSession | null>(null);
|
||||
let busy = $state(false);
|
||||
let expanded = $state<string | null>(null);
|
||||
let offEvent: (() => void) | undefined;
|
||||
|
||||
const running = $derived(session?.status === 'running');
|
||||
const pastSessions = $derived(
|
||||
(protocol?.monitorSessions ?? []).filter((s) => s.status === 'stopped'),
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
const p = await getProtocol($page.params.id ?? '');
|
||||
if (!p) {
|
||||
toast.show('Protokoll nicht gefunden', 'error');
|
||||
goto('/auftraege/');
|
||||
return;
|
||||
}
|
||||
protocol = p;
|
||||
|
||||
// Läuft bereits eine Überwachung? → wieder andocken
|
||||
const live = p.monitorSessions?.find((s) => s.status === 'running');
|
||||
if (live?.runId) {
|
||||
try {
|
||||
const st = await scanner.getMonitorStatus({ runId: live.runId });
|
||||
if (st.running) {
|
||||
live.events = st.events.map(toStored);
|
||||
session = live;
|
||||
attachListener(live.runId);
|
||||
} else {
|
||||
live.status = 'stopped';
|
||||
live.endedAt = Date.now();
|
||||
await persist();
|
||||
}
|
||||
} catch {
|
||||
live.status = 'stopped';
|
||||
await persist();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Listener lösen, aber die Überwachung NICHT stoppen — sie läuft weiter
|
||||
offEvent?.();
|
||||
void persist();
|
||||
});
|
||||
|
||||
async function persist() {
|
||||
if (!protocol) return;
|
||||
protocol.dirty = true;
|
||||
await saveProtocol($state.snapshot(protocol) as Protocol);
|
||||
await sync.refreshPending();
|
||||
}
|
||||
|
||||
function toStored(e: MonitorEventData): MonitorEvent {
|
||||
return { ts: e.ts, ip: e.ip, type: e.type, durationSec: e.durationSec };
|
||||
}
|
||||
|
||||
function attachListener(runId: string) {
|
||||
offEvent?.();
|
||||
offEvent = onMonitorEvent((e) => {
|
||||
if (e.runId !== runId || !session) return;
|
||||
session.events.push(toStored(e));
|
||||
void persist();
|
||||
});
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (!protocol || busy) return;
|
||||
const devices = protocol.devices.filter((d) => selected.includes(d.clientId));
|
||||
if (devices.length === 0) {
|
||||
toast.show('Bitte mindestens ein Gerät wählen', 'info');
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const hosts = devices.map((d) => ({
|
||||
ip: d.ip,
|
||||
label: d.customName || d.hostname || d.ip,
|
||||
}));
|
||||
const { runId } = await scanner.startMonitor({ hosts, intervalSec });
|
||||
const sessions = (protocol.monitorSessions ??= []);
|
||||
sessions.push({
|
||||
id: uid(),
|
||||
name: 'Überwachung ' + fmtDateTime(Date.now()),
|
||||
startedAt: Date.now(),
|
||||
intervalSec,
|
||||
targets: hosts,
|
||||
events: [],
|
||||
status: 'running',
|
||||
runId,
|
||||
});
|
||||
session = sessions[sessions.length - 1];
|
||||
attachListener(runId);
|
||||
await persist();
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : 'Monitor-Start fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!session?.runId || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const res = await scanner.stopMonitor({ runId: session.runId });
|
||||
session.events = res.events.map(toStored);
|
||||
session.status = 'stopped';
|
||||
session.endedAt = Date.now();
|
||||
offEvent?.();
|
||||
offEvent = undefined;
|
||||
await persist();
|
||||
toast.show('Überwachung beendet', 'success');
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : 'Monitor-Stopp fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
function fmtDateTime(ts: number): string {
|
||||
return new Date(ts).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
function fmtDuration(sec: number): string {
|
||||
if (sec < 60) return sec + ' s';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return s ? `${m} min ${s} s` : `${m} min`;
|
||||
}
|
||||
function labelFor(s: DeviceMonitorSession, ip: string): string {
|
||||
return s.targets.find((t) => t.ip === ip)?.label || ip;
|
||||
}
|
||||
function downCount(s: DeviceMonitorSession): number {
|
||||
return s.events.filter((e) => e.type === 'down').length;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet eventRow(s: DeviceMonitorSession, e: MonitorEvent)}
|
||||
<div class="mb-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
class="h-2 w-2 shrink-0 rounded-full {e.type === 'down' ? 'bg-red-500' : 'bg-emerald-500'}"
|
||||
></span>
|
||||
<span class="w-16 shrink-0 text-zinc-500">{fmtTime(e.ts)}</span>
|
||||
<span class="min-w-0 flex-1 truncate">{labelFor(s, e.ip)}</span>
|
||||
<span class="shrink-0 {e.type === 'down' ? 'text-red-400' : 'text-emerald-400'}">
|
||||
{e.type === 'down'
|
||||
? 'Ausfall'
|
||||
: 'wieder da' + (e.durationSec ? ' (' + fmtDuration(e.durationSec) + ')' : '')}
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if protocol}
|
||||
<AppHeader title="Geräte-Monitor" subtitle={protocol.label} back />
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
{#if running && session}
|
||||
<!-- Laufende Überwachung -->
|
||||
<div class="rounded-lg bg-zinc-900 p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2.5 w-2.5 animate-pulse rounded-full bg-emerald-500"></span>
|
||||
<span class="text-sm font-medium">Überwachung läuft</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-zinc-500">
|
||||
{session.targets.length} Geräte · alle {session.intervalSec}s · seit
|
||||
{fmtTime(session.startedAt)}
|
||||
</p>
|
||||
<button
|
||||
class="mt-3 w-full rounded-lg bg-red-600 py-2 text-sm font-semibold text-white active:bg-red-700 disabled:opacity-50"
|
||||
onclick={stop}
|
||||
disabled={busy}
|
||||
>
|
||||
Überwachung beenden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-1 mt-3 text-sm font-semibold text-zinc-300">
|
||||
Ereignisse ({session.events.length})
|
||||
</h2>
|
||||
{#if session.events.length === 0}
|
||||
<p class="text-xs text-zinc-500">Noch kein Ausfall — alle Geräte erreichbar.</p>
|
||||
{/if}
|
||||
{#each [...session.events].reverse() as e (e.ts + '-' + e.ip)}
|
||||
{@render eventRow(session, e)}
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Einrichtung -->
|
||||
{#if protocol.devices.length === 0}
|
||||
<p class="text-sm text-zinc-500">
|
||||
Noch keine Geräte — bitte zuerst einen IP-Scan ausführen.
|
||||
</p>
|
||||
{:else}
|
||||
<h2 class="mb-1 text-sm font-semibold text-zinc-300">Geräte zum Überwachen wählen</h2>
|
||||
<DeviceMultiSelect devices={protocol.devices} bind:selected />
|
||||
|
||||
<label class="mt-3 flex flex-col gap-1 text-xs text-zinc-400">
|
||||
Prüfintervall
|
||||
<select
|
||||
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
|
||||
bind:value={intervalSec}
|
||||
>
|
||||
<option value={15}>alle 15 Sekunden</option>
|
||||
<option value={30}>alle 30 Sekunden</option>
|
||||
<option value={60}>alle 1 Minute</option>
|
||||
<option value={120}>alle 2 Minuten</option>
|
||||
<option value={300}>alle 5 Minuten</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="mt-3 w-full rounded-lg bg-emerald-600 py-2 text-sm font-semibold text-white active:bg-emerald-700 disabled:opacity-50"
|
||||
onclick={start}
|
||||
disabled={busy || selected.length === 0}
|
||||
>
|
||||
Überwachung starten ({selected.length})
|
||||
</button>
|
||||
<p class="mt-2 text-[11px] leading-tight text-zinc-500">
|
||||
Läuft auch bei ausgeschaltetem Display weiter und meldet jeden Ausfall mit
|
||||
Uhrzeit — gut, um sporadisch ausfallende Geräte einzukreisen.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Frühere Überwachungen -->
|
||||
{#if pastSessions.length > 0}
|
||||
<h2 class="mb-1 mt-4 text-sm font-semibold text-zinc-300">Frühere Überwachungen</h2>
|
||||
{#each pastSessions as s (s.id)}
|
||||
<div class="mb-1.5 rounded-lg bg-zinc-900">
|
||||
<button
|
||||
class="flex w-full items-center justify-between gap-2 p-2.5 text-left"
|
||||
onclick={() => (expanded = expanded === s.id ? null : s.id)}
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">{s.name}</div>
|
||||
<div class="text-[11px] text-zinc-500">
|
||||
{s.targets.length} Geräte · {downCount(s)} Ausfälle · {fmtDateTime(s.startedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
class="shrink-0 text-zinc-500 {expanded === s.id ? 'rotate-180' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if expanded === s.id}
|
||||
<div class="border-t border-zinc-800 p-2.5">
|
||||
{#if s.events.length === 0}
|
||||
<p class="text-xs text-zinc-500">Kein Ausfall während der Überwachung.</p>
|
||||
{/if}
|
||||
{#each [...s.events].reverse() as e (e.ts + '-' + e.ip)}
|
||||
{@render eventRow(s, e)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-screen items-center justify-center text-zinc-500">Lädt …</div>
|
||||
{/if}
|
||||
Loading…
Reference in a new issue