Compare commits
No commits in common. "d2df3ee929aa8ded573350871c2a4386bdb28ea2" and "53d91d152673d437ce09b5888e2f051d12499646" have entirely different histories.
d2df3ee929
...
53d91d1526
23 changed files with 119 additions and 2576 deletions
|
|
@ -33,12 +33,6 @@
|
||||||
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 -->
|
||||||
|
|
@ -47,12 +41,7 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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,17 +1,13 @@
|
||||||
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
|
||||||
|
|
@ -31,8 +27,6 @@ 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
|
||||||
|
|
@ -40,9 +34,6 @@ 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.
|
||||||
|
|
@ -159,36 +150,14 @@ 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 (h in enriched) {
|
for (ip in alive) {
|
||||||
val dev = JSObject().put("ip", h.ip)
|
val dev = JSObject().put("ip", ip)
|
||||||
val mac = arp[h.ip]
|
arp[ip]?.let { dev.put("mac", it).put("vendor", ouiVendor(it)) }
|
||||||
val vendor = mac?.let { ouiVendor(it) } ?: ""
|
try {
|
||||||
if (mac != null) dev.put("mac", mac)
|
val name = InetAddress.getByName(ip).canonicalHostName
|
||||||
if (vendor.isNotEmpty()) dev.put("vendor", vendor)
|
if (name != ip) dev.put("hostname", name)
|
||||||
if (h.hostname.isNotEmpty()) dev.put("hostname", h.hostname)
|
} catch (_: Exception) { }
|
||||||
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))
|
||||||
|
|
@ -198,215 +167,6 @@ 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.
|
||||||
|
|
@ -452,73 +212,6 @@ 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 */
|
||||||
/* --------------------------------------------------------------------- */
|
/* --------------------------------------------------------------------- */
|
||||||
|
|
@ -827,140 +520,6 @@ 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 */
|
||||||
/* --------------------------------------------------------------------- */
|
/* --------------------------------------------------------------------- */
|
||||||
|
|
@ -1133,86 +692,10 @@ 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. */
|
||||||
* Kuratierter OUI-Auszug der gängigsten Hersteller im Handwerksumfeld
|
private val OUI = mapOf(
|
||||||
* (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf
|
"3810D5" to "AVM", "DCA632" to "Raspberry Pi", "B827EB" to "Raspberry Pi",
|
||||||
* Vollständigkeit — unbekannte MACs liefern einfach einen leeren Vendor.
|
"001CC0" to "Intel", "F0B479" to "Apple", "D8EB97" to "TP-Link"
|
||||||
*/
|
)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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,17 +1,13 @@
|
||||||
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
|
||||||
|
|
@ -31,8 +27,6 @@ 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
|
||||||
|
|
@ -40,9 +34,6 @@ 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.
|
||||||
|
|
@ -159,36 +150,14 @@ 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 (h in enriched) {
|
for (ip in alive) {
|
||||||
val dev = JSObject().put("ip", h.ip)
|
val dev = JSObject().put("ip", ip)
|
||||||
val mac = arp[h.ip]
|
arp[ip]?.let { dev.put("mac", it).put("vendor", ouiVendor(it)) }
|
||||||
val vendor = mac?.let { ouiVendor(it) } ?: ""
|
try {
|
||||||
if (mac != null) dev.put("mac", mac)
|
val name = InetAddress.getByName(ip).canonicalHostName
|
||||||
if (vendor.isNotEmpty()) dev.put("vendor", vendor)
|
if (name != ip) dev.put("hostname", name)
|
||||||
if (h.hostname.isNotEmpty()) dev.put("hostname", h.hostname)
|
} catch (_: Exception) { }
|
||||||
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))
|
||||||
|
|
@ -198,215 +167,6 @@ 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.
|
||||||
|
|
@ -452,73 +212,6 @@ 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 */
|
||||||
/* --------------------------------------------------------------------- */
|
/* --------------------------------------------------------------------- */
|
||||||
|
|
@ -827,140 +520,6 @@ 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 */
|
||||||
/* --------------------------------------------------------------------- */
|
/* --------------------------------------------------------------------- */
|
||||||
|
|
@ -1133,86 +692,10 @@ 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. */
|
||||||
* Kuratierter OUI-Auszug der gängigsten Hersteller im Handwerksumfeld
|
private val OUI = mapOf(
|
||||||
* (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf
|
"3810D5" to "AVM", "DCA632" to "Raspberry Pi", "B827EB" to "Raspberry Pi",
|
||||||
* Vollständigkeit — unbekannte MACs liefern einfach einen leeren Vendor.
|
"001CC0" to "Intel", "F0B479" to "Apple", "D8EB97" to "TP-Link"
|
||||||
*/
|
)
|
||||||
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 } from '@capacitor/app';
|
import { App, type PluginListenerHandle } from '@capacitor/app';
|
||||||
import { Capacitor, type PluginListenerHandle } from '@capacitor/core';
|
import { Capacitor } 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. */
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
<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,20 +17,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -80,10 +66,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 ? normalizeProtocol(JSON.parse(row.json) as Protocol) : null;
|
return row ? (JSON.parse(row.json) as Protocol) : null;
|
||||||
}
|
}
|
||||||
const raw = localStorage.getItem(LS_PREFIX + uuid);
|
const raw = localStorage.getItem(LS_PREFIX + uuid);
|
||||||
return raw ? normalizeProtocol(JSON.parse(raw) as Protocol) : null;
|
return raw ? (JSON.parse(raw) as Protocol) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Alle Protokolle laden (neueste zuerst) */
|
/** Alle Protokolle laden (neueste zuerst) */
|
||||||
|
|
@ -91,12 +77,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) => normalizeProtocol(JSON.parse(r.json) as Protocol));
|
list = (res.values ?? []).map((r) => 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(normalizeProtocol(JSON.parse(localStorage.getItem(key)!) as Protocol));
|
list.push(JSON.parse(localStorage.getItem(key)!) as Protocol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
list.sort((a, b) => b.updatedAt - a.updatedAt);
|
list.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
/**
|
|
||||||
* 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, SavedScan } from './types';
|
import type { Device, Measurement, Protocol } from './types';
|
||||||
|
|
||||||
/** Eindeutige ID erzeugen */
|
/** Eindeutige ID erzeugen */
|
||||||
export function uid(): string {
|
export function uid(): string {
|
||||||
|
|
@ -44,16 +44,11 @@ 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: Partial<Device> & { ip: string },
|
dev: Omit<Device, 'clientId'> & { clientId?: 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) {
|
||||||
// Nur gesetzte Felder übernehmen — ein magerer Re-Scan darf zuvor
|
Object.assign(existing, { ...dev, clientId: existing.clientId });
|
||||||
// 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() };
|
||||||
|
|
@ -61,42 +56,6 @@ 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, type PluginListenerHandle } from '@capacitor/core';
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
|
|
||||||
/* --- Datentypen der Plugin-Antworten --- */
|
/* --- Datentypen der Plugin-Antworten --- */
|
||||||
|
|
||||||
|
|
@ -15,40 +15,6 @@ 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;
|
||||||
|
|
@ -96,14 +62,6 @@ 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) */
|
||||||
|
|
@ -129,17 +87,6 @@ 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');
|
||||||
|
|
@ -152,11 +99,6 @@ 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' };
|
||||||
|
|
@ -164,67 +106,13 @@ 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.1',
|
{ ip: '192.168.1.10', mac: 'AA:BB:CC:00:00:0A', hostname: 'switch-keller', vendor: 'TP-Link' },
|
||||||
mac: 'AA:BB:CC:00:00:01',
|
{ ip: '192.168.1.50', mac: 'AA:BB:CC:00:00:32', hostname: 'handy', vendor: 'Samsung' },
|
||||||
hostname: 'fritzbox',
|
{ ip: '192.168.1.77', mac: 'AA:BB:CC:00:00:4D', hostname: 'wallbox', vendor: 'Keba' },
|
||||||
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' },
|
||||||
|
|
@ -286,59 +174,7 @@ 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,7 +10,6 @@
|
||||||
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';
|
||||||
|
|
@ -29,7 +28,6 @@ export const TOOLS: Tool[] = [
|
||||||
pingTool,
|
pingTool,
|
||||||
wifiScanTool,
|
wifiScanTool,
|
||||||
dhcpCheckTool,
|
dhcpCheckTool,
|
||||||
ipConflictTool,
|
|
||||||
snmpTool,
|
snmpTool,
|
||||||
tracerouteTool,
|
tracerouteTool,
|
||||||
stressTestTool,
|
stressTestTool,
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,24 +8,10 @@
|
||||||
* dessen Subnetz direkt.
|
* dessen Subnetz direkt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { scanner, type MdnsDevice } from '../../scanner';
|
import { scanner } 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',
|
||||||
|
|
@ -80,51 +66,18 @@ 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: `${merged.length} Geräte im Netz ${subnet}${via}`,
|
label: `${devices.length} Geräte im Netz ${subnet}${via}`,
|
||||||
result: { subnet, count: merged.length, quelle: source },
|
result: { subnet, count: devices.length, quelle: source },
|
||||||
measureStatus: merged.length > 0 ? 0 : 1,
|
measureStatus: devices.length > 0 ? 0 : 1,
|
||||||
devices: merged,
|
devices: devices.map((d) => ({
|
||||||
|
ip: d.ip,
|
||||||
|
mac: d.mac,
|
||||||
|
hostname: d.hostname,
|
||||||
|
vendor: d.vendor,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,15 @@ 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). `ip` ist Pflicht, alles Weitere optional.
|
* übernommen (z.B. beim IP-Scan).
|
||||||
*/
|
*/
|
||||||
devices?: Array<Partial<Device> & { ip: string }>;
|
devices?: Array<{
|
||||||
|
ip: string;
|
||||||
|
mac?: string;
|
||||||
|
hostname?: string;
|
||||||
|
vendor?: string;
|
||||||
|
deviceType?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Diagnose-Werkzeug */
|
/** Ein Diagnose-Werkzeug */
|
||||||
|
|
|
||||||
|
|
@ -54,56 +54,6 @@ 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 */
|
||||||
|
|
@ -142,10 +92,6 @@ 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,15 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto, afterNavigate } from '$app/navigation';
|
import { goto } 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';
|
||||||
|
|
@ -23,18 +21,6 @@
|
||||||
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() {
|
||||||
|
|
@ -58,7 +44,7 @@
|
||||||
|
|
||||||
// Hardware-Backbutton (Modul-Scope, Single-Instance — KB #480/#549)
|
// Hardware-Backbutton (Modul-Scope, Single-Instance — KB #480/#549)
|
||||||
registerBackListener({
|
registerBackListener({
|
||||||
handleOverlay: () => closeTopOverlay(),
|
handleOverlay: () => false,
|
||||||
isHomeRoute: () => {
|
isHomeRoute: () => {
|
||||||
const p = $page.url.pathname;
|
const p = $page.url.pathname;
|
||||||
return p === HOME || p === '/' || p === '/login/';
|
return p === HOME || p === '/' || p === '/login/';
|
||||||
|
|
@ -67,17 +53,6 @@
|
||||||
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,15 +12,12 @@
|
||||||
|
|
||||||
let orders = $state<Order[]>([]);
|
let orders = $state<Order[]>([]);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
// Standard: nur aktive Aufträge. Haken entfernen zeigt auch abgeschlossene.
|
// Standard: alle Aufträge, zuletzt bearbeitete zuerst (Server sortiert nach tms).
|
||||||
// Sortierung: Aufträge mit lokaler Scan-Tätigkeit zuerst, dann Server-Reihenfolge (tms).
|
// Haken setzen blendet abgeschlossene aus und zeigt nur aktive Aufträge.
|
||||||
let onlyActive = $state(true);
|
let onlyActive = $state(false);
|
||||||
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 */
|
||||||
|
|
@ -46,23 +43,7 @@
|
||||||
loadError = '';
|
loadError = '';
|
||||||
try {
|
try {
|
||||||
const res = await listOrders({ open: onlyActive, q: search.trim() || undefined });
|
const res = await listOrders({ open: onlyActive, q: search.trim() || undefined });
|
||||||
// Lokale Protokoll-Tätigkeit erfassen — ein Scan auf dem Handy ändert die
|
orders = res.orders;
|
||||||
// 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 {
|
||||||
|
|
@ -70,15 +51,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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);
|
||||||
|
|
@ -112,8 +84,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const pref = (await Preferences.get({ key: 'nd_only_active' })).value;
|
onlyActive = (await Preferences.get({ key: 'nd_only_active' })).value === '1';
|
||||||
onlyActive = pref == null ? true : pref === '1';
|
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -150,7 +121,6 @@
|
||||||
<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)}
|
||||||
|
|
@ -175,12 +145,16 @@
|
||||||
{/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 : ''}{editedInfo(order)}
|
{order.ref}{order.refClient ? ' · ' + order.refClient : ''}{order.tms
|
||||||
|
? ' · bearb. ' + fmtDate(order.tms)
|
||||||
|
: order.date
|
||||||
|
? ' · ' + fmtDate(order.date)
|
||||||
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if pc > 0}
|
{#if order.protocolCount && order.protocolCount > 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} />{pc}
|
<FileStack size={14} />{order.protocolCount}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount } 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 {
|
import { addMeasurement, upsertDevice } from '$lib/protocols';
|
||||||
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';
|
||||||
|
|
@ -31,29 +18,15 @@
|
||||||
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');
|
||||||
|
|
@ -61,19 +34,6 @@
|
||||||
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 */
|
||||||
|
|
@ -84,13 +44,6 @@
|
||||||
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
|
||||||
|
|
@ -112,11 +65,16 @@
|
||||||
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) — alle gelieferten
|
// Neu gefundene Geräte übernehmen (z.B. IP-Scan)
|
||||||
// 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, { ...d, lastSeen: Date.now() });
|
upsertDevice(protocol, {
|
||||||
|
ip: d.ip,
|
||||||
|
mac: d.mac,
|
||||||
|
hostname: d.hostname,
|
||||||
|
vendor: d.vendor,
|
||||||
|
deviceType: d.deviceType,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,63 +106,14 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Favoriten-Stern eines Geräts umschalten */
|
async function removeProtocol() {
|
||||||
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) ?? [];
|
||||||
}
|
}
|
||||||
|
|
@ -267,17 +176,6 @@
|
||||||
<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>
|
||||||
|
|
||||||
|
|
@ -300,74 +198,50 @@
|
||||||
|
|
||||||
<!-- Geräte -->
|
<!-- Geräte -->
|
||||||
<section class="px-3 pb-3">
|
<section class="px-3 pb-3">
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<h2 class="mb-2 text-sm font-semibold text-zinc-300">
|
||||||
<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 sortedDevices as device (device.clientId)}
|
{#each protocol.devices as device (device.clientId)}
|
||||||
<DeviceCard
|
<div class="mb-2 rounded-lg bg-zinc-900 p-3">
|
||||||
{device}
|
<div class="flex items-baseline justify-between">
|
||||||
measurements={measurementsFor(device.clientId)}
|
<span class="font-medium">{device.ip}</span>
|
||||||
tools={deviceTools}
|
<span class="text-xs text-zinc-500">{device.vendor ?? ''}</span>
|
||||||
onrun={(tool) => openTool(tool, device)}
|
</div>
|
||||||
onfavorite={() => doToggleFav(device)}
|
<div class="text-xs text-zinc-500">
|
||||||
onrename={() => (renameTarget = device)}
|
{device.hostname ?? ''}{device.mac ? ' · ' + device.mac : ''}
|
||||||
/>
|
</div>
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Gespeicherte Scans -->
|
{#each measurementsFor(device.clientId) as m (m.clientId)}
|
||||||
{#if protocol.savedScans && protocol.savedScans.length > 0}
|
<div class="mt-1.5 flex items-start gap-2 border-t border-zinc-800 pt-1.5">
|
||||||
<section class="px-3 pb-3">
|
<span class="mt-1 h-2 w-2 shrink-0 rounded-full {ampelDot[m.measureStatus]}"></span>
|
||||||
<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="min-w-0">
|
||||||
<div class="truncate text-sm font-medium">{scan.name}</div>
|
<p class="text-xs {ampel[m.measureStatus]}">{m.label}</p>
|
||||||
<div class="text-[11px] text-zinc-500">
|
<MeasurementResult result={m.result} />
|
||||||
{scan.devices.length} Geräte · {scan.subnet || '—'} · {fmtDateTime(scan.createdAt)}
|
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{/each}
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{#each deviceTools as tool (tool.id)}
|
||||||
<button
|
<button
|
||||||
class="mt-1 text-xs text-red-400 underline"
|
class="rounded bg-zinc-800 px-2 py-1 text-xs text-sky-300 active:bg-zinc-700"
|
||||||
onclick={() => (deleteScanId = scan.id)}
|
onclick={() => openTool(tool, device)}
|
||||||
>
|
>
|
||||||
Scan löschen
|
{tool.name}
|
||||||
</button>
|
</button>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="px-3">
|
<div class="px-3">
|
||||||
<button class="text-xs text-red-400 underline" onclick={() => (confirmDelete = true)}>
|
<button class="text-xs text-red-400 underline" onclick={removeProtocol}>
|
||||||
Protokoll löschen
|
Protokoll löschen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -393,50 +267,6 @@
|
||||||
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}
|
||||||
|
|
|
||||||
|
|
@ -1,298 +0,0 @@
|
||||||
<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