From bf01b4cd213eaedf054d2baa2402618afbb8d16d Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Tue, 19 May 2026 12:01:56 +0200 Subject: [PATCH] =?UTF-8?q?Initiales=20Commit=20=E2=80=94=20NetDiag=20App?= =?UTF-8?q?=20vollst=C3=A4ndig=20implementiert=20[apk]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SvelteKit + Capacitor 6 Netzwerk-Diagnose-App: - Tool-Plattform (IP-Scan, Port, Ping, WLAN, DHCP, SNMP, Traceroute, Stresstest, iperf) - Offline-First SQLite-Cache + idempotenter Dolibarr-Sync - Natives Kotlin-Plugin NetDiagScanner (ARP, Ping, Ports, WLAN, DHCP, SNMP, Traceroute) - Backbutton-Single-Instance-Modul, Auto-Updater, Toast-System - Auftrags-/Kunden-Übersicht nach Baustellen-App-Muster - CI: [apk]-Tag → Forgejo Runner → Package Registry netdiag-apk Co-Authored-By: Claude Opus 4.7 (1M context) --- .forgejo/workflows/build.yml | 143 +++++++ .gitignore | 19 + README.md | 73 ++++ capacitor.config.ts | 17 + native-plugin/NetDiagScannerPlugin.kt | 551 +++++++++++++++++++++++++ native-plugin/README.md | 72 ++++ native-plugin/Snmp.kt | 138 +++++++ package.json | 39 ++ src/app.css | 35 ++ src/app.d.ts | 21 + src/app.html | 13 + src/lib/api.ts | 217 ++++++++++ src/lib/auth.svelte.ts | 59 +++ src/lib/backButton.svelte.ts | 85 ++++ src/lib/components/AppHeader.svelte | 43 ++ src/lib/components/Toast.svelte | 17 + src/lib/components/ToolDialog.svelte | 95 +++++ src/lib/db.ts | 97 +++++ src/lib/protocols.ts | 67 +++ src/lib/scanner.ts | 169 ++++++++ src/lib/sync.svelte.ts | 88 ++++ src/lib/toast.svelte.ts | 36 ++ src/lib/tools/index.ts | 55 +++ src/lib/tools/internet/iperf.ts | 55 +++ src/lib/tools/netzwerk/dhcpcheck.ts | 35 ++ src/lib/tools/netzwerk/ipscan.ts | 39 ++ src/lib/tools/netzwerk/ping.ts | 46 +++ src/lib/tools/netzwerk/portscan.ts | 47 +++ src/lib/tools/netzwerk/snmp.ts | 59 +++ src/lib/tools/netzwerk/stresstest.ts | 59 +++ src/lib/tools/netzwerk/traceroute.ts | 28 ++ src/lib/tools/netzwerk/wifiscan.ts | 28 ++ src/lib/tools/types.ts | 73 ++++ src/lib/types.ts | 94 +++++ src/lib/updater.ts | 68 +++ src/routes/+layout.svelte | 79 ++++ src/routes/+layout.ts | 8 + src/routes/+page.svelte | 9 + src/routes/auftraege/+page.svelte | 144 +++++++ src/routes/einstellungen/+page.svelte | 65 +++ src/routes/kunden/+page.svelte | 77 ++++ src/routes/login/+page.svelte | 85 ++++ src/routes/protokoll/[id]/+page.svelte | 276 +++++++++++++ svelte.config.js | 16 + tsconfig.json | 15 + vite.config.ts | 21 + 46 files changed, 3575 insertions(+) create mode 100644 .forgejo/workflows/build.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 capacitor.config.ts create mode 100644 native-plugin/NetDiagScannerPlugin.kt create mode 100644 native-plugin/README.md create mode 100644 native-plugin/Snmp.kt create mode 100644 package.json create mode 100644 src/app.css create mode 100644 src/app.d.ts create mode 100644 src/app.html create mode 100644 src/lib/api.ts create mode 100644 src/lib/auth.svelte.ts create mode 100644 src/lib/backButton.svelte.ts create mode 100644 src/lib/components/AppHeader.svelte create mode 100644 src/lib/components/Toast.svelte create mode 100644 src/lib/components/ToolDialog.svelte create mode 100644 src/lib/db.ts create mode 100644 src/lib/protocols.ts create mode 100644 src/lib/scanner.ts create mode 100644 src/lib/sync.svelte.ts create mode 100644 src/lib/toast.svelte.ts create mode 100644 src/lib/tools/index.ts create mode 100644 src/lib/tools/internet/iperf.ts create mode 100644 src/lib/tools/netzwerk/dhcpcheck.ts create mode 100644 src/lib/tools/netzwerk/ipscan.ts create mode 100644 src/lib/tools/netzwerk/ping.ts create mode 100644 src/lib/tools/netzwerk/portscan.ts create mode 100644 src/lib/tools/netzwerk/snmp.ts create mode 100644 src/lib/tools/netzwerk/stresstest.ts create mode 100644 src/lib/tools/netzwerk/traceroute.ts create mode 100644 src/lib/tools/netzwerk/wifiscan.ts create mode 100644 src/lib/tools/types.ts create mode 100644 src/lib/types.ts create mode 100644 src/lib/updater.ts create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/+layout.ts create mode 100644 src/routes/+page.svelte create mode 100644 src/routes/auftraege/+page.svelte create mode 100644 src/routes/einstellungen/+page.svelte create mode 100644 src/routes/kunden/+page.svelte create mode 100644 src/routes/login/+page.svelte create mode 100644 src/routes/protokoll/[id]/+page.svelte create mode 100644 svelte.config.js create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 0000000..8422cd8 --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -0,0 +1,143 @@ +name: Build APK + +on: + push: + branches: [main] + +jobs: + build-apk: + if: contains(github.event.head_commit.message, '[apk]') + runs-on: docker + steps: + - name: Notify Start + uses: https://git.data-it-solution.de/data/ntfy-action@main + with: + status: start + project: NetDiag APK + ntfy_auth: ${{ secrets.NTFY_AUTH }} + run_number: ${{ github.run_number }} + message: ${{ github.event.head_commit.message }} + + - name: Repo klonen + Version festlegen + run: | + git clone https://data:${{ secrets.GIT_TOKEN }}@git.data-it-solution.de/data/netdiag-app.git . + git checkout ${{ github.sha }} + echo "$(date +%Y%m%d-%H%M)" > /tmp/BUILD_VERSION + + - name: Node.js Dependencies installieren + run: npm install + + - name: Frontend bauen + run: VITE_APP_VERSION="$(cat /tmp/BUILD_VERSION)" npx vite build + + - name: Android-Projekt anlegen + run: | + # cap add android nur wenn android/ noch nicht vorhanden + [ -d android ] || npx cap add android + + - name: Natives Plugin kopieren + run: | + PLUGIN_DST=android/app/src/main/java/de/data_it_solution/netdiag + mkdir -p "$PLUGIN_DST" + cp native-plugin/NetDiagScannerPlugin.kt "$PLUGIN_DST/" + cp native-plugin/Snmp.kt "$PLUGIN_DST/" + + - name: MainActivity — Plugin registrieren + run: | + MAIN=android/app/src/main/java/de/data_it_solution/netdiag/MainActivity.kt + if ! grep -q 'NetDiagScannerPlugin' "$MAIN"; then + cat > "$MAIN" <<'KT' + package de.data_it_solution.netdiag + + import android.os.Bundle + import com.getcapacitor.BridgeActivity + + class MainActivity : BridgeActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + registerPlugin(NetDiagScannerPlugin::class.java) + super.onCreate(savedInstanceState) + } + } + KT + fi + + - name: Kotlin-Coroutines sicherstellen + run: | + GRADLE=android/app/build.gradle + if ! grep -q 'kotlinx-coroutines-android' "$GRADLE"; then + sed -i '/dependencies {/a \ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"' "$GRADLE" + fi + + - name: Capacitor sync + run: npx cap sync android + + - name: Keystore vorbereiten + run: | + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > /tmp/release.jks + cat > android/gradle.properties < local.properties + echo "org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m" >> gradle.properties + chmod +x gradlew + ./gradlew assembleRelease --no-daemon + + - name: APK in Package Registry hochladen + run: | + VERSION=$(cat /tmp/BUILD_VERSION) + APK_FILE=$(find android/app/build -name "*.apk" -path "*/release/*" | head -1) + echo "APK gefunden: $APK_FILE" + + curl --fail --user "data:${{ secrets.REGISTRY_TOKEN }}" \ + --upload-file "$APK_FILE" \ + "https://git.data-it-solution.de/api/packages/data-it/generic/netdiag-apk/${VERSION}/NetDiag-${VERSION}.apk" + + curl -s -X DELETE --user "data:${{ secrets.REGISTRY_TOKEN }}" \ + "https://git.data-it-solution.de/api/v1/packages/data-it/generic/netdiag-apk/latest" || true + + curl --fail --user "data:${{ secrets.REGISTRY_TOKEN }}" \ + --upload-file "$APK_FILE" \ + "https://git.data-it-solution.de/api/packages/data-it/generic/netdiag-apk/latest/NetDiag.apk" + + echo "APK hochgeladen: ${VERSION} + latest" + + - name: Notify Success + if: success() + uses: https://git.data-it-solution.de/data/ntfy-action@main + with: + status: success + project: NetDiag APK + ntfy_auth: ${{ secrets.NTFY_AUTH }} + run_number: ${{ github.run_number }} + + - name: Notify Failure + if: failure() + uses: https://git.data-it-solution.de/data/ntfy-action@main + with: + status: failure + project: NetDiag APK + ntfy_auth: ${{ secrets.NTFY_AUTH }} + run_number: ${{ github.run_number }} + click_url: https://git.data-it-solution.de/${{ github.repository }}/actions diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b216105 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +node_modules/ +/build/ +/.svelte-kit/ +/package/ +.env +.env.* +!.env.example +.DS_Store +vite.config.ts.timestamp-* + +# Capacitor — android/ wird committet (CI braucht es), nur Build-Artefakte ignorieren +android/app/build/ +android/build/ +android/.gradle/ +android/local.properties +android/app/release/ +*.apk +*.keystore +*.jks diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f8612c --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# NetDiag — Diagnose-App (Android) + +Mobile Netzwerk-Diagnose-App. Erfasst vor Ort beim Kunden (Handy am WLAN oder +USB-C→RJ45-Adapter) Geräte, Ports und Messungen und hängt die Protokolle ans +Dolibarr-Modul `netdiag` an Kunde und Auftrag. + +## Stack + +SvelteKit 2 · Svelte 5 · Tailwind 4 · Vite 7 · Capacitor 6 · SQLite-Offline + +## Entwicklung (Browser) + +```bash +npm install +npm run dev # http://localhost:5175 +``` + +Im Browser liefert ein **Mock** (`src/lib/scanner.ts`) Beispiel-Scandaten — die +Oberfläche lässt sich ohne Gerät entwickeln. API-Aufrufe gehen über den +Vite-Proxy an den Dolibarr-Testserver (`192.168.155.11`, siehe `vite.config.ts`). + +## Android-Build + +```bash +npm run build +npx cap add android # einmalig +# native-plugin/ einbinden -> siehe native-plugin/README.md +npx cap sync android +npx cap open android +``` + +Release-APK über CI: Commit mit `[apk]` in der Message → Forgejo baut und lädt +die APK in die Package Registry (`netdiag-apk`). Siehe `.forgejo/workflows/build.yml`. + +## Architektur + +``` +src/lib/ + api.ts JSON-API-Client (JWT, 401-Refresh, Timeout) + auth.svelte.ts Anmelde-Status + db.ts Offline-Speicher (SQLite nativ / localStorage Browser) + sync.svelte.ts Sync-Queue -> Dolibarr (idempotent über clientUuid) + scanner.ts Brücke zum nativen Plugin (+ Browser-Mock) + backButton.svelte.ts Hardware-Back (Single-Instance, KB #480/#549) + updater.ts APK-Auto-Update-Prüfung (KB #363) + tools/ erweiterbare Tool-Plattform + index.ts Registry — neues Tool hier eintragen + netzwerk/ IP-Scan, Port, Ping, WLAN, DHCP, SNMP, Traceroute, Stress + internet/ Durchsatz-Test + telefonie/ (folgt: SIP, FreePBX, RTP) +src/routes/ + login/ auftraege/ kunden/ protokoll/[id]/ einstellungen/ +native-plugin/ Kotlin-Plugin NetDiagScanner (+ Integrationsanleitung) +``` + +## Neues Tool hinzufügen + +1. Datei unter `src/lib/tools//.ts` anlegen, `Tool` implementieren. +2. In `src/lib/tools/index.ts` importieren und in `TOOLS` eintragen. + +Kein Eingriff in App-Logik, Sync oder Datenbank — das Ergebnis ist generisches +JSON. Braucht das Tool eine neue native Messroutine, eine Methode im +Kotlin-Plugin ergänzen. + +## Bedienung + +1. **Anmelden** (Dolibarr-Zugang; auf dem Gerät zusätzlich Server-URL). +2. **Aufträge** — aktive direkt sichtbar, abgeschlossene per Checkbox, Suche. + Alternativ über **Kunden** suchen. +3. Auftrag/Kunde antippen → Diagnose-Protokoll öffnet sich. +4. **Werkzeuge** ausführen (IP-Scan füllt die Geräteliste, je Gerät weitere Tools). +5. **Abschließen & synchronisieren** — Protokoll geht ans Dolibarr, PDF landet + im ECM. Offline bleibt es lokal und synct automatisch bei Verbindung. diff --git a/capacitor.config.ts b/capacitor.config.ts new file mode 100644 index 0000000..bd63a92 --- /dev/null +++ b/capacitor.config.ts @@ -0,0 +1,17 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'de.data_it_solution.netdiag', + appName: 'NetDiag', + webDir: 'build', + server: { androidScheme: 'https' }, + plugins: { + SplashScreen: { + launchAutoHide: true, + backgroundColor: '#0d1117', + showSpinner: false, + }, + }, +}; + +export default config; diff --git a/native-plugin/NetDiagScannerPlugin.kt b/native-plugin/NetDiagScannerPlugin.kt new file mode 100644 index 0000000..04dfeff --- /dev/null +++ b/native-plugin/NetDiagScannerPlugin.kt @@ -0,0 +1,551 @@ +package de.data_it_solution.netdiag + +import android.Manifest +import android.content.Context +import android.net.wifi.WifiManager +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import com.getcapacitor.annotation.Permission +import com.getcapacitor.annotation.PermissionCallback +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.File +import java.io.FileReader +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.util.concurrent.ConcurrentHashMap + +/** + * NetDiagScanner — natives Scan-Plugin der NetDiag-App. + * + * Der WebView kann keine Raw-Sockets/ICMP/ARP. Diese Klasse führt die + * eigentlichen Netzwerk-Messungen durch und wird vom TS-Wrapper + * (src/lib/scanner.ts) über `registerPlugin('NetDiagScanner')` angesprochen. + * + * Integration: Datei nach + * android/app/src/main/java/de/data_it_solution/netdiag/ + * kopieren und in MainActivity registrieren: + * registerPlugin(NetDiagScannerPlugin::class.java) + */ +@CapacitorPlugin( + name = "NetDiagScanner", + permissions = [ + Permission(alias = "location", strings = [Manifest.permission.ACCESS_FINE_LOCATION]) + ] +) +class NetDiagScannerPlugin : Plugin() { + + private val io = CoroutineScope(Dispatchers.IO) + private val stressRuns = ConcurrentHashMap() + + /* --------------------------------------------------------------------- */ + /* Subnetz / lokale Netzwerkinfo */ + /* --------------------------------------------------------------------- */ + + @PluginMethod + fun getLocalSubnet(call: PluginCall) { + io.launch { + try { + val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo + val ipInt = dhcp?.ipAddress ?: 0 + val gwInt = dhcp?.gateway ?: 0 + val ip = if (ipInt != 0) intToIp(ipInt) else firstLocalIpv4() + val gateway = if (gwInt != 0) intToIp(gwInt) else "" + val base = ip.substringBeforeLast('.', "192.168.1") + resolve(call, JSObject() + .put("subnet", "$base.0/24") + .put("ip", ip) + .put("gateway", gateway)) + } catch (e: Exception) { + call.reject("getLocalSubnet: ${e.message}") + } + } + } + + /* --------------------------------------------------------------------- */ + /* IP-Scan: Geräte im Subnetz finden */ + /* --------------------------------------------------------------------- */ + + @PluginMethod + fun ipScan(call: PluginCall) { + val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt") + val base = subnet.substringBeforeLast('.', "192.168.1") + io.launch { + try { + // Parallel-Ping über das gesamte /24 + val alive = withContext(Dispatchers.IO) { + (1..254).map { host -> + async { + val ip = "$base.$host" + if (InetAddress.getByName(ip).isReachable(350)) ip else null + } + }.awaitAll().filterNotNull() + } + val arp = readArpTable() + val devices = JSArray() + for (ip in alive) { + val dev = JSObject().put("ip", ip) + arp[ip]?.let { dev.put("mac", it).put("vendor", ouiVendor(it)) } + try { + val name = InetAddress.getByName(ip).canonicalHostName + if (name != ip) dev.put("hostname", name) + } catch (_: Exception) { } + devices.put(dev) + } + resolve(call, JSObject().put("devices", devices)) + } catch (e: Exception) { + call.reject("ipScan: ${e.message}") + } + } + } + + /* --------------------------------------------------------------------- */ + /* Port-Scan */ + /* --------------------------------------------------------------------- */ + + @PluginMethod + fun portScan(call: PluginCall) { + val ip = call.getString("ip") ?: return call.reject("ip fehlt") + val portsArg = call.getArray("ports") ?: JSArray() + val ports = (0 until portsArg.length()).map { portsArg.getInt(it) } + io.launch { + try { + val open = withContext(Dispatchers.IO) { + ports.map { port -> + async { + try { + Socket().use { s -> + s.connect(InetSocketAddress(ip, port), 700) + } + port + } catch (_: Exception) { + null + } + } + }.awaitAll().filterNotNull() + } + val arr = JSArray() + for (p in open) arr.put(JSObject().put("port", p).put("service", serviceName(p))) + resolve(call, JSObject().put("open", arr)) + } catch (e: Exception) { + call.reject("portScan: ${e.message}") + } + } + } + + /* --------------------------------------------------------------------- */ + /* Ping-Qualität */ + /* --------------------------------------------------------------------- */ + + @PluginMethod + fun pingQuality(call: PluginCall) { + val host = call.getString("host") ?: return call.reject("host fehlt") + val count = call.getInt("count") ?: 20 + io.launch { + try { + resolve(call, measurePing(host, count)) + } catch (e: Exception) { + call.reject("pingQuality: ${e.message}") + } + } + } + + private fun measurePing(host: String, count: Int): JSObject { + val times = ArrayList() + val addr = InetAddress.getByName(host) + repeat(count) { + val t0 = System.nanoTime() + if (addr.isReachable(1000)) { + times.add((System.nanoTime() - t0) / 1_000_000.0) + } + Thread.sleep(200) + } + val received = times.size + val loss = ((count - received) * 100) / count + val min = times.minOrNull() ?: 0.0 + val max = times.maxOrNull() ?: 0.0 + val avg = if (times.isNotEmpty()) times.average() else 0.0 + // Jitter = mittlere absolute Abweichung aufeinanderfolgender Werte + var jitter = 0.0 + for (i in 1 until times.size) jitter += Math.abs(times[i] - times[i - 1]) + if (times.size > 1) jitter /= (times.size - 1) + return JSObject() + .put("sent", count).put("received", received).put("lossPct", loss) + .put("minMs", round1(min)).put("avgMs", round1(avg)) + .put("maxMs", round1(max)).put("jitterMs", round1(jitter)) + } + + /* --------------------------------------------------------------------- */ + /* WLAN-Scan */ + /* --------------------------------------------------------------------- */ + + @PluginMethod + fun wifiScan(call: PluginCall) { + if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) { + requestPermissionForAlias("location", call, "wifiScanPermCallback") + return + } + doWifiScan(call) + } + + @PermissionCallback + private fun wifiScanPermCallback(call: PluginCall) { + if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) { + doWifiScan(call) + } else { + call.reject("Standortberechtigung für WLAN-Scan abgelehnt") + } + } + + private fun doWifiScan(call: PluginCall) { + try { + val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val arr = JSArray() + for (r in wifi.scanResults) { + val freq = r.frequency + arr.put(JSObject() + .put("ssid", if (r.SSID.isNullOrEmpty()) "(versteckt)" else r.SSID) + .put("bssid", r.BSSID ?: "") + .put("channel", freqToChannel(freq)) + .put("rssi", r.level) + .put("band", if (freq > 4000) "5 GHz" else "2.4 GHz")) + } + resolve(call, JSObject().put("networks", arr)) + } catch (e: Exception) { + call.reject("wifiScan: ${e.message}") + } + } + + /* --------------------------------------------------------------------- */ + /* DHCP-Discover (Rogue-DHCP-Erkennung) */ + /* --------------------------------------------------------------------- */ + + @PluginMethod + fun dhcpDiscover(call: PluginCall) { + io.launch { + try { + val servers = discoverDhcpServers() + val arr = JSArray() + val arp = readArpTable() + for (ip in servers) { + arr.put(JSObject().put("ip", ip).put("mac", arp[ip] ?: "")) + } + resolve(call, JSObject().put("servers", arr)) + } catch (e: Exception) { + call.reject("dhcpDiscover: ${e.message}") + } + } + } + + /** + * Sendet einen DHCPDISCOVER-Broadcast und sammelt antwortende Server. + * Best-effort: scheitert auf manchen Geräten an Port-68-Belegung. + */ + private fun discoverDhcpServers(): List { + val found = LinkedHashSet() + val socket = DatagramSocket() + socket.broadcast = true + socket.soTimeout = 3000 + try { + val xid = byteArrayOf(0x39, 0x03, 0x42.toByte(), 0x12) + val packet = buildDhcpDiscover(xid) + socket.send(DatagramPacket(packet, packet.size, InetAddress.getByName("255.255.255.255"), 67)) + val buf = ByteArray(1500) + val deadline = System.currentTimeMillis() + 3000 + while (System.currentTimeMillis() < deadline) { + try { + val resp = DatagramPacket(buf, buf.size) + socket.receive(resp) + // Server-Identifier (Option 54) aus dem Paket lesen, sonst Absender + val srv = parseDhcpServerId(buf, resp.length) ?: resp.address.hostAddress + if (srv != null) found.add(srv) + } catch (_: Exception) { + break + } + } + } finally { + socket.close() + } + return found.toList() + } + + /* --------------------------------------------------------------------- */ + /* SNMP v2c GET (Switch: Link-Speed, Fehlerzähler) */ + /* --------------------------------------------------------------------- */ + + @PluginMethod + fun snmpGet(call: PluginCall) { + val host = call.getString("host") ?: return call.reject("host fehlt") + val community = call.getString("community") ?: "public" + val oidsArg = call.getArray("oids") ?: JSArray() + val oids = (0 until oidsArg.length()).map { oidsArg.getString(it) } + io.launch { + try { + val values = JSObject() + for (oid in oids) { + values.put(oid, Snmp.get(host, community, oid) ?: "-") + } + resolve(call, JSObject().put("values", values)) + } catch (e: Exception) { + call.reject("snmpGet: ${e.message}") + } + } + } + + /* --------------------------------------------------------------------- */ + /* Traceroute (über das System-ping-Binary, kein Root nötig) */ + /* --------------------------------------------------------------------- */ + + @PluginMethod + fun traceroute(call: PluginCall) { + val host = call.getString("host") ?: return call.reject("host fehlt") + io.launch { + try { + val hops = JSArray() + for (ttl in 1..20) { + val hop = pingWithTtl(host, ttl) + hops.put(JSObject() + .put("ttl", ttl) + .put("ip", hop.first) + .put("ms", hop.second)) + if (hop.first == host || hop.reachedTarget) break + } + resolve(call, JSObject().put("hops", hops)) + } catch (e: Exception) { + call.reject("traceroute: ${e.message}") + } + } + } + + /* --------------------------------------------------------------------- */ + /* Durchsatz-Test */ + /* --------------------------------------------------------------------- */ + + @PluginMethod + fun throughput(call: PluginCall) { + val host = call.getString("host") ?: return call.reject("host fehlt") + val port = call.getInt("port") ?: 5201 + val durationSec = call.getInt("durationSec") ?: 10 + io.launch { + try { + // Einfacher TCP-Durchsatz gegen eine Sink/Source-Gegenstelle: + // Download = empfangene Bytes, Upload = gesendete Bytes je Sekunde. + val res = measureThroughput(host, port, durationSec) + resolve(call, res) + } catch (e: Exception) { + call.reject("throughput: ${e.message}") + } + } + } + + private fun measureThroughput(host: String, port: Int, durationSec: Int): JSObject { + val buf = ByteArray(64 * 1024) + var downBytes = 0L + var upBytes = 0L + // Upload-Phase + Socket().use { s -> + s.connect(InetSocketAddress(host, port), 3000) + val end = System.currentTimeMillis() + durationSec * 500L + val out = s.getOutputStream() + while (System.currentTimeMillis() < end) { + out.write(buf); upBytes += buf.size + } + } + // Download-Phase + Socket().use { s -> + s.connect(InetSocketAddress(host, port), 3000) + val end = System.currentTimeMillis() + durationSec * 500L + val inp = s.getInputStream() + while (System.currentTimeMillis() < end) { + val n = inp.read(buf); if (n < 0) break; downBytes += n + } + } + val secs = durationSec / 2.0 + return JSObject() + .put("downMbps", round1(downBytes * 8.0 / 1_000_000.0 / secs)) + .put("upMbps", round1(upBytes * 8.0 / 1_000_000.0 / secs)) + } + + /* --------------------------------------------------------------------- */ + /* Dauer-/Stresstest */ + /* --------------------------------------------------------------------- */ + + @PluginMethod + fun startStressTest(call: PluginCall) { + val host = call.getString("host") ?: return call.reject("host fehlt") + val durationSec = call.getInt("durationSec") ?: 300 + val runId = "run-${System.currentTimeMillis()}" + val run = StressRun(host, durationSec) + stressRuns[runId] = run + // Hinweis: für Läufe > einige Minuten sollte ein Foreground-Service + // gestartet werden, sonst kann Android den Prozess beenden. + io.launch { + val end = System.currentTimeMillis() + durationSec * 1000L + while (System.currentTimeMillis() < end && run.active) { + val q = measurePing(host, 5) + run.samples++ + run.lossSum += q.getInteger("lossPct", 0) + run.avgSum += q.getDouble("avgMs") + run.maxMs = Math.max(run.maxMs, q.getDouble("maxMs")) + } + } + resolve(call, JSObject().put("runId", runId)) + } + + @PluginMethod + fun stopStressTest(call: PluginCall) { + val runId = call.getString("runId") ?: return call.reject("runId fehlt") + val run = stressRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden") + run.active = false + val n = Math.max(1, run.samples) + resolve(call, JSObject() + .put("samples", run.samples) + .put("lossPct", run.lossSum / n) + .put("avgMs", round1(run.avgSum / n)) + .put("maxMs", round1(run.maxMs))) + } + + private class StressRun(val host: String, val durationSec: Int) { + var active = true + var samples = 0 + var lossSum = 0 + var avgSum = 0.0 + var maxMs = 0.0 + } + + /* --------------------------------------------------------------------- */ + /* Hilfsfunktionen */ + /* --------------------------------------------------------------------- */ + + private fun resolve(call: PluginCall, data: JSObject) { + // Capacitor erwartet die Auflösung auf dem Main-Thread + activity.runOnUiThread { call.resolve(data) } + } + + private fun intToIp(i: Int): String = + "${i and 0xFF}.${i shr 8 and 0xFF}.${i shr 16 and 0xFF}.${i shr 24 and 0xFF}" + + private fun firstLocalIpv4(): String { + java.net.NetworkInterface.getNetworkInterfaces().toList().forEach { ni -> + ni.inetAddresses.toList().forEach { addr -> + if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) { + return addr.hostAddress ?: "" + } + } + } + return "192.168.1.1" + } + + /** /proc/net/arp lesen -> Map IP -> MAC (kann auf neuen Android-Versionen leer sein) */ + private fun readArpTable(): Map { + val map = HashMap() + try { + BufferedReader(FileReader(File("/proc/net/arp"))).use { br -> + br.readLine() // Kopfzeile + var line = br.readLine() + while (line != null) { + val parts = line.split(Regex("\\s+")) + if (parts.size >= 4 && parts[3] != "00:00:00:00:00:00") { + map[parts[0]] = parts[3].uppercase() + } + line = br.readLine() + } + } + } catch (_: Exception) { } + return map + } + + /** ping mit fester TTL -> (antwortende IP, Latenz in ms) */ + private data class Hop(val ip: String, val ms: Double, val reachedTarget: Boolean) + + private fun pingWithTtl(host: String, ttl: Int): Hop { + return try { + val proc = ProcessBuilder("/system/bin/ping", "-c", "1", "-W", "2", "-t", ttl.toString(), host) + .redirectErrorStream(true).start() + val out = proc.inputStream.bufferedReader().readText() + proc.waitFor() + val ip = Regex("""From ([\d.]+)""").find(out)?.groupValues?.get(1) + ?: Regex("""\((\d+\.\d+\.\d+\.\d+)\)""").find(out)?.groupValues?.get(1) + ?: "*" + val ms = Regex("""time=([\d.]+)""").find(out)?.groupValues?.get(1)?.toDoubleOrNull() ?: 0.0 + val reached = out.contains("bytes from") + Hop(if (reached) host else ip, ms, reached) + } catch (e: Exception) { + Hop("*", 0.0, false) + } + } + + private fun freqToChannel(freq: Int): Int = when { + freq == 2484 -> 14 + freq in 2412..2472 -> (freq - 2412) / 5 + 1 + freq in 5170..5825 -> (freq - 5170) / 5 + 34 + else -> 0 + } + + private fun serviceName(port: Int): String = when (port) { + 21 -> "ftp"; 22 -> "ssh"; 23 -> "telnet"; 53 -> "dns"; 80 -> "http" + 139 -> "netbios"; 443 -> "https"; 445 -> "smb"; 502 -> "modbus" + 1883 -> "mqtt"; 3389 -> "rdp"; 8080 -> "http-alt"; 8443 -> "https-alt" + else -> "" + } + + /** Minimaler OUI-Hersteller-Lookup. Für vollständige Abdeckung OUI-DB einbinden. */ + private fun ouiVendor(mac: String): String { + val oui = mac.replace(":", "").take(6).uppercase() + return OUI[oui] ?: "" + } + + private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0 + + private fun buildDhcpDiscover(xid: ByteArray): ByteArray { + val p = ByteArray(300) + p[0] = 1 // op = BOOTREQUEST + p[1] = 1 // htype = Ethernet + p[2] = 6 // hlen + System.arraycopy(xid, 0, p, 4, 4) + p[10] = 0x80.toByte() // Broadcast-Flag + // Magic Cookie + p[236] = 99; p[237] = 130.toByte(); p[238] = 83; p[239] = 99 + // Option 53: DHCP Message Type = DISCOVER + p[240] = 53; p[241] = 1; p[242] = 1 + p[243] = 255.toByte() // Ende + return p + } + + private fun parseDhcpServerId(buf: ByteArray, len: Int): String? { + var i = 240 + while (i + 1 < len) { + val opt = buf[i].toInt() and 0xFF + if (opt == 255) break + if (opt == 0) { i++; continue } + val l = buf[i + 1].toInt() and 0xFF + if (opt == 54 && l == 4) { + return "${buf[i+2].toInt() and 0xFF}.${buf[i+3].toInt() and 0xFF}." + + "${buf[i+4].toInt() and 0xFF}.${buf[i+5].toInt() and 0xFF}" + } + i += 2 + l + } + return null + } + + companion object { + /** Kleiner OUI-Auszug — bei Bedarf vollständige IEEE-OUI-Datei einbinden. */ + private val OUI = mapOf( + "3810D5" to "AVM", "DCA632" to "Raspberry Pi", "B827EB" to "Raspberry Pi", + "001CC0" to "Intel", "F0B479" to "Apple", "D8EB97" to "TP-Link" + ) + } +} diff --git a/native-plugin/README.md b/native-plugin/README.md new file mode 100644 index 0000000..6b09afa --- /dev/null +++ b/native-plugin/README.md @@ -0,0 +1,72 @@ +# Natives Scan-Plugin `NetDiagScanner` + +Der WebView kann keine Raw-Sockets/ICMP/ARP — die eigentliche Netzwerk-Messung +läuft in diesem nativen Android-Plugin (Kotlin). + +## Dateien + +- `NetDiagScannerPlugin.kt` — Capacitor-Plugin mit allen Scan-Methoden +- `Snmp.kt` — minimaler SNMP-v2c-GET-Client + +## Integration (einmalig, nach `npx cap add android`) + +1. **Android-Projekt erzeugen** (falls noch nicht vorhanden): + ```bash + npm install + npm run build + npx cap add android + ``` + +2. **Plugin-Dateien kopieren** nach: + ``` + android/app/src/main/java/de/data_it_solution/netdiag/ + ├── NetDiagScannerPlugin.kt + └── Snmp.kt + ``` + +3. **Plugin registrieren** in `android/app/src/main/java/de/data_it_solution/netdiag/MainActivity.kt`: + ```kotlin + package de.data_it_solution.netdiag + + import android.os.Bundle + import com.getcapacitor.BridgeActivity + + class MainActivity : BridgeActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + registerPlugin(NetDiagScannerPlugin::class.java) + super.onCreate(savedInstanceState) + } + } + ``` + +4. **Berechtigungen** in `android/app/src/main/AndroidManifest.xml` (innerhalb ``): + ```xml + + + + + + + + ``` + `ACCESS_FINE_LOCATION` ist Pflicht, damit Android WLAN-Scan-Ergebnisse liefert. + +5. **Kotlin-Coroutines** sicherstellen — in `android/app/build.gradle`: + ```gradle + dependencies { + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0" + } + ``` + +## Bekannte Einschränkungen + +- **ARP/MAC**: `/proc/net/arp` ist ab Android 10 teils nicht mehr lesbar — dann + bleiben MAC/Hersteller leer; Geräte werden trotzdem über Ping/Hostname erkannt. +- **Hersteller-Lookup**: nur kleiner OUI-Auszug eingebaut. Für volle Abdeckung + die IEEE-OUI-Datei als Asset einbinden und in `ouiVendor()` nutzen. +- **DHCP-Discover**: Best-effort — Port 68 kann vom System-DHCP-Client belegt sein. +- **Durchsatz-Test**: benötigt eine TCP-Sink/Source-Gegenstelle (2. Gerät bzw. + iperf3-kompatibler Server). Misst sonst nur Fehlversuche. +- **Stresstest**: für lange Läufe sollte ein Foreground-Service ergänzt werden, + sonst kann Android den Prozess im Hintergrund beenden. +- **PoE-/Strommessung**: bewusst nicht enthalten (Hardware-Grenze) — späteres Modul. diff --git a/native-plugin/Snmp.kt b/native-plugin/Snmp.kt new file mode 100644 index 0000000..3154dd4 --- /dev/null +++ b/native-plugin/Snmp.kt @@ -0,0 +1,138 @@ +package de.data_it_solution.netdiag + +import java.io.ByteArrayOutputStream +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress + +/** + * Minimaler SNMP-v2c-GET-Client. + * + * Reicht für das Auslesen einzelner OIDs (Link-Speed, Fehlerzähler) von + * gemanagten Switches. Implementiert nur so viel BER-Kodierung wie nötig. + */ +object Snmp { + + /** + * Eine OID per SNMP v2c GET abfragen. + * + * @return Wert als String oder null bei Fehler/Timeout + */ + fun get(host: String, community: String, oid: String): String? { + return try { + val request = buildGetRequest(community, oid) + val socket = DatagramSocket() + socket.soTimeout = 2500 + socket.use { + it.send(DatagramPacket(request, request.size, InetAddress.getByName(host), 161)) + val buf = ByteArray(2048) + val resp = DatagramPacket(buf, buf.size) + it.receive(resp) + parseFirstValue(buf, resp.length) + } + } catch (e: Exception) { + null + } + } + + /* ---- BER-Kodierung ---- */ + + private fun tlv(tag: Int, value: ByteArray): ByteArray { + val out = ByteArrayOutputStream() + out.write(tag) + when { + value.size < 0x80 -> out.write(value.size) + value.size < 0x100 -> { out.write(0x81); out.write(value.size) } + else -> { out.write(0x82); out.write(value.size shr 8); out.write(value.size and 0xFF) } + } + out.write(value) + return out.toByteArray() + } + + private fun integer(v: Int): ByteArray { + val bytes = when { + v == 0 -> byteArrayOf(0) + v < 0x80 -> byteArrayOf(v.toByte()) + v < 0x8000 -> byteArrayOf((v shr 8).toByte(), v.toByte()) + else -> byteArrayOf((v shr 24).toByte(), (v shr 16).toByte(), (v shr 8).toByte(), v.toByte()) + } + return tlv(0x02, bytes) + } + + private fun octetString(s: String): ByteArray = tlv(0x04, s.toByteArray()) + + private fun oid(o: String): ByteArray { + val parts = o.trim().trimStart('.').split('.').map { it.toInt() } + val out = ByteArrayOutputStream() + out.write(parts[0] * 40 + parts[1]) // erste zwei Subidentifier zusammengefasst + for (i in 2 until parts.size) { + var v = parts[i] + if (v < 0x80) { + out.write(v) + } else { + val stack = ArrayDeque() + stack.addFirst(v and 0x7F) + v = v shr 7 + while (v > 0) { stack.addFirst((v and 0x7F) or 0x80); v = v shr 7 } + stack.forEach { out.write(it) } + } + } + return tlv(0x06, out.toByteArray()) + } + + private fun buildGetRequest(community: String, oidStr: String): ByteArray { + val varbind = tlv(0x30, oid(oidStr) + tlv(0x05, ByteArray(0))) // OID + NULL + val varbindList = tlv(0x30, varbind) + val requestId = (System.currentTimeMillis() and 0x7FFF).toInt() + val pdu = tlv( + 0xA0, // GetRequest-PDU + integer(requestId) + integer(0) + integer(0) + varbindList, + ) + val message = tlv( + 0x30, + integer(1) + octetString(community) + pdu, // version 1 = SNMPv2c + ) + return message + } + + /* ---- Antwort parsen: ersten Variablen-Wert herausziehen ---- */ + + private fun parseFirstValue(buf: ByteArray, len: Int): String? { + var i = 0 + // Durch die Struktur navigieren bis zum ersten primitiven Wert nach einer OID + var lastWasOid = false + while (i < len) { + val tag = buf[i].toInt() and 0xFF + i++ + if (i >= len) break + var l = buf[i].toInt() and 0xFF + i++ + if (l and 0x80 != 0) { + val n = l and 0x7F + l = 0 + for (k in 0 until n) { l = (l shl 8) or (buf[i].toInt() and 0xFF); i++ } + } + // Konstruierte Typen (SEQUENCE, PDU) aufsteigen + if (tag == 0x30 || tag == 0xA2 || tag == 0xA0) continue + if (tag == 0x06) { lastWasOid = true; i += l; continue } + if (lastWasOid) { + return when (tag) { + 0x02, 0x41, 0x42, 0x43, 0x44, 0x46 -> { // INTEGER, Counter, Gauge, TimeTicks ... + var v = 0L + for (k in 0 until l) v = (v shl 8) or (buf[i + k].toLong() and 0xFF) + v.toString() + } + 0x04 -> String(buf, i, l) // OCTET STRING + 0x05 -> "" // NULL -> kein Wert + else -> { + val sb = StringBuilder() + for (k in 0 until l) sb.append("%02X".format(buf[i + k])) + sb.toString() + } + } + } + i += l + } + return null + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..07cd0e7 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "netdiag-app", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "cap:sync": "cap sync", + "cap:open:android": "cap open android", + "cap:build:android": "npm run build && cap sync android" + }, + "devDependencies": { + "@capacitor/android": "^6.0.0", + "@capacitor/cli": "^6.0.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.2", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.2", + "typescript": "^5.9.3", + "vite": "^7.3.1" + }, + "dependencies": { + "@capacitor-community/sqlite": "^6.0.0", + "@capacitor/app": "^6.0.0", + "@capacitor/core": "^6.0.0", + "@capacitor/filesystem": "^6.0.0", + "@capacitor/network": "^6.0.0", + "@capacitor/preferences": "^6.0.0", + "@capacitor/share": "^6.0.0", + "lucide-svelte": "^0.577.0" + } +} diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..582062d --- /dev/null +++ b/src/app.css @@ -0,0 +1,35 @@ +@import 'tailwindcss'; + +/* NetDiag — dunkles Theme, mobil-first */ +:root { + color-scheme: dark; +} + +html, +body { + margin: 0; + height: 100%; + background: #0d1117; + color: #e6edf3; + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + -webkit-tap-highlight-color: transparent; +} + +/* Sichere Bereiche (Notch / Statusleiste) */ +.safe-top { + padding-top: env(safe-area-inset-top); +} +.safe-bottom { + padding-bottom: env(safe-area-inset-bottom); +} + +/* Ampel-Farben für Messergebnisse */ +.ampel-ok { + color: #3fb950; +} +.ampel-warn { + color: #d29922; +} +.ampel-fail { + color: #f85149; +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..53137da --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,21 @@ +// SvelteKit / Vite Typdeklarationen + +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface Platform {} + } +} + +interface ImportMetaEnv { + /** Build-Version, von der CI über VITE_APP_VERSION gesetzt */ + readonly VITE_APP_VERSION?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..aa4881c --- /dev/null +++ b/src/app.html @@ -0,0 +1,13 @@ + + + + + + + NetDiag + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..c08203e --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,217 @@ +/** + * API-Client für die NetDiag-Dolibarr-Schnittstelle. + * + * Spricht das Modul `netdiag` unter /custom/netdiag/api/ an. Authentifizierung + * per JWT (Bearer). Im Browser-Dev läuft alles über den Vite-Proxy, auf dem + * Gerät über die in den Einstellungen hinterlegte Server-URL. + */ + +import { Preferences } from '@capacitor/preferences'; +import type { Customer, Order, Protocol } from './types'; + +const API_PATH = '/custom/netdiag/api'; +const FETCH_TIMEOUT_MS = 15_000; + +let serverUrl = ''; +let token = ''; + +/** Callback, der bei 401 (Sitzung abgelaufen) ausgelöst wird */ +let onAuthFailure: (() => void) | null = null; + +/** Fehlerklasse für API-Antworten */ +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + this.name = 'ApiError'; + } +} + +/** Gespeicherte Server-URL und Token aus den Preferences laden */ +export async function initApi(): Promise { + serverUrl = (await Preferences.get({ key: 'serverUrl' })).value ?? ''; + token = (await Preferences.get({ key: 'token' })).value ?? ''; +} + +/** Callback für abgelaufene Sitzung registrieren */ +export function setAuthFailureHandler(fn: () => void): void { + onAuthFailure = fn; +} + +export function getServerUrl(): string { + return serverUrl; +} + +export async function setServerUrl(url: string): Promise { + serverUrl = url.replace(/\/+$/, ''); + await Preferences.set({ key: 'serverUrl', value: serverUrl }); +} + +export function isLoggedIn(): boolean { + return token !== ''; +} + +async function setToken(value: string): Promise { + token = value; + await Preferences.set({ key: 'token', value }); +} + +export async function clearToken(): Promise { + token = ''; + await Preferences.remove({ key: 'token' }); +} + +/** Vollständige URL für einen API-Pfad bauen */ +function url(endpoint: string): string { + return `${serverUrl}${API_PATH}/${endpoint}`; +} + +/** + * Generischer Request mit Timeout und JSON-Verarbeitung. + */ +async function request(endpoint: string, options: RequestInit = {}): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + const headers: Record = { Accept: 'application/json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + if (options.body) headers['Content-Type'] = 'application/json'; + + let res: Response; + try { + res = await fetch(url(endpoint), { ...options, headers, signal: controller.signal }); + } catch (e) { + clearTimeout(timer); + if (e instanceof DOMException && e.name === 'AbortError') { + throw new ApiError(0, 'Zeitüberschreitung — Server nicht erreichbar'); + } + throw new ApiError(0, 'Keine Verbindung zum Server'); + } + clearTimeout(timer); + + if (res.status === 401) { + await clearToken(); + onAuthFailure?.(); + throw new ApiError(401, 'Sitzung abgelaufen, bitte neu anmelden'); + } + + let data: unknown = null; + const text = await res.text(); + if (text) { + try { + data = JSON.parse(text); + } catch { + throw new ApiError(res.status, 'Ungültige Server-Antwort'); + } + } + + if (!res.ok) { + const msg = + data && typeof data === 'object' && 'error' in data + ? String((data as { error: unknown }).error) + : `Fehler ${res.status}`; + throw new ApiError(res.status, msg); + } + return data as T; +} + +/* ----------------------------------------------------------------------- */ +/* API-Methoden */ +/* ----------------------------------------------------------------------- */ + +export interface LoginResult { + token: string; + expiresIn: number; + user: { id: number; login: string; name: string; email: string; canWrite: boolean }; +} + +/** Anmelden — speichert das Token bei Erfolg */ +export async function login(loginName: string, password: string): Promise { + const res = await request('auth.php', { + method: 'POST', + body: JSON.stringify({ login: loginName, password }), + }); + await setToken(res.token); + return res; +} + +/** Aufträge laden — nur aktive (offene), optional Suchtext */ +export function listOrders(opts: { open?: boolean; q?: string } = {}): Promise<{ orders: Order[] }> { + const p = new URLSearchParams(); + if (opts.open) p.set('open', '1'); + if (opts.q) p.set('q', opts.q); + return request<{ orders: Order[] }>(`orders.php?${p.toString()}`); +} + +/** Einzelnen Auftrag mit Protokollen laden */ +export function getOrder(id: number): Promise<{ order: Order; protocols: unknown[] }> { + return request(`orders.php?id=${id}`); +} + +/** Kunden suchen */ +export function searchCustomers(q: string): Promise<{ customers: Customer[] }> { + return request<{ customers: Customer[] }>(`customers.php?q=${encodeURIComponent(q)}`); +} + +/** Einzelnen Kunden mit Aufträgen und Protokollen laden */ +export function getCustomer( + id: number, +): Promise<{ customer: Customer; orders: Order[]; protocols: unknown[] }> { + return request(`customers.php?id=${id}`); +} + +export interface SyncResult { + ok: boolean; + protocolId: number; + ref: string; + created: boolean; + pdfGenerated: boolean; +} + +/** Protokoll zum Server synchronisieren (idempotent über clientUuid) */ +export function syncProtocol(protocol: Protocol): Promise { + const payload = { + action: 'sync', + protocol: { + clientUuid: protocol.clientUuid, + label: protocol.label, + socId: protocol.socId ?? null, + orderId: protocol.orderId ?? null, + dateDiag: protocol.dateDiag, + location: protocol.location, + subnet: protocol.subnet, + status: protocol.status, + note: protocol.note, + devices: protocol.devices.map((d) => ({ + clientId: d.clientId, + ip: d.ip, + mac: d.mac ?? '', + hostname: d.hostname ?? '', + vendor: d.vendor ?? '', + deviceType: d.deviceType ?? '', + note: d.note ?? '', + })), + measurements: protocol.measurements.map((m) => ({ + deviceClientId: m.deviceClientId ?? null, + tool: m.tool, + category: m.category, + label: m.label, + params: m.params, + result: m.result, + measureStatus: m.measureStatus, + dateMeasure: m.dateMeasure, + })), + }, + }; + return request('protocols.php', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +/** URL zum Protokoll-PDF (inkl. Token als Query-Parameter) */ +export function pdfUrl(serverProtocolId: number): string { + return `${serverUrl}${API_PATH}/pdf.php?id=${serverProtocolId}&jwt=${encodeURIComponent(token)}`; +} diff --git a/src/lib/auth.svelte.ts b/src/lib/auth.svelte.ts new file mode 100644 index 0000000..6603ebd --- /dev/null +++ b/src/lib/auth.svelte.ts @@ -0,0 +1,59 @@ +/** + * Authentifizierungs-Status der App (Svelte 5 Runes). + */ + +import { Preferences } from '@capacitor/preferences'; +import { + clearToken, + getServerUrl, + initApi, + isLoggedIn, + login as apiLogin, + setAuthFailureHandler, +} from './api'; +import type { NetUser } from './types'; + +class AuthState { + user = $state(null); + loggedIn = $state(false); + /** true wenn auf dem Gerät noch keine Server-URL hinterlegt ist */ + needsServerUrl = $state(false); + ready = $state(false); + + /** Beim App-Start: gespeicherte Daten laden */ + async init(): Promise { + await initApi(); + setAuthFailureHandler(() => this.handleAuthFailure()); + + const storedUser = (await Preferences.get({ key: 'user' })).value; + if (storedUser) this.user = JSON.parse(storedUser) as NetUser; + + this.loggedIn = isLoggedIn(); + this.needsServerUrl = getServerUrl() === '' && !import.meta.env.DEV; + this.ready = true; + } + + /** Anmelden */ + async login(loginName: string, password: string): Promise { + const res = await apiLogin(loginName, password); + this.user = res.user; + this.loggedIn = true; + await Preferences.set({ key: 'user', value: JSON.stringify(res.user) }); + } + + /** Abmelden */ + async logout(): Promise { + await clearToken(); + await Preferences.remove({ key: 'user' }); + this.user = null; + this.loggedIn = false; + } + + /** Wird bei 401 vom API-Client ausgelöst */ + private handleAuthFailure(): void { + this.loggedIn = false; + this.user = null; + } +} + +export const auth = new AuthState(); diff --git a/src/lib/backButton.svelte.ts b/src/lib/backButton.svelte.ts new file mode 100644 index 0000000..6400709 --- /dev/null +++ b/src/lib/backButton.svelte.ts @@ -0,0 +1,85 @@ +/** + * Hardware-Backbutton (Android). + * + * Muster aus der Wissensbasis (KB #480, #549): Den nativen Listener NICHT im + * Svelte-$effect registrieren — bei State-Toggles würde er mehrfach hängen und + * Taps verschlucken. Stattdessen Modul-Scope mit Single-Instance-Garantie. + * + * Ablauf eines Back-Taps: + * 1. Gibt es einen offenen Dialog/Sheet? -> schließen (handler liefert true) + * 2. Sind wir nicht auf der Hauptroute? -> eine Ebene zurück + * 3. Auf der Hauptroute: 1. Tap = Hinweis, 2. Tap binnen 1,8 s = App beenden + */ + +import { App, type PluginListenerHandle } from '@capacitor/app'; +import { Capacitor } from '@capacitor/core'; + +interface BackConfig { + /** Schließt einen offenen Overlay-Zustand. true = verarbeitet, nichts weiter tun. */ + handleOverlay: () => boolean; + /** true wenn die aktuelle Route die Hauptroute (Auftragsliste) ist */ + isHomeRoute: () => boolean; + /** Eine Navigationsebene zurück */ + goBack: () => void; + /** Hinweis "nochmal drücken zum Beenden" anzeigen */ + showExitHint: () => void; +} + +let listener: PluginListenerHandle | null = null; +let registering = false; +let config: BackConfig | null = null; +let exitRequestedUntil = 0; + +const EXIT_WINDOW_MS = 1800; + +function onBackPressed(): void { + if (!config) return; + + // 1. Overlay schließen + if (config.handleOverlay()) { + exitRequestedUntil = 0; + return; + } + + // 2. Nicht auf der Hauptroute -> zurück + if (!config.isHomeRoute()) { + exitRequestedUntil = 0; + config.goBack(); + return; + } + + // 3. Hauptroute -> Doppel-Tap zum Beenden + const now = Date.now(); + if (now < exitRequestedUntil) { + App.exitApp(); + } else { + exitRequestedUntil = now + EXIT_WINDOW_MS; + config.showExitHint(); + } +} + +/** + * Backbutton-Listener registrieren bzw. Konfiguration aktualisieren. + * Mehrfachaufrufe sind sicher — der native Listener wird nur einmal angelegt. + */ +export function registerBackListener(cfg: BackConfig): void { + config = cfg; // Callbacks immer aktualisieren + if (!Capacitor.isNativePlatform()) return; + if (listener || registering) return; + + registering = true; + App.addListener('backButton', onBackPressed) + .then((handle) => { + listener = handle; + }) + .finally(() => { + registering = false; + }); +} + +/** Listener endgültig entfernen (nur beim Zerstören des Root-Layouts) */ +export function removeBackListener(): void { + listener?.remove(); + listener = null; + config = null; +} diff --git a/src/lib/components/AppHeader.svelte b/src/lib/components/AppHeader.svelte new file mode 100644 index 0000000..3751551 --- /dev/null +++ b/src/lib/components/AppHeader.svelte @@ -0,0 +1,43 @@ + + +
+ {#if back} + + {/if} +
+

{title}

+ {#if subtitle} +

{subtitle}

+ {/if} +
+ +
diff --git a/src/lib/components/Toast.svelte b/src/lib/components/Toast.svelte new file mode 100644 index 0000000..2b50c2f --- /dev/null +++ b/src/lib/components/Toast.svelte @@ -0,0 +1,17 @@ + + +
+ {#each toast.items as item (item.id)} +
+ {item.text} +
+ {/each} +
diff --git a/src/lib/components/ToolDialog.svelte b/src/lib/components/ToolDialog.svelte new file mode 100644 index 0000000..c35bb83 --- /dev/null +++ b/src/lib/components/ToolDialog.svelte @@ -0,0 +1,95 @@ + + + diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..e9a64bc --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,97 @@ +/** + * Lokaler Offline-Speicher für Diagnose-Protokolle. + * + * Ein Protokoll ist ein in sich geschlossenes JSON-Objekt (inkl. Geräte und + * Messungen) und wird als JSON-Blob abgelegt: + * - Android: SQLite (@capacitor-community/sqlite) + * - Browser-Dev: localStorage + * + * So bleiben die Daten auf der Baustelle auch ohne Verbindung erhalten und + * werden später vom Sync-Dienst zum Dolibarr-Server geschoben. + */ + +import { Capacitor } from '@capacitor/core'; +import { CapacitorSQLite, SQLiteConnection, type SQLiteDBConnection } from '@capacitor-community/sqlite'; +import type { Protocol } from './types'; + +const DB_NAME = 'netdiag'; +const LS_PREFIX = 'netdiag.protocol.'; + +let useSqlite = false; +let db: SQLiteDBConnection | null = null; + +/** Speicher initialisieren (Tabelle anlegen) */ +export async function initDb(): Promise { + useSqlite = Capacitor.isNativePlatform(); + if (!useSqlite) return; + + const sqlite = new SQLiteConnection(CapacitorSQLite); + const conn = await sqlite.createConnection(DB_NAME, false, 'no-encryption', 1, false); + db = conn; + await db.open(); + await db.execute(` + CREATE TABLE IF NOT EXISTS protocols ( + uuid TEXT PRIMARY KEY, + json TEXT NOT NULL, + dirty INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL + ); + `); +} + +/** Protokoll speichern (anlegen oder ersetzen) */ +export async function saveProtocol(p: Protocol): Promise { + p.updatedAt = Date.now(); + const json = JSON.stringify(p); + if (useSqlite && db) { + await db.run( + 'INSERT OR REPLACE INTO protocols (uuid, json, dirty, updated_at) VALUES (?, ?, ?, ?)', + [p.clientUuid, json, p.dirty ? 1 : 0, p.updatedAt], + ); + } else { + localStorage.setItem(LS_PREFIX + p.clientUuid, json); + } +} + +/** Einzelnes Protokoll laden */ +export async function getProtocol(uuid: string): Promise { + if (useSqlite && db) { + const res = await db.query('SELECT json FROM protocols WHERE uuid = ?', [uuid]); + const row = res.values?.[0]; + return row ? (JSON.parse(row.json) as Protocol) : null; + } + const raw = localStorage.getItem(LS_PREFIX + uuid); + return raw ? (JSON.parse(raw) as Protocol) : null; +} + +/** Alle Protokolle laden (neueste zuerst) */ +export async function getAllProtocols(): Promise { + let list: Protocol[] = []; + if (useSqlite && db) { + const res = await db.query('SELECT json FROM protocols ORDER BY updated_at DESC'); + list = (res.values ?? []).map((r) => JSON.parse(r.json) as Protocol); + } else { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(LS_PREFIX)) { + list.push(JSON.parse(localStorage.getItem(key)!) as Protocol); + } + } + list.sort((a, b) => b.updatedAt - a.updatedAt); + } + return list; +} + +/** Alle noch nicht synchronisierten Protokolle */ +export async function getDirtyProtocols(): Promise { + return (await getAllProtocols()).filter((p) => p.dirty); +} + +/** Protokoll löschen */ +export async function deleteProtocol(uuid: string): Promise { + if (useSqlite && db) { + await db.run('DELETE FROM protocols WHERE uuid = ?', [uuid]); + } else { + localStorage.removeItem(LS_PREFIX + uuid); + } +} diff --git a/src/lib/protocols.ts b/src/lib/protocols.ts new file mode 100644 index 0000000..605cd55 --- /dev/null +++ b/src/lib/protocols.ts @@ -0,0 +1,67 @@ +/** + * Hilfsfunktionen rund um Diagnose-Protokolle. + */ + +import { saveProtocol } from './db'; +import type { Device, Measurement, Protocol } from './types'; + +/** Eindeutige ID erzeugen */ +export function uid(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID(); + return 'id-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2); +} + +/** Neues, leeres Protokoll anlegen und speichern */ +export async function createProtocol(init: { + socId?: number | null; + socName?: string; + orderId?: number | null; + orderRef?: string; + subnet?: string; +}): Promise { + const p: Protocol = { + clientUuid: uid(), + serverId: null, + label: init.orderRef ? `Diagnose ${init.orderRef}` : 'Netzwerk-Diagnose', + socId: init.socId ?? null, + socName: init.socName ?? '', + orderId: init.orderId ?? null, + orderRef: init.orderRef ?? '', + dateDiag: Date.now(), + location: '', + subnet: init.subnet ?? '', + status: 0, + note: '', + devices: [], + measurements: [], + dirty: true, + updatedAt: Date.now(), + }; + await saveProtocol(p); + return p; +} + +/** Gerät zum Protokoll hinzufügen oder per IP aktualisieren */ +export function upsertDevice( + protocol: Protocol, + dev: Omit & { clientId?: string }, +): Device { + const existing = protocol.devices.find((d) => d.ip === dev.ip); + if (existing) { + Object.assign(existing, { ...dev, clientId: existing.clientId }); + return existing; + } + const created: Device = { ...dev, clientId: dev.clientId ?? uid() }; + protocol.devices.push(created); + return created; +} + +/** Messung zum Protokoll hinzufügen */ +export function addMeasurement( + protocol: Protocol, + m: Omit, +): Measurement { + const created: Measurement = { ...m, clientId: uid() }; + protocol.measurements.push(created); + return created; +} diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts new file mode 100644 index 0000000..cc23500 --- /dev/null +++ b/src/lib/scanner.ts @@ -0,0 +1,169 @@ +/** + * Brücke zum nativen Scan-Plugin `NetDiagScanner` (Kotlin). + * + * Der WebView kann keine Raw-Sockets/ICMP/ARP — die eigentliche Netzwerk- + * Messung läuft im nativen Android-Plugin. Im Browser-Dev liefert ein Mock + * Beispieldaten, damit die Oberfläche ohne Gerät entwickelt werden kann. + */ + +import { Capacitor, registerPlugin } from '@capacitor/core'; + +/* --- Datentypen der Plugin-Antworten --- */ + +export interface ScannedDevice { + ip: string; + mac?: string; + hostname?: string; + vendor?: string; +} +export interface OpenPort { + port: number; + service?: string; +} +export interface PingQuality { + sent: number; + received: number; + lossPct: number; + minMs: number; + avgMs: number; + maxMs: number; + jitterMs: number; +} +export interface WifiNetwork { + ssid: string; + bssid: string; + channel: number; + rssi: number; + band: string; +} +export interface DhcpServer { + ip: string; + mac?: string; +} +export interface TracerouteHop { + ttl: number; + ip: string; + ms: number; +} +export interface ThroughputResult { + downMbps: number; + upMbps: number; +} + +/** Schnittstelle des nativen Plugins */ +export interface NetDiagScannerPlugin { + /** Aktuelles Subnetz des Geräts ermitteln (z.B. "192.168.1.0/24") */ + getLocalSubnet(): Promise<{ subnet: string; ip: string; gateway: string }>; + /** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */ + ipScan(opts: { subnet: string }): Promise<{ devices: ScannedDevice[] }>; + /** Port-Scan eines Geräts */ + portScan(opts: { ip: string; ports: number[] }): Promise<{ open: OpenPort[] }>; + /** Ping-Qualität (Latenz, Jitter, Paketverlust) */ + pingQuality(opts: { host: string; count: number }): Promise; + /** WLAN-Scan: umliegende Netze, Kanäle, Signalstärke */ + wifiScan(): Promise<{ networks: WifiNetwork[] }>; + /** DHCP-Server im Netz erkennen (Rogue-DHCP-Erkennung) */ + dhcpDiscover(): Promise<{ servers: DhcpServer[] }>; + /** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */ + snmpGet(opts: { host: string; community: string; oids: string[] }): Promise<{ + values: Record; + }>; + /** Traceroute zu einem Host */ + traceroute(opts: { host: string }): Promise<{ hops: TracerouteHop[] }>; + /** Durchsatztest gegen eine Gegenstelle (iperf-kompatibel) */ + throughput(opts: { host: string; port: number; durationSec: number }): Promise; + /** Dauer-/Stresstest starten (läuft als Foreground-Service) */ + startStressTest(opts: { host: string; durationSec: number }): Promise<{ runId: string }>; + /** Laufenden Stresstest beenden und Ergebnis holen */ + stopStressTest(opts: { runId: string }): Promise<{ + samples: number; + lossPct: number; + avgMs: number; + maxMs: number; + }>; +} + +const native = registerPlugin('NetDiagScanner'); + +/* ----------------------------------------------------------------------- */ +/* Mock für Browser-Entwicklung */ +/* ----------------------------------------------------------------------- */ + +function rnd(min: number, max: number): number { + return Math.round((min + Math.random() * (max - min)) * 10) / 10; +} + +const mock: NetDiagScannerPlugin = { + async getLocalSubnet() { + return { subnet: '192.168.1.0/24', ip: '192.168.1.50', gateway: '192.168.1.1' }; + }, + async ipScan() { + return { + devices: [ + { ip: '192.168.1.1', mac: 'AA:BB:CC:00:00:01', hostname: 'fritzbox', vendor: 'AVM' }, + { ip: '192.168.1.10', mac: 'AA:BB:CC:00:00:0A', hostname: 'switch-keller', vendor: 'TP-Link' }, + { ip: '192.168.1.50', mac: 'AA:BB:CC:00:00:32', hostname: 'handy', vendor: 'Samsung' }, + { ip: '192.168.1.77', mac: 'AA:BB:CC:00:00:4D', hostname: 'wallbox', vendor: 'Keba' }, + ], + }; + }, + async portScan(opts) { + const all: OpenPort[] = [ + { port: 80, service: 'http' }, + { port: 443, service: 'https' }, + { port: 22, service: 'ssh' }, + ]; + return { open: all.filter((p) => opts.ports.includes(p.port)) }; + }, + async pingQuality(opts) { + const sent = opts.count; + const received = sent - (Math.random() < 0.3 ? 1 : 0); + return { + sent, + received, + lossPct: Math.round(((sent - received) / sent) * 100), + minMs: rnd(1, 4), + avgMs: rnd(4, 12), + maxMs: rnd(12, 40), + jitterMs: rnd(0.5, 5), + }; + }, + async wifiScan() { + return { + networks: [ + { ssid: 'AllesWattLaeuft', bssid: 'AA:BB:CC:11:22:33', channel: 6, rssi: -52, band: '2.4 GHz' }, + { ssid: 'Nachbar-WLAN', bssid: 'DD:EE:FF:44:55:66', channel: 11, rssi: -78, band: '2.4 GHz' }, + { ssid: 'AllesWattLaeuft-5G', bssid: 'AA:BB:CC:11:22:34', channel: 36, rssi: -58, band: '5 GHz' }, + ], + }; + }, + async dhcpDiscover() { + return { servers: [{ ip: '192.168.1.1', mac: 'AA:BB:CC:00:00:01' }] }; + }, + async snmpGet(opts) { + const values: Record = {}; + for (const oid of opts.oids) values[oid] = String(Math.floor(Math.random() * 1000)); + return { values }; + }, + async traceroute() { + return { + hops: [ + { ttl: 1, ip: '192.168.1.1', ms: rnd(1, 3) }, + { ttl: 2, ip: '10.0.0.1', ms: rnd(8, 15) }, + { ttl: 3, ip: '8.8.8.8', ms: rnd(15, 30) }, + ], + }; + }, + async throughput() { + return { downMbps: rnd(80, 940), upMbps: rnd(40, 500) }; + }, + async startStressTest() { + return { runId: 'mock-run' }; + }, + async stopStressTest() { + return { samples: 120, lossPct: rnd(0, 2), avgMs: rnd(3, 10), maxMs: rnd(20, 90) }; + }, +}; + +/** Aktive Scanner-Implementierung: nativ auf dem Gerät, Mock im Browser */ +export const scanner: NetDiagScannerPlugin = Capacitor.isNativePlatform() ? native : mock; diff --git a/src/lib/sync.svelte.ts b/src/lib/sync.svelte.ts new file mode 100644 index 0000000..e54979f --- /dev/null +++ b/src/lib/sync.svelte.ts @@ -0,0 +1,88 @@ +/** + * Offline-Sync der Diagnose-Protokolle (Svelte 5 Runes). + * + * Auf der Baustelle ist oft keine Verbindung zum Dolibarr-Server. Protokolle + * werden lokal gespeichert (db.ts) und hier zum Server geschoben, sobald + * wieder Netz da ist. Der Server-Endpunkt ist idempotent (clientUuid), ein + * doppelter Sync schadet also nicht. + */ + +import { Network } from '@capacitor/network'; +import { ApiError, isLoggedIn, syncProtocol } from './api'; +import { getDirtyProtocols, saveProtocol } from './db'; + +type SyncStatus = 'idle' | 'syncing' | 'offline' | 'error'; + +const SYNC_INTERVAL_MS = 30_000; + +class SyncState { + status = $state('idle'); + pendingCount = $state(0); + lastError = $state(''); + online = $state(true); + + private timer: ReturnType | null = null; + + /** Sync-Dienst starten: Netz-Listener + periodischer Lauf */ + async start(): Promise { + const st = await Network.getStatus(); + this.online = st.connected; + + Network.addListener('networkStatusChange', (s) => { + this.online = s.connected; + if (s.connected) void this.syncNow(); + else this.status = 'offline'; + }); + + this.timer = setInterval(() => void this.syncNow(), SYNC_INTERVAL_MS); + await this.refreshPending(); + void this.syncNow(); + } + + /** Sync-Dienst stoppen */ + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } + + /** Anzahl offener (dirty) Protokolle neu zählen */ + async refreshPending(): Promise { + this.pendingCount = (await getDirtyProtocols()).length; + } + + /** + * Alle offenen Protokolle synchronisieren. + * Läuft still im Hintergrund; Fehler werden gemerkt, nicht geworfen. + */ + async syncNow(): Promise { + if (this.status === 'syncing' || !this.online || !isLoggedIn()) return; + + const dirty = await getDirtyProtocols(); + this.pendingCount = dirty.length; + if (dirty.length === 0) { + this.status = 'idle'; + return; + } + + this.status = 'syncing'; + this.lastError = ''; + for (const p of dirty) { + try { + const res = await syncProtocol(p); + p.serverId = res.protocolId; + p.ref = res.ref; + p.dirty = false; + await saveProtocol(p); + } catch (e) { + this.lastError = e instanceof ApiError ? e.message : 'Sync-Fehler'; + this.status = 'error'; + await this.refreshPending(); + return; // beim nächsten Lauf erneut versuchen + } + } + await this.refreshPending(); + this.status = 'idle'; + } +} + +export const sync = new SyncState(); diff --git a/src/lib/toast.svelte.ts b/src/lib/toast.svelte.ts new file mode 100644 index 0000000..17dcc1d --- /dev/null +++ b/src/lib/toast.svelte.ts @@ -0,0 +1,36 @@ +/** + * Einfache Toast-Benachrichtigungen (Svelte 5 Runes). + * Dedupliziert gleiche Meldungen, damit Doppel-Tap-Hinweise nicht flackern. + */ + +export type ToastType = 'info' | 'success' | 'error'; + +interface ToastItem { + id: number; + text: string; + type: ToastType; +} + +class ToastState { + items = $state([]); + private seq = 0; + private lastKey = ''; + private lastAt = 0; + + show(text: string, type: ToastType = 'info', durationMs = 3000): void { + const key = `${type}:${text}`; + const now = Date.now(); + // Gleiche Meldung binnen 2 s nicht erneut zeigen + if (key === this.lastKey && now - this.lastAt < 2000) return; + this.lastKey = key; + this.lastAt = now; + + const id = ++this.seq; + this.items = [...this.items, { id, text, type }]; + setTimeout(() => { + this.items = this.items.filter((t) => t.id !== id); + }, durationMs); + } +} + +export const toast = new ToastState(); diff --git a/src/lib/tools/index.ts b/src/lib/tools/index.ts new file mode 100644 index 0000000..4064c86 --- /dev/null +++ b/src/lib/tools/index.ts @@ -0,0 +1,55 @@ +/** + * Tool-Registry — zentrale Sammlung aller Diagnose-Werkzeuge. + * + * Ein neues Tool hinzufügen: + * 1. Datei unter tools//.ts anlegen (Tool implementieren) + * 2. hier importieren und in TOOLS eintragen + * Mehr ist nicht nötig — App-Logik, Sync und Datenbank bleiben unberührt. + */ + +import type { Tool, ToolCategory } from './types'; + +import { dhcpCheckTool } from './netzwerk/dhcpcheck'; +import { ipScanTool } from './netzwerk/ipscan'; +import { pingTool } from './netzwerk/ping'; +import { portScanTool } from './netzwerk/portscan'; +import { snmpTool } from './netzwerk/snmp'; +import { stressTestTool } from './netzwerk/stresstest'; +import { tracerouteTool } from './netzwerk/traceroute'; +import { wifiScanTool } from './netzwerk/wifiscan'; + +import { iperfTool } from './internet/iperf'; + +/** Alle registrierten Tools */ +export const TOOLS: Tool[] = [ + // Netzwerk + ipScanTool, + portScanTool, + pingTool, + wifiScanTool, + dhcpCheckTool, + snmpTool, + tracerouteTool, + stressTestTool, + // Internet + iperfTool, + // Telefonie: folgt (SIP-Registrierung, FreePBX-Check, RTP-Qualität) +]; + +/** Tool nach ID finden */ +export function getTool(id: string): Tool | undefined { + return TOOLS.find((t) => t.id === id); +} + +/** Tools einer Kategorie */ +export function toolsByCategory(cat: ToolCategory): Tool[] { + return TOOLS.filter((t) => t.category === cat); +} + +/** Tools mit bestimmtem Scope */ +export function toolsByScope(scope: 'protocol' | 'device'): Tool[] { + return TOOLS.filter((t) => t.scope === scope); +} + +export { CATEGORY_LABELS } from './types'; +export type { Tool, ToolCategory } from './types'; diff --git a/src/lib/tools/internet/iperf.ts b/src/lib/tools/internet/iperf.ts new file mode 100644 index 0000000..29d94ba --- /dev/null +++ b/src/lib/tools/internet/iperf.ts @@ -0,0 +1,55 @@ +/** + * Tool: Durchsatz-Test — misst die Bandbreite gegen eine Gegenstelle. + * + * Benötigt eine iperf-kompatible Gegenstelle (2. Gerät oder iperf3-Server). + */ + +import { scanner } from '../../scanner'; +import type { MeasureStatus, Tool } from '../types'; + +export const iperfTool: Tool = { + id: 'iperf', + category: 'internet', + name: 'Durchsatz-Test', + icon: 'gauge-circle', + description: 'Misst Down-/Upload-Bandbreite gegen eine Gegenstelle.', + scope: 'protocol', + params: [ + { key: 'host', label: 'Gegenstelle (IP)', type: 'text', placeholder: '192.168.1.20' }, + { key: 'port', label: 'Port', type: 'number', default: 5201 }, + { + key: 'duration', + label: 'Dauer (Sek.)', + type: 'select', + default: '10', + options: [ + { value: '5', label: '5 Sekunden' }, + { value: '10', label: '10 Sekunden' }, + { value: '30', label: '30 Sekunden' }, + ], + }, + ], + async run(ctx) { + const host = String(ctx.params.host ?? ''); + if (!host) throw new Error('Keine Gegenstelle angegeben'); + const port = Number(ctx.params.port || 5201); + const durationSec = Number(ctx.params.duration || 10); + + const res = await scanner.throughput({ host, port, durationSec }); + + let status: MeasureStatus = 0; + if (res.downMbps < 100) status = 1; + if (res.downMbps < 10) status = 2; + + return { + label: `↓ ${res.downMbps} Mbit/s · ↑ ${res.upMbps} Mbit/s`, + result: { + gegenstelle: `${host}:${port}`, + downloadMbps: res.downMbps, + uploadMbps: res.upMbps, + dauerSekunden: durationSec, + }, + measureStatus: status, + }; + }, +}; diff --git a/src/lib/tools/netzwerk/dhcpcheck.ts b/src/lib/tools/netzwerk/dhcpcheck.ts new file mode 100644 index 0000000..6a9717c --- /dev/null +++ b/src/lib/tools/netzwerk/dhcpcheck.ts @@ -0,0 +1,35 @@ +/** + * Tool: DHCP-Check — erkennt antwortende DHCP-Server. + * Mehr als ein Server deutet auf einen Rogue-DHCP hin (Warnung). + */ + +import { scanner } from '../../scanner'; +import type { MeasureStatus, Tool } from '../types'; + +export const dhcpCheckTool: Tool = { + id: 'dhcpcheck', + category: 'netzwerk', + name: 'DHCP-Check', + icon: 'server', + description: 'Findet DHCP-Server — erkennt unerwünschte Zweit-Server.', + scope: 'protocol', + params: [], + async run() { + const { servers } = await scanner.dhcpDiscover(); + let status: MeasureStatus = 0; + if (servers.length === 0) status = 2; // kein DHCP-Server + if (servers.length > 1) status = 2; // Rogue-DHCP + return { + label: + servers.length === 1 + ? `1 DHCP-Server: ${servers[0].ip}` + : `${servers.length} DHCP-Server (!)`, + result: { + count: servers.length, + server: servers.map((s) => `${s.ip}${s.mac ? ' / ' + s.mac : ''}`), + hinweis: servers.length > 1 ? 'Mehrere DHCP-Server — Rogue-DHCP prüfen!' : '', + }, + measureStatus: status, + }; + }, +}; diff --git a/src/lib/tools/netzwerk/ipscan.ts b/src/lib/tools/netzwerk/ipscan.ts new file mode 100644 index 0000000..33ff9d8 --- /dev/null +++ b/src/lib/tools/netzwerk/ipscan.ts @@ -0,0 +1,39 @@ +/** + * Tool: IP-Scanner — findet Geräte im Subnetz. + * Die gefundenen Geräte werden ins Protokoll übernommen. + */ + +import { scanner } from '../../scanner'; +import type { Tool } from '../types'; + +export const ipScanTool: Tool = { + id: 'ipscan', + category: 'netzwerk', + name: 'IP-Scanner', + icon: 'radar', + description: 'Sucht alle Geräte im Netzbereich (ARP + Ping + Namen).', + scope: 'protocol', + params: [ + { + key: 'subnet', + label: 'Netzbereich (CIDR)', + type: 'text', + placeholder: '192.168.1.0/24', + }, + ], + async run(ctx) { + const subnet = String(ctx.params.subnet || ctx.protocol.subnet || '192.168.1.0/24'); + const { devices } = await scanner.ipScan({ subnet }); + return { + label: `${devices.length} Geräte im Netz ${subnet}`, + result: { subnet, count: devices.length }, + measureStatus: devices.length > 0 ? 0 : 1, + devices: devices.map((d) => ({ + ip: d.ip, + mac: d.mac, + hostname: d.hostname, + vendor: d.vendor, + })), + }; + }, +}; diff --git a/src/lib/tools/netzwerk/ping.ts b/src/lib/tools/netzwerk/ping.ts new file mode 100644 index 0000000..e448cff --- /dev/null +++ b/src/lib/tools/netzwerk/ping.ts @@ -0,0 +1,46 @@ +/** + * Tool: Ping-Qualität — Latenz, Jitter und Paketverlust zu einem Host. + */ + +import { scanner } from '../../scanner'; +import type { MeasureStatus, Tool } from '../types'; + +export const pingTool: Tool = { + id: 'ping', + category: 'netzwerk', + name: 'Ping / Qualität', + icon: 'activity', + description: 'Misst Latenz, Jitter und Paketverlust.', + scope: 'device', + params: [ + { key: 'host', label: 'Ziel (leer = Gerät)', type: 'text', placeholder: '192.168.1.1' }, + { key: 'count', label: 'Anzahl Pakete', type: 'number', default: 20 }, + ], + async run(ctx) { + const host = ctx.device?.ip ?? String(ctx.params.host ?? ''); + if (!host) throw new Error('Kein Ziel angegeben'); + const count = Number(ctx.params.count ?? 20); + + const q = await scanner.pingQuality({ host, count }); + + // Bewertung: Verlust und Jitter + let status: MeasureStatus = 0; + if (q.lossPct > 0 || q.jitterMs > 10) status = 1; + if (q.lossPct >= 10 || q.avgMs > 100) status = 2; + + return { + label: `${host}: ${q.avgMs} ms ø, ${q.lossPct}% Verlust`, + result: { + host, + gesendet: q.sent, + empfangen: q.received, + verlustProzent: q.lossPct, + minMs: q.minMs, + avgMs: q.avgMs, + maxMs: q.maxMs, + jitterMs: q.jitterMs, + }, + measureStatus: status, + }; + }, +}; diff --git a/src/lib/tools/netzwerk/portscan.ts b/src/lib/tools/netzwerk/portscan.ts new file mode 100644 index 0000000..5d7bf90 --- /dev/null +++ b/src/lib/tools/netzwerk/portscan.ts @@ -0,0 +1,47 @@ +/** + * Tool: Port-Scanner — prüft offene TCP-Ports eines Geräts. + * Geräte-Tool: wird je gefundenem Gerät ausgeführt. + */ + +import { scanner } from '../../scanner'; +import type { Tool } from '../types'; + +/** Häufige Ports im Standard-Scan */ +const DEFAULT_PORTS = [21, 22, 23, 53, 80, 139, 443, 445, 502, 1883, 3389, 8080, 8443]; + +export const portScanTool: Tool = { + id: 'portscan', + category: 'netzwerk', + name: 'Port-Scan', + icon: 'scan-line', + description: 'Prüft offene TCP-Ports eines einzelnen Geräts.', + scope: 'device', + params: [ + { + key: 'ports', + label: 'Ports (Komma-getrennt, leer = Standard)', + type: 'text', + placeholder: DEFAULT_PORTS.join(','), + }, + ], + async run(ctx) { + const ip = ctx.device?.ip ?? String(ctx.params.ip ?? ''); + if (!ip) throw new Error('Kein Gerät/IP angegeben'); + + const raw = String(ctx.params.ports || '').trim(); + const ports = raw + ? raw.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => n > 0) + : DEFAULT_PORTS; + + const { open } = await scanner.portScan({ ip, ports }); + return { + label: `${ip}: ${open.length} offene Ports`, + result: { + ip, + scanned: ports.length, + open: open.map((p) => `${p.port}${p.service ? '/' + p.service : ''}`), + }, + measureStatus: 0, + }; + }, +}; diff --git a/src/lib/tools/netzwerk/snmp.ts b/src/lib/tools/netzwerk/snmp.ts new file mode 100644 index 0000000..0965306 --- /dev/null +++ b/src/lib/tools/netzwerk/snmp.ts @@ -0,0 +1,59 @@ +/** + * Tool: SNMP-Switch-Abfrage — Link-Speed und Fehlerzähler eines Ports. + * + * Liest über SNMP v2c Standard-OIDs aus der IF-MIB. Damit kommen + * Fehlerraten/Link-Speed auch ohne PoE-Hardware ins Protokoll. + */ + +import { scanner } from '../../scanner'; +import type { MeasureStatus, Tool } from '../types'; + +/** Standard-OIDs (IF-MIB), Index .1 als Beispiel-Port */ +const OIDS: Record = { + 'ifDescr': '1.3.6.1.2.1.2.2.1.2.1', + 'ifSpeed': '1.3.6.1.2.1.2.2.1.5.1', + 'ifInErrors': '1.3.6.1.2.1.2.2.1.14.1', + 'ifOutErrors': '1.3.6.1.2.1.2.2.1.20.1', +}; + +export const snmpTool: Tool = { + id: 'snmp', + category: 'netzwerk', + name: 'SNMP-Switch', + icon: 'network', + description: 'Liest Link-Speed und Fehlerzähler eines Switches (SNMP v2c).', + scope: 'device', + params: [ + { key: 'host', label: 'Switch-IP (leer = Gerät)', type: 'text', placeholder: '192.168.1.10' }, + { key: 'community', label: 'SNMP Community', type: 'text', default: 'public' }, + ], + async run(ctx) { + const host = ctx.device?.ip ?? String(ctx.params.host ?? ''); + if (!host) throw new Error('Keine Switch-IP angegeben'); + const community = String(ctx.params.community || 'public'); + + const { values } = await scanner.snmpGet({ host, community, oids: Object.values(OIDS) }); + + // OID-Werte den lesbaren Namen zuordnen + const named: Record = {}; + for (const [name, oid] of Object.entries(OIDS)) named[name] = values[oid] ?? '-'; + + const inErr = parseInt(named['ifInErrors'], 10) || 0; + const outErr = parseInt(named['ifOutErrors'], 10) || 0; + let status: MeasureStatus = 0; + if (inErr + outErr > 0) status = 1; + if (inErr + outErr > 100) status = 2; + + return { + label: `${host}: ${inErr + outErr} Fehler`, + result: { + host, + port: named['ifDescr'], + linkSpeed: named['ifSpeed'], + eingangsFehler: inErr, + ausgangsFehler: outErr, + }, + measureStatus: status, + }; + }, +}; diff --git a/src/lib/tools/netzwerk/stresstest.ts b/src/lib/tools/netzwerk/stresstest.ts new file mode 100644 index 0000000..125a5d6 --- /dev/null +++ b/src/lib/tools/netzwerk/stresstest.ts @@ -0,0 +1,59 @@ +/** + * Tool: Dauer-/Stresstest — Langzeitmessung von Verlust und Latenz. + * + * Läuft nativ als Foreground-Service, damit Android den Lauf nicht beendet. + * Die Dauer wird als Parameter vorgegeben; run() wartet auf das Ergebnis. + */ + +import { scanner } from '../../scanner'; +import type { MeasureStatus, Tool } from '../types'; + +export const stressTestTool: Tool = { + id: 'stresstest', + category: 'netzwerk', + name: 'Dauer-/Stresstest', + icon: 'gauge', + description: 'Langzeitmessung: Paketverlust und Latenz über einen Zeitraum.', + scope: 'protocol', + params: [ + { key: 'host', label: 'Ziel', type: 'text', default: '192.168.1.1' }, + { + key: 'duration', + label: 'Dauer', + type: 'select', + default: '300', + options: [ + { value: '60', label: '1 Minute' }, + { value: '300', label: '5 Minuten' }, + { value: '900', label: '15 Minuten' }, + { value: '3600', label: '1 Stunde' }, + ], + }, + ], + async run(ctx) { + const host = String(ctx.params.host || '192.168.1.1'); + const durationSec = Number(ctx.params.duration || 300); + + const { runId } = await scanner.startStressTest({ host, durationSec }); + // Auf das Ende des Laufs warten (Foreground-Service misst weiter) + await new Promise((r) => setTimeout(r, durationSec * 1000)); + const res = await scanner.stopStressTest({ runId }); + + let status: MeasureStatus = 0; + if (res.lossPct > 0 || res.maxMs > 100) status = 1; + if (res.lossPct >= 5 || res.maxMs > 500) status = 2; + + return { + label: `${host}: ${res.lossPct}% Verlust über ${Math.round(durationSec / 60)} min`, + result: { + host, + dauerSekunden: durationSec, + messpunkte: res.samples, + verlustProzent: res.lossPct, + avgMs: res.avgMs, + maxMs: res.maxMs, + }, + measureStatus: status, + }; + }, +}; diff --git a/src/lib/tools/netzwerk/traceroute.ts b/src/lib/tools/netzwerk/traceroute.ts new file mode 100644 index 0000000..b7c0708 --- /dev/null +++ b/src/lib/tools/netzwerk/traceroute.ts @@ -0,0 +1,28 @@ +/** + * Tool: Traceroute — Weg und Latenz zu einem Ziel. + */ + +import { scanner } from '../../scanner'; +import type { Tool } from '../types'; + +export const tracerouteTool: Tool = { + id: 'traceroute', + category: 'netzwerk', + name: 'Traceroute', + icon: 'route', + description: 'Zeigt die Netzwerk-Hops bis zum Ziel.', + scope: 'protocol', + params: [{ key: 'host', label: 'Ziel', type: 'text', default: '8.8.8.8' }], + async run(ctx) { + const host = String(ctx.params.host || '8.8.8.8'); + const { hops } = await scanner.traceroute({ host }); + return { + label: `${hops.length} Hops bis ${host}`, + result: { + ziel: host, + hops: hops.map((h) => `${h.ttl}. ${h.ip} (${h.ms} ms)`), + }, + measureStatus: hops.length > 0 ? 0 : 2, + }; + }, +}; diff --git a/src/lib/tools/netzwerk/wifiscan.ts b/src/lib/tools/netzwerk/wifiscan.ts new file mode 100644 index 0000000..c5a857f --- /dev/null +++ b/src/lib/tools/netzwerk/wifiscan.ts @@ -0,0 +1,28 @@ +/** + * Tool: WLAN-Scan — umliegende Netze, Kanäle und Signalstärke. + */ + +import { scanner } from '../../scanner'; +import type { Tool } from '../types'; + +export const wifiScanTool: Tool = { + id: 'wifiscan', + category: 'netzwerk', + name: 'WLAN-Scan', + icon: 'wifi', + description: 'Listet WLAN-Netze, Kanäle und Signalstärke.', + scope: 'protocol', + params: [], + async run() { + const { networks } = await scanner.wifiScan(); + const sorted = [...networks].sort((a, b) => b.rssi - a.rssi); + return { + label: `${networks.length} WLAN-Netze gefunden`, + result: { + count: networks.length, + netze: sorted.map((n) => `${n.ssid} (Kanal ${n.channel}, ${n.rssi} dBm, ${n.band})`), + }, + measureStatus: 0, + }; + }, +}; diff --git a/src/lib/tools/types.ts b/src/lib/tools/types.ts new file mode 100644 index 0000000..ea65c56 --- /dev/null +++ b/src/lib/tools/types.ts @@ -0,0 +1,73 @@ +/** + * Vertrag der Tool-Plattform. + * + * Ein Tool ist ein eigenständiger Baustein: eine Datei unter tools// + * plus ein Eintrag in tools/index.ts. Kein Eingriff in App-Logik, Sync oder + * Datenbank — das Ergebnis ist immer generisches JSON. So lassen sich Tools + * für Netzwerk, Internet, Telefonie usw. beliebig nachrüsten. + */ + +import type { Device, MeasureStatus, Protocol } from '../types'; + +export type ToolCategory = 'netzwerk' | 'internet' | 'telefonie'; + +/** Eingabefeld eines Tools (für das Parameter-Formular) */ +export interface ToolParamField { + key: string; + label: string; + type: 'text' | 'number' | 'select'; + default?: string | number; + options?: { value: string; label: string }[]; + placeholder?: string; +} + +/** Kontext, mit dem ein Tool ausgeführt wird */ +export interface ToolContext { + params: Record; + protocol: Protocol; + /** gesetzt, wenn das Tool für ein einzelnes Gerät läuft (scope 'device') */ + device?: Device; +} + +/** Rückgabe eines Tool-Laufs */ +export interface ToolRunResult { + /** Kurzbeschreibung für die Protokollzeile */ + label: string; + /** strukturiertes Ergebnis (wird als JSON gespeichert) */ + result: Record; + /** Ampel-Bewertung */ + measureStatus: MeasureStatus; + /** + * Optional: im Netzwerk gefundene Geräte. Werden vom Protokoll + * übernommen (z.B. beim IP-Scan). + */ + devices?: Array<{ + ip: string; + mac?: string; + hostname?: string; + vendor?: string; + deviceType?: string; + }>; +} + +/** Ein Diagnose-Werkzeug */ +export interface Tool { + /** eindeutige ID (wird als measurement.tool gespeichert) */ + id: string; + category: ToolCategory; + name: string; + /** lucide-svelte Icon-Name */ + icon: string; + description: string; + /** 'protocol' = global, 'device' = pro gefundenem Gerät */ + scope: 'protocol' | 'device'; + params: ToolParamField[]; + run(ctx: ToolContext): Promise; +} + +/** Beschriftung einer Kategorie */ +export const CATEGORY_LABELS: Record = { + netzwerk: 'Netzwerk', + internet: 'Internet', + telefonie: 'Telefonie', +}; diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..e55cbe4 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,94 @@ +/** + * Gemeinsame Typen der NetDiag-App. + */ + +/** Angemeldeter Benutzer (aus Dolibarr) */ +export interface NetUser { + id: number; + login: string; + name: string; + email: string; + canWrite: boolean; +} + +/** Kunde (Dolibarr thirdparty) */ +export interface Customer { + id: number; + name: string; + code?: string; + address?: string; + zip?: string; + town?: string; + phone?: string; + email?: string; + protocolCount?: number; +} + +/** Auftrag (Dolibarr commande) */ +export interface Order { + id: number; + ref: string; + refClient?: string; + date?: number; + /** Dolibarr-Status: -1 storniert, 0 Entwurf, 1 validiert, 2 in Bearbeitung, 3 abgeschlossen */ + status: number; + /** true wenn Status 0/1/2 (aktiver Auftrag) */ + open: boolean; + protocolCount?: number; + customer?: Partial; +} + +/** Ein im Netzwerk gefundenes Gerät */ +export interface Device { + /** lokale ID innerhalb des Protokolls (für Offline-Verknüpfung) */ + clientId: string; + /** Server-Rowid nach Sync, sonst null */ + serverId?: number | null; + ip: string; + mac?: string; + hostname?: string; + vendor?: string; + deviceType?: string; + note?: string; +} + +/** Ampel-Bewertung einer Messung */ +export type MeasureStatus = 0 | 1 | 2; // 0=ok, 1=warn, 2=fail + +/** Ergebnis eines Tool-Laufs */ +export interface Measurement { + clientId: string; + /** lokale ID des zugehörigen Geräts, falls geräte-bezogen */ + deviceClientId?: string | null; + tool: string; + category: string; + label: string; + params: Record; + result: Record; + measureStatus: MeasureStatus; + dateMeasure: number; +} + +/** Diagnose-Protokoll (Offline-Datensatz der App) */ +export interface Protocol { + clientUuid: string; + /** Server-Rowid nach Sync */ + serverId?: number | null; + ref?: string; + label: string; + socId?: number | null; + socName?: string; + orderId?: number | null; + orderRef?: string; + dateDiag: number; + location: string; + subnet: string; + /** 0 = Entwurf, 1 = abgeschlossen */ + status: number; + note: string; + devices: Device[]; + measurements: Measurement[]; + /** true solange noch nicht zum Server synchronisiert */ + dirty: boolean; + updatedAt: number; +} diff --git a/src/lib/updater.ts b/src/lib/updater.ts new file mode 100644 index 0000000..32eb2c8 --- /dev/null +++ b/src/lib/updater.ts @@ -0,0 +1,68 @@ +/** + * Auto-Updater (Muster aus Wissensbasis KB #363). + * + * Prüft die Forgejo Package Registry auf eine neuere APK. Die CI lädt jede + * APK mit Versionsstempel `YYYYMMDD-HHMM` hoch; dieselbe Version steckt über + * `VITE_APP_VERSION` im Build. Stringvergleich genügt also. + */ + +import { Capacitor } from '@capacitor/core'; + +const PKG_OWNER = 'data-it'; +const PKG_NAME = 'netdiag-apk'; +const REGISTRY_BASE = 'https://git.data-it-solution.de'; + +/** Aktuelle Build-Version (von der CI injiziert, im Dev leer) */ +export const APP_VERSION: string = import.meta.env.VITE_APP_VERSION ?? 'dev'; + +export interface UpdateInfo { + version: string; + downloadUrl: string; +} + +interface ForgejoPackage { + name: string; + version: string; +} + +/** + * Prüfen, ob eine neuere APK verfügbar ist. + * + * @returns UpdateInfo bei verfügbarem Update, sonst null + */ +export async function checkForUpdate(): Promise { + // Im Browser-Dev oder ohne CI-Version nicht prüfen + if (!Capacitor.isNativePlatform() || APP_VERSION === 'dev') return null; + + try { + const res = await fetch( + `${REGISTRY_BASE}/api/v1/packages/${PKG_OWNER}?type=generic&q=${PKG_NAME}`, + ); + if (!res.ok) return null; + const pkgs = (await res.json()) as ForgejoPackage[]; + + const versions = pkgs + .filter((p) => p.name === PKG_NAME && p.version !== 'latest') + .map((p) => p.version) + .sort(); + const latest = versions[versions.length - 1]; + + if (latest && latest > APP_VERSION) { + return { + version: latest, + downloadUrl: `${REGISTRY_BASE}/api/packages/${PKG_OWNER}/generic/${PKG_NAME}/${latest}/NetDiag-${latest}.apk`, + }; + } + } catch { + // Update-Prüfung ist unkritisch — Fehler still ignorieren + } + return null; +} + +/** + * Update-APK im System öffnen. Android lädt die Datei herunter; der Nutzer + * bestätigt anschließend die Installation. + */ +export function openUpdate(info: UpdateInfo): void { + window.open(info.downloadUrl, '_system'); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..e754830 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,79 @@ + + +
+ {#if !booted} +
+ NetDiag startet … +
+ {:else} + {#if updateInfo} + + {/if} + {@render children()} + {/if} +
+ + diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 0000000..b43053d --- /dev/null +++ b/src/routes/+layout.ts @@ -0,0 +1,8 @@ +/** + * Layout-Konfiguration: reine Client-App (kein SSR/Prerendering). + * Capacitor lädt statisches HTML, die Logik läuft im WebView. + */ + +export const ssr = false; +export const prerender = false; +export const trailingSlash = 'always'; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..d08e64f --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,9 @@ + + +
NetDiag …
diff --git a/src/routes/auftraege/+page.svelte b/src/routes/auftraege/+page.svelte new file mode 100644 index 0000000..b3c4030 --- /dev/null +++ b/src/routes/auftraege/+page.svelte @@ -0,0 +1,144 @@ + + + + +
+
+
+ + +
+
+ + + +
+ {#if loading} +

Lädt …

+ {:else if loadError} +
+

{loadError}

+ +
+ {:else if orders.length === 0} +

Keine Aufträge gefunden.

+ {:else} + {#each orders as order (order.id)} + + {/each} + {/if} +
+ + +
diff --git a/src/routes/einstellungen/+page.svelte b/src/routes/einstellungen/+page.svelte new file mode 100644 index 0000000..760b65a --- /dev/null +++ b/src/routes/einstellungen/+page.svelte @@ -0,0 +1,65 @@ + + + + +
+
+

Konto

+

{auth.user?.name ?? '—'}

+

{auth.user?.email ?? ''}

+

+ {auth.user?.canWrite ? 'Schreibrecht vorhanden' : 'Nur Lesezugriff'} +

+
+ +
+

Server

+

{getServerUrl() || '(Browser-Proxy)'}

+

+ Offene Protokolle: {sync.pendingCount} · Status: {sync.status} +

+ +
+ +
+

App

+

Version {APP_VERSION}

+ +
+ + +
diff --git a/src/routes/kunden/+page.svelte b/src/routes/kunden/+page.svelte new file mode 100644 index 0000000..0714fdd --- /dev/null +++ b/src/routes/kunden/+page.svelte @@ -0,0 +1,77 @@ + + + + +
+
+
+ + +
+
+ +
+ {#if loading} +

Lädt …

+ {:else if query.trim().length < 2} +

Mindestens 2 Zeichen eingeben.

+ {:else if customers.length === 0} +

Kein Kunde gefunden.

+ {:else} + {#each customers as c (c.id)} + + {/each} + {/if} +
+
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..1c6adbd --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,85 @@ + + +
+
+

NetDiag

+

Netzwerk-Diagnose

+
+ +
+ {#if needsServer} + + {/if} + + + +
+
diff --git a/src/routes/protokoll/[id]/+page.svelte b/src/routes/protokoll/[id]/+page.svelte new file mode 100644 index 0000000..b82aa7b --- /dev/null +++ b/src/routes/protokoll/[id]/+page.svelte @@ -0,0 +1,276 @@ + + +{#if protocol} + + +
+ +
+ + + +
+ + +
+

Werkzeuge

+
+ {#each protocolTools as tool (tool.id)} + {@const IconC = icon(tool.icon)} + + {/each} +
+
+ + + {#if protocolMeasurements().length > 0} +
+

Messungen

+ {#each protocolMeasurements() as m (m.clientId)} +
+
+ + {getTool(m.tool)?.name ?? m.tool} +
+

{m.label}

+

{resultText(m.result)}

+
+ {/each} +
+ {/if} + + +
+

+ Geräte ({protocol.devices.length}) +

+ {#if protocol.devices.length === 0} +

+ Noch keine Geräte — IP-Scanner ausführen, um das Netz zu erfassen. +

+ {/if} + {#each protocol.devices as device (device.clientId)} +
+
+ {device.ip} + {device.vendor ?? ''} +
+
+ {device.hostname ?? ''}{device.mac ? ' · ' + device.mac : ''} +
+ + {#each measurementsFor(device.clientId) as m (m.clientId)} +
+ +
+

{m.label}

+

{resultText(m.result)}

+
+
+ {/each} + +
+ {#each deviceTools as tool (tool.id)} + + {/each} +
+
+ {/each} +
+ +
+ +
+
+ + +
+ +
+ + {#if activeTool} + (activeTool = null)} + onrun={runTool} + /> + {/if} +{:else} +
Lädt …
+{/if} diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..e65f394 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,16 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + onwarn: (warning, handler) => { + if (warning.code.startsWith('a11y_')) return; + handler(warning); + }, + kit: { + adapter: adapter({ fallback: 'index.html' }), + }, +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bbeba3a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..1072262 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,21 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +// Im Browser-Dev werden API-Aufrufe an den Dolibarr-Testserver geproxied. +// Auf dem Gerät spricht die App direkt die in den Einstellungen hinterlegte +// Server-URL an (siehe src/lib/api.ts). +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + envPrefix: ['VITE_'], + server: { + port: 5175, + proxy: { + '/custom/netdiag/api': { + target: 'http://192.168.155.11', + changeOrigin: true, + }, + }, + }, + build: { target: 'esnext', sourcemap: false }, +});