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:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths"></meta-data>
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<!-- Vordergrund-Dienst des Geräte-Monitors -->
|
||||||
|
<service
|
||||||
|
android:name=".MonitorService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
@ -41,7 +47,12 @@
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_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.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
</manifest>
|
</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
|
package de.data_it_solution.netdiag
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.net.nsd.NsdManager
|
||||||
|
import android.net.nsd.NsdServiceInfo
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import com.getcapacitor.JSArray
|
import com.getcapacitor.JSArray
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
|
|
@ -27,6 +31,8 @@ import java.io.BufferedReader
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.FileReader
|
import java.io.FileReader
|
||||||
|
import java.net.DatagramPacket
|
||||||
|
import java.net.DatagramSocket
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
|
|
@ -34,6 +40,9 @@ import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
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.
|
* NetDiagScanner — natives Scan-Plugin der NetDiag-App.
|
||||||
|
|
@ -150,14 +159,36 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
}.awaitAll().filterNotNull()
|
}.awaitAll().filterNotNull()
|
||||||
}
|
}
|
||||||
val arp = readArpTable()
|
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()
|
val devices = JSArray()
|
||||||
for (ip in alive) {
|
for (h in enriched) {
|
||||||
val dev = JSObject().put("ip", ip)
|
val dev = JSObject().put("ip", h.ip)
|
||||||
arp[ip]?.let { dev.put("mac", it).put("vendor", ouiVendor(it)) }
|
val mac = arp[h.ip]
|
||||||
try {
|
val vendor = mac?.let { ouiVendor(it) } ?: ""
|
||||||
val name = InetAddress.getByName(ip).canonicalHostName
|
if (mac != null) dev.put("mac", mac)
|
||||||
if (name != ip) dev.put("hostname", name)
|
if (vendor.isNotEmpty()) dev.put("vendor", vendor)
|
||||||
} catch (_: Exception) { }
|
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)
|
devices.put(dev)
|
||||||
}
|
}
|
||||||
resolve(call, JSObject().put("devices", devices))
|
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.
|
* Alle Host-IPs (als Int) eines CIDR-Subnetzes.
|
||||||
* "192.168.1.0/24" -> .1 bis .254, "10.0.0.0/22" -> 1022 Hosts usw.
|
* "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 =
|
private fun intToIpv4(i: Int): String =
|
||||||
"${(i shr 24) and 0xFF}.${(i shr 16) and 0xFF}.${(i shr 8) and 0xFF}.${i and 0xFF}"
|
"${(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 */
|
/* Port-Scan */
|
||||||
/* --------------------------------------------------------------------- */
|
/* --------------------------------------------------------------------- */
|
||||||
|
|
@ -520,6 +827,140 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
var maxMs = 0.0
|
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 */
|
/* 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
|
private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Kleiner OUI-Auszug — bei Bedarf vollständige IEEE-OUI-Datei einbinden. */
|
/**
|
||||||
private val OUI = mapOf(
|
* Kuratierter OUI-Auszug der gängigsten Hersteller im Handwerksumfeld
|
||||||
"3810D5" to "AVM", "DCA632" to "Raspberry Pi", "B827EB" to "Raspberry Pi",
|
* (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf
|
||||||
"001CC0" to "Intel", "F0B479" to "Apple", "D8EB97" to "TP-Link"
|
* 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
|
package de.data_it_solution.netdiag
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.net.nsd.NsdManager
|
||||||
|
import android.net.nsd.NsdServiceInfo
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import com.getcapacitor.JSArray
|
import com.getcapacitor.JSArray
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
|
|
@ -27,6 +31,8 @@ import java.io.BufferedReader
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.FileReader
|
import java.io.FileReader
|
||||||
|
import java.net.DatagramPacket
|
||||||
|
import java.net.DatagramSocket
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
|
|
@ -34,6 +40,9 @@ import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
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.
|
* NetDiagScanner — natives Scan-Plugin der NetDiag-App.
|
||||||
|
|
@ -150,14 +159,36 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
}.awaitAll().filterNotNull()
|
}.awaitAll().filterNotNull()
|
||||||
}
|
}
|
||||||
val arp = readArpTable()
|
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()
|
val devices = JSArray()
|
||||||
for (ip in alive) {
|
for (h in enriched) {
|
||||||
val dev = JSObject().put("ip", ip)
|
val dev = JSObject().put("ip", h.ip)
|
||||||
arp[ip]?.let { dev.put("mac", it).put("vendor", ouiVendor(it)) }
|
val mac = arp[h.ip]
|
||||||
try {
|
val vendor = mac?.let { ouiVendor(it) } ?: ""
|
||||||
val name = InetAddress.getByName(ip).canonicalHostName
|
if (mac != null) dev.put("mac", mac)
|
||||||
if (name != ip) dev.put("hostname", name)
|
if (vendor.isNotEmpty()) dev.put("vendor", vendor)
|
||||||
} catch (_: Exception) { }
|
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)
|
devices.put(dev)
|
||||||
}
|
}
|
||||||
resolve(call, JSObject().put("devices", devices))
|
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.
|
* Alle Host-IPs (als Int) eines CIDR-Subnetzes.
|
||||||
* "192.168.1.0/24" -> .1 bis .254, "10.0.0.0/22" -> 1022 Hosts usw.
|
* "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 =
|
private fun intToIpv4(i: Int): String =
|
||||||
"${(i shr 24) and 0xFF}.${(i shr 16) and 0xFF}.${(i shr 8) and 0xFF}.${i and 0xFF}"
|
"${(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 */
|
/* Port-Scan */
|
||||||
/* --------------------------------------------------------------------- */
|
/* --------------------------------------------------------------------- */
|
||||||
|
|
@ -520,6 +827,140 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
var maxMs = 0.0
|
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 */
|
/* 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
|
private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Kleiner OUI-Auszug — bei Bedarf vollständige IEEE-OUI-Datei einbinden. */
|
/**
|
||||||
private val OUI = mapOf(
|
* Kuratierter OUI-Auszug der gängigsten Hersteller im Handwerksumfeld
|
||||||
"3810D5" to "AVM", "DCA632" to "Raspberry Pi", "B827EB" to "Raspberry Pi",
|
* (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf
|
||||||
"001CC0" to "Intel", "F0B479" to "Apple", "D8EB97" to "TP-Link"
|
* 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
|
* 3. Auf der Hauptroute: 1. Tap = Hinweis, 2. Tap binnen 1,8 s = App beenden
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { App, type PluginListenerHandle } from '@capacitor/app';
|
import { App } from '@capacitor/app';
|
||||||
import { Capacitor } from '@capacitor/core';
|
import { Capacitor, type PluginListenerHandle } from '@capacitor/core';
|
||||||
|
|
||||||
interface BackConfig {
|
interface BackConfig {
|
||||||
/** Schließt einen offenen Overlay-Zustand. true = verarbeitet, nichts weiter tun. */
|
/** 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 DB_NAME = 'netdiag';
|
||||||
const LS_PREFIX = 'netdiag.protocol.';
|
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 useSqlite = false;
|
||||||
let db: SQLiteDBConnection | null = null;
|
let db: SQLiteDBConnection | null = null;
|
||||||
|
|
||||||
|
|
@ -66,10 +80,10 @@ export async function getProtocol(uuid: string): Promise<Protocol | null> {
|
||||||
if (useSqlite && db) {
|
if (useSqlite && db) {
|
||||||
const res = await db.query('SELECT json FROM protocols WHERE uuid = ?', [uuid]);
|
const res = await db.query('SELECT json FROM protocols WHERE uuid = ?', [uuid]);
|
||||||
const row = res.values?.[0];
|
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);
|
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) */
|
/** Alle Protokolle laden (neueste zuerst) */
|
||||||
|
|
@ -77,12 +91,12 @@ export async function getAllProtocols(): Promise<Protocol[]> {
|
||||||
let list: Protocol[] = [];
|
let list: Protocol[] = [];
|
||||||
if (useSqlite && db) {
|
if (useSqlite && db) {
|
||||||
const res = await db.query('SELECT json FROM protocols ORDER BY updated_at DESC');
|
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 {
|
} else {
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
const key = localStorage.key(i);
|
const key = localStorage.key(i);
|
||||||
if (key?.startsWith(LS_PREFIX)) {
|
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);
|
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 { saveProtocol } from './db';
|
||||||
import type { Device, Measurement, Protocol } from './types';
|
import type { Device, Measurement, Protocol, SavedScan } from './types';
|
||||||
|
|
||||||
/** Eindeutige ID erzeugen */
|
/** Eindeutige ID erzeugen */
|
||||||
export function uid(): string {
|
export function uid(): string {
|
||||||
|
|
@ -44,11 +44,16 @@ export async function createProtocol(init: {
|
||||||
/** Gerät zum Protokoll hinzufügen oder per IP aktualisieren */
|
/** Gerät zum Protokoll hinzufügen oder per IP aktualisieren */
|
||||||
export function upsertDevice(
|
export function upsertDevice(
|
||||||
protocol: Protocol,
|
protocol: Protocol,
|
||||||
dev: Omit<Device, 'clientId'> & { clientId?: string },
|
dev: Partial<Device> & { ip: string },
|
||||||
): Device {
|
): Device {
|
||||||
const existing = protocol.devices.find((d) => d.ip === dev.ip);
|
const existing = protocol.devices.find((d) => d.ip === dev.ip);
|
||||||
if (existing) {
|
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;
|
return existing;
|
||||||
}
|
}
|
||||||
const created: Device = { ...dev, clientId: dev.clientId ?? uid() };
|
const created: Device = { ...dev, clientId: dev.clientId ?? uid() };
|
||||||
|
|
@ -56,6 +61,42 @@ export function upsertDevice(
|
||||||
return created;
|
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 */
|
/** Messung zum Protokoll hinzufügen */
|
||||||
export function addMeasurement(
|
export function addMeasurement(
|
||||||
protocol: Protocol,
|
protocol: Protocol,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
* Beispieldaten, damit die Oberfläche ohne Gerät entwickelt werden kann.
|
* 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 --- */
|
/* --- Datentypen der Plugin-Antworten --- */
|
||||||
|
|
||||||
|
|
@ -15,6 +15,40 @@ export interface ScannedDevice {
|
||||||
mac?: string;
|
mac?: string;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
vendor?: 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 {
|
export interface OpenPort {
|
||||||
port: number;
|
port: number;
|
||||||
|
|
@ -62,6 +96,14 @@ export interface NetDiagScannerPlugin {
|
||||||
getLocalSubnet(): Promise<{ subnet: string; ip: string; gateway: string }>;
|
getLocalSubnet(): Promise<{ subnet: string; ip: string; gateway: string }>;
|
||||||
/** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */
|
/** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */
|
||||||
ipScan(opts: { subnet: string }): Promise<{ devices: ScannedDevice[] }>;
|
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 */
|
/** Port-Scan eines Geräts */
|
||||||
portScan(opts: { ip: string; ports: number[] }): Promise<{ open: OpenPort[] }>;
|
portScan(opts: { ip: string; ports: number[] }): Promise<{ open: OpenPort[] }>;
|
||||||
/** Ping-Qualität (Latenz, Jitter, Paketverlust) */
|
/** Ping-Qualität (Latenz, Jitter, Paketverlust) */
|
||||||
|
|
@ -87,6 +129,17 @@ export interface NetDiagScannerPlugin {
|
||||||
avgMs: number;
|
avgMs: number;
|
||||||
maxMs: 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');
|
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;
|
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 = {
|
const mock: NetDiagScannerPlugin = {
|
||||||
async getLocalSubnet() {
|
async getLocalSubnet() {
|
||||||
return { subnet: '192.168.1.0/24', ip: '192.168.1.50', gateway: '192.168.1.1' };
|
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() {
|
async ipScan() {
|
||||||
return {
|
return {
|
||||||
devices: [
|
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.1',
|
||||||
{ ip: '192.168.1.50', mac: 'AA:BB:CC:00:00:32', hostname: 'handy', vendor: 'Samsung' },
|
mac: 'AA:BB:CC:00:00:01',
|
||||||
{ ip: '192.168.1.77', mac: 'AA:BB:CC:00:00:4D', hostname: 'wallbox', vendor: 'Keba' },
|
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) {
|
async portScan(opts) {
|
||||||
const all: OpenPort[] = [
|
const all: OpenPort[] = [
|
||||||
{ port: 80, service: 'http' },
|
{ port: 80, service: 'http' },
|
||||||
|
|
@ -174,7 +286,59 @@ const mock: NetDiagScannerPlugin = {
|
||||||
async stopStressTest() {
|
async stopStressTest() {
|
||||||
return { samples: 120, lossPct: rnd(0, 2), avgMs: rnd(3, 10), maxMs: rnd(20, 90) };
|
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 */
|
/** Aktive Scanner-Implementierung: nativ auf dem Gerät, Mock im Browser */
|
||||||
export const scanner: NetDiagScannerPlugin = Capacitor.isNativePlatform() ? native : mock;
|
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 type { Tool, ToolCategory } from './types';
|
||||||
|
|
||||||
import { dhcpCheckTool } from './netzwerk/dhcpcheck';
|
import { dhcpCheckTool } from './netzwerk/dhcpcheck';
|
||||||
|
import { ipConflictTool } from './netzwerk/ipconflict';
|
||||||
import { ipScanTool } from './netzwerk/ipscan';
|
import { ipScanTool } from './netzwerk/ipscan';
|
||||||
import { pingTool } from './netzwerk/ping';
|
import { pingTool } from './netzwerk/ping';
|
||||||
import { portScanTool } from './netzwerk/portscan';
|
import { portScanTool } from './netzwerk/portscan';
|
||||||
|
|
@ -28,6 +29,7 @@ export const TOOLS: Tool[] = [
|
||||||
pingTool,
|
pingTool,
|
||||||
wifiScanTool,
|
wifiScanTool,
|
||||||
dhcpCheckTool,
|
dhcpCheckTool,
|
||||||
|
ipConflictTool,
|
||||||
snmpTool,
|
snmpTool,
|
||||||
tracerouteTool,
|
tracerouteTool,
|
||||||
stressTestTool,
|
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.
|
* dessen Subnetz direkt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { scanner } from '../../scanner';
|
import { scanner, type MdnsDevice } from '../../scanner';
|
||||||
import { debugLog } from '../../debuglog.svelte';
|
import { debugLog } from '../../debuglog.svelte';
|
||||||
|
import type { Device } from '../../types';
|
||||||
import type { Tool } 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 = {
|
export const ipScanTool: Tool = {
|
||||||
id: 'ipscan',
|
id: 'ipscan',
|
||||||
category: 'netzwerk',
|
category: 'netzwerk',
|
||||||
|
|
@ -66,18 +80,51 @@ export const ipScanTool: Tool = {
|
||||||
`gescannt wird "${subnet}" (Quelle: ${source})`,
|
`gescannt wird "${subnet}" (Quelle: ${source})`,
|
||||||
);
|
);
|
||||||
const { devices } = await scanner.ipScan({ subnet });
|
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)' : '';
|
const via = source === 'adapter' ? ' (Adapter erkannt)' : '';
|
||||||
return {
|
return {
|
||||||
label: `${devices.length} Geräte im Netz ${subnet}${via}`,
|
label: `${merged.length} Geräte im Netz ${subnet}${via}`,
|
||||||
result: { subnet, count: devices.length, quelle: source },
|
result: { subnet, count: merged.length, quelle: source },
|
||||||
measureStatus: devices.length > 0 ? 0 : 1,
|
measureStatus: merged.length > 0 ? 0 : 1,
|
||||||
devices: devices.map((d) => ({
|
devices: merged,
|
||||||
ip: d.ip,
|
|
||||||
mac: d.mac,
|
|
||||||
hostname: d.hostname,
|
|
||||||
vendor: d.vendor,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -43,15 +43,9 @@ export interface ToolRunResult {
|
||||||
measureStatus: MeasureStatus;
|
measureStatus: MeasureStatus;
|
||||||
/**
|
/**
|
||||||
* Optional: im Netzwerk gefundene Geräte. Werden vom Protokoll
|
* 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<{
|
devices?: Array<Partial<Device> & { ip: string }>;
|
||||||
ip: string;
|
|
||||||
mac?: string;
|
|
||||||
hostname?: string;
|
|
||||||
vendor?: string;
|
|
||||||
deviceType?: string;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Diagnose-Werkzeug */
|
/** Ein Diagnose-Werkzeug */
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,56 @@ export interface Device {
|
||||||
vendor?: string;
|
vendor?: string;
|
||||||
deviceType?: string;
|
deviceType?: string;
|
||||||
note?: 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 */
|
/** Ampel-Bewertung einer Messung */
|
||||||
|
|
@ -92,6 +142,10 @@ export interface Protocol {
|
||||||
note: string;
|
note: string;
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
measurements: Measurement[];
|
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 */
|
/** true solange noch nicht zum Server synchronisiert */
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto, afterNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { Preferences } from '@capacitor/preferences';
|
||||||
import { auth } from '$lib/auth.svelte';
|
import { auth } from '$lib/auth.svelte';
|
||||||
import { sync } from '$lib/sync.svelte';
|
import { sync } from '$lib/sync.svelte';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
import { initDb } from '$lib/db';
|
import { initDb } from '$lib/db';
|
||||||
import { registerBackListener, removeBackListener } from '$lib/backButton.svelte';
|
import { registerBackListener, removeBackListener } from '$lib/backButton.svelte';
|
||||||
|
import { closeTopOverlay } from '$lib/overlay.svelte';
|
||||||
import { checkForUpdate, installUpdate, type UpdateInfo } from '$lib/updater';
|
import { checkForUpdate, installUpdate, type UpdateInfo } from '$lib/updater';
|
||||||
import { initDebugLog } from '$lib/debuglog.svelte';
|
import { initDebugLog } from '$lib/debuglog.svelte';
|
||||||
import Toast from '$lib/components/Toast.svelte';
|
import Toast from '$lib/components/Toast.svelte';
|
||||||
|
|
@ -21,6 +23,18 @@
|
||||||
let updatePercent = $state(0);
|
let updatePercent = $state(0);
|
||||||
|
|
||||||
const HOME = '/auftraege/';
|
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
|
// Update herunterladen und Installer öffnen — Fortschritt im Banner
|
||||||
async function runUpdate() {
|
async function runUpdate() {
|
||||||
|
|
@ -44,7 +58,7 @@
|
||||||
|
|
||||||
// Hardware-Backbutton (Modul-Scope, Single-Instance — KB #480/#549)
|
// Hardware-Backbutton (Modul-Scope, Single-Instance — KB #480/#549)
|
||||||
registerBackListener({
|
registerBackListener({
|
||||||
handleOverlay: () => false,
|
handleOverlay: () => closeTopOverlay(),
|
||||||
isHomeRoute: () => {
|
isHomeRoute: () => {
|
||||||
const p = $page.url.pathname;
|
const p = $page.url.pathname;
|
||||||
return p === HOME || p === '/' || p === '/login/';
|
return p === HOME || p === '/' || p === '/login/';
|
||||||
|
|
@ -53,6 +67,17 @@
|
||||||
showExitHint: () => toast.show('Nochmal drücken zum Beenden'),
|
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
|
// Auf neue APK prüfen — beim Start still (kein Toast), nur Banner bei Erfolg
|
||||||
try {
|
try {
|
||||||
updateInfo = await checkForUpdate();
|
updateInfo = await checkForUpdate();
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,15 @@
|
||||||
|
|
||||||
let orders = $state<Order[]>([]);
|
let orders = $state<Order[]>([]);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
// Standard: alle Aufträge, zuletzt bearbeitete zuerst (Server sortiert nach tms).
|
// Standard: nur aktive Aufträge. Haken entfernen zeigt auch abgeschlossene.
|
||||||
// Haken setzen blendet abgeschlossene aus und zeigt nur aktive Aufträge.
|
// Sortierung: Aufträge mit lokaler Scan-Tätigkeit zuerst, dann Server-Reihenfolge (tms).
|
||||||
let onlyActive = $state(false);
|
let onlyActive = $state(true);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let loadError = $state('');
|
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>;
|
let searchTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
/** Kunde + Ort als eine Zeile — das, was man im Kopf hat */
|
/** Kunde + Ort als eine Zeile — das, was man im Kopf hat */
|
||||||
|
|
@ -43,7 +46,23 @@
|
||||||
loadError = '';
|
loadError = '';
|
||||||
try {
|
try {
|
||||||
const res = await listOrders({ open: onlyActive, q: search.trim() || undefined });
|
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) {
|
} catch (e) {
|
||||||
loadError = e instanceof ApiError ? e.message : 'Laden fehlgeschlagen';
|
loadError = e instanceof ApiError ? e.message : 'Laden fehlgeschlagen';
|
||||||
} finally {
|
} 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() {
|
function onSearchInput() {
|
||||||
clearTimeout(searchTimer);
|
clearTimeout(searchTimer);
|
||||||
searchTimer = setTimeout(load, 300);
|
searchTimer = setTimeout(load, 300);
|
||||||
|
|
@ -84,7 +112,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
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();
|
load();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -121,6 +150,7 @@
|
||||||
<p class="p-6 text-center text-sm text-zinc-500">Keine Aufträge gefunden.</p>
|
<p class="p-6 text-center text-sm text-zinc-500">Keine Aufträge gefunden.</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each orders as order (order.id)}
|
{#each orders as order (order.id)}
|
||||||
|
{@const pc = Math.max(order.protocolCount ?? 0, localActivity.get(order.id)?.count ?? 0)}
|
||||||
<button
|
<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"
|
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)}
|
onclick={() => openOrder(order)}
|
||||||
|
|
@ -145,16 +175,12 @@
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Auftragsnummer + Bearbeitungsdatum: nur Kleingedrucktes -->
|
<!-- Auftragsnummer + Bearbeitungsdatum: nur Kleingedrucktes -->
|
||||||
<div class="truncate text-[11px] text-zinc-500">
|
<div class="truncate text-[11px] text-zinc-500">
|
||||||
{order.ref}{order.refClient ? ' · ' + order.refClient : ''}{order.tms
|
{order.ref}{order.refClient ? ' · ' + order.refClient : ''}{editedInfo(order)}
|
||||||
? ' · bearb. ' + fmtDate(order.tms)
|
|
||||||
: order.date
|
|
||||||
? ' · ' + fmtDate(order.date)
|
|
||||||
: ''}
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<span class="flex shrink-0 items-center gap-1 text-xs text-sky-400">
|
||||||
<FileStack size={14} />{order.protocolCount}
|
<FileStack size={14} />{pc}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,27 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { App } from '@capacitor/app';
|
||||||
|
import type { PluginListenerHandle } from '@capacitor/core';
|
||||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||||
import ToolDialog from '$lib/components/ToolDialog.svelte';
|
import ToolDialog from '$lib/components/ToolDialog.svelte';
|
||||||
import MeasurementResult from '$lib/components/MeasurementResult.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 { 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 { sync } from '$lib/sync.svelte';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
|
import { pushOverlay } from '$lib/overlay.svelte';
|
||||||
import { TOOLS, getTool } from '$lib/tools';
|
import { TOOLS, getTool } from '$lib/tools';
|
||||||
import type { Tool } from '$lib/tools/types';
|
import type { Tool } from '$lib/tools/types';
|
||||||
import type { Device, Protocol } from '$lib/types';
|
import type { Device, Protocol } from '$lib/types';
|
||||||
|
|
@ -18,15 +31,29 @@
|
||||||
let activeTool = $state<Tool | null>(null);
|
let activeTool = $state<Tool | null>(null);
|
||||||
let activeDevice = $state<Device | undefined>(undefined);
|
let activeDevice = $state<Device | undefined>(undefined);
|
||||||
let saving = $state(false);
|
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 protocolTools = TOOLS.filter((t) => t.scope === 'protocol');
|
||||||
const deviceTools = TOOLS.filter((t) => t.scope === 'device');
|
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 ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail'];
|
||||||
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500'];
|
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500'];
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const uuid = $page.params.id;
|
const uuid = $page.params.id ?? '';
|
||||||
const p = await getProtocol(uuid);
|
const p = await getProtocol(uuid);
|
||||||
if (!p) {
|
if (!p) {
|
||||||
toast.show('Protokoll nicht gefunden', 'error');
|
toast.show('Protokoll nicht gefunden', 'error');
|
||||||
|
|
@ -34,6 +61,19 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
protocol = p;
|
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 */
|
/** Protokoll als geändert markieren und lokal speichern */
|
||||||
|
|
@ -44,6 +84,13 @@
|
||||||
await sync.refreshPending();
|
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) */
|
/** Lucide-Icon dynamisch holen (Tool-Icon-Name ist kebab-case) */
|
||||||
function icon(name: string) {
|
function icon(name: string) {
|
||||||
const pascal = name
|
const pascal = name
|
||||||
|
|
@ -65,16 +112,11 @@
|
||||||
const tool = activeTool;
|
const tool = activeTool;
|
||||||
const result = await tool.run({ params, protocol, device: activeDevice });
|
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) {
|
if (result.devices) {
|
||||||
for (const d of result.devices) {
|
for (const d of result.devices) {
|
||||||
upsertDevice(protocol, {
|
upsertDevice(protocol, { ...d, lastSeen: Date.now() });
|
||||||
ip: d.ip,
|
|
||||||
mac: d.mac,
|
|
||||||
hostname: d.hostname,
|
|
||||||
vendor: d.vendor,
|
|
||||||
deviceType: d.deviceType,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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 (!protocol) return;
|
||||||
if (!confirm('Dieses Protokoll wirklich löschen?')) return;
|
|
||||||
await deleteProtocol(protocol.clientUuid);
|
await deleteProtocol(protocol.clientUuid);
|
||||||
await sync.refreshPending();
|
await sync.refreshPending();
|
||||||
goto('/auftraege/');
|
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) {
|
function measurementsFor(deviceClientId: string) {
|
||||||
return protocol?.measurements.filter((m) => m.deviceClientId === deviceClientId) ?? [];
|
return protocol?.measurements.filter((m) => m.deviceClientId === deviceClientId) ?? [];
|
||||||
}
|
}
|
||||||
|
|
@ -176,6 +267,17 @@
|
||||||
<span class="text-[11px] leading-tight text-zinc-500">{tool.description}</span>
|
<span class="text-[11px] leading-tight text-zinc-500">{tool.description}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -198,50 +300,74 @@
|
||||||
|
|
||||||
<!-- Geräte -->
|
<!-- Geräte -->
|
||||||
<section class="px-3 pb-3">
|
<section class="px-3 pb-3">
|
||||||
<h2 class="mb-2 text-sm font-semibold text-zinc-300">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold text-zinc-300">
|
||||||
Geräte ({protocol.devices.length})
|
Geräte ({protocol.devices.length})
|
||||||
</h2>
|
</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}
|
{#if protocol.devices.length === 0}
|
||||||
<p class="text-xs text-zinc-500">
|
<p class="text-xs text-zinc-500">
|
||||||
Noch keine Geräte — IP-Scanner ausführen, um das Netz zu erfassen.
|
Noch keine Geräte — IP-Scanner ausführen, um das Netz zu erfassen.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#each protocol.devices as device (device.clientId)}
|
{#each sortedDevices as device (device.clientId)}
|
||||||
<div class="mb-2 rounded-lg bg-zinc-900 p-3">
|
<DeviceCard
|
||||||
<div class="flex items-baseline justify-between">
|
{device}
|
||||||
<span class="font-medium">{device.ip}</span>
|
measurements={measurementsFor(device.clientId)}
|
||||||
<span class="text-xs text-zinc-500">{device.vendor ?? ''}</span>
|
tools={deviceTools}
|
||||||
</div>
|
onrun={(tool) => openTool(tool, device)}
|
||||||
<div class="text-xs text-zinc-500">
|
onfavorite={() => doToggleFav(device)}
|
||||||
{device.hostname ?? ''}{device.mac ? ' · ' + device.mac : ''}
|
onrename={() => (renameTarget = device)}
|
||||||
</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}
|
{/each}
|
||||||
</section>
|
</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">
|
<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
|
Protokoll löschen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -267,6 +393,50 @@
|
||||||
onrun={runTool}
|
onrun={runTool}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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}
|
{:else}
|
||||||
<div class="flex min-h-screen items-center justify-center text-zinc-500">Lädt …</div>
|
<div class="flex min-h-screen items-center justify-center text-zinc-500">Lädt …</div>
|
||||||
{/if}
|
{/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