Initiales Commit — NetDiag App vollständig implementiert [apk]
Some checks failed
Build APK / build-apk (push) Failing after 11m29s

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) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-19 12:01:56 +02:00
commit bf01b4cd21
46 changed files with 3575 additions and 0 deletions

View file

@ -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 <<PROPS
android.useAndroidX=true
RELEASE_STORE_FILE=/tmp/release.jks
RELEASE_STORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }}
RELEASE_KEY_ALIAS=${{ secrets.KEY_ALIAS }}
RELEASE_KEY_PASSWORD=${{ secrets.KEY_PASSWORD }}
PROPS
- name: Release-Signing in build.gradle eintragen
run: |
cd android/app
sed -i '/buildTypes {/i \
signingConfigs {\
release {\
storeFile file(RELEASE_STORE_FILE)\
storePassword RELEASE_STORE_PASSWORD\
keyAlias RELEASE_KEY_ALIAS\
keyPassword RELEASE_KEY_PASSWORD\
}\
}' build.gradle
sed -i 's/minifyEnabled false/minifyEnabled false\n signingConfig signingConfigs.release/' build.gradle
- name: APK bauen (Release)
run: |
cd android
echo "sdk.dir=/opt/android-sdk" > 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

19
.gitignore vendored Normal file
View file

@ -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

73
README.md Normal file
View file

@ -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/<kategorie>/<id>.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.

17
capacitor.config.ts Normal file
View file

@ -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;

View file

@ -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<String, StressRun>()
/* --------------------------------------------------------------------- */
/* 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<Double>()
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<String> {
val found = LinkedHashSet<String>()
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<String, String> {
val map = HashMap<String, String>()
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"
)
}
}

72
native-plugin/README.md Normal file
View file

@ -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 `<manifest>`):
```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
```
`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.

138
native-plugin/Snmp.kt Normal file
View file

@ -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<Int>()
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
}
}

39
package.json Normal file
View file

@ -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"
}
}

35
src/app.css Normal file
View file

@ -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;
}

21
src/app.d.ts vendored Normal file
View file

@ -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 {};

13
src/app.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0d1117" />
<title>NetDiag</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

217
src/lib/api.ts Normal file
View file

@ -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<void> {
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<void> {
serverUrl = url.replace(/\/+$/, '');
await Preferences.set({ key: 'serverUrl', value: serverUrl });
}
export function isLoggedIn(): boolean {
return token !== '';
}
async function setToken(value: string): Promise<void> {
token = value;
await Preferences.set({ key: 'token', value });
}
export async function clearToken(): Promise<void> {
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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const headers: Record<string, string> = { 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<LoginResult> {
const res = await request<LoginResult>('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<SyncResult> {
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<SyncResult>('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)}`;
}

59
src/lib/auth.svelte.ts Normal file
View file

@ -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<NetUser | null>(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<void> {
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<void> {
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<void> {
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();

View file

@ -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;
}

View file

@ -0,0 +1,43 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { sync } from '$lib/sync.svelte';
import { ChevronLeft, RefreshCw } from 'lucide-svelte';
let {
title,
back = false,
subtitle = '',
}: { title: string; back?: boolean; subtitle?: string } = $props();
// Sync-Ampel: grün=ok, gelb=läuft/offen, rot=Fehler
const dot = $derived(
sync.status === 'error'
? 'bg-red-500'
: sync.pendingCount > 0 || sync.status === 'syncing'
? 'bg-amber-400'
: 'bg-emerald-500',
);
</script>
<header class="flex items-center gap-2 border-b border-zinc-800 bg-zinc-900 px-3 py-3 safe-top">
{#if back}
<button class="rounded p-1 active:bg-zinc-800" onclick={() => history.back()} aria-label="Zurück">
<ChevronLeft size={24} />
</button>
{/if}
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-semibold">{title}</h1>
{#if subtitle}
<p class="truncate text-xs text-zinc-400">{subtitle}</p>
{/if}
</div>
<button
class="flex items-center gap-1 rounded px-2 py-1 text-xs text-zinc-300 active:bg-zinc-800"
onclick={() => sync.syncNow()}
title="Synchronisieren"
>
<span class="h-2.5 w-2.5 rounded-full {dot}"></span>
{#if sync.pendingCount > 0}<span>{sync.pendingCount}</span>{/if}
<RefreshCw size={14} class={sync.status === 'syncing' ? 'animate-spin' : ''} />
</button>
</header>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { toast } from '$lib/toast.svelte';
const colors = {
info: 'bg-zinc-700',
success: 'bg-emerald-700',
error: 'bg-red-700',
};
</script>
<div class="pointer-events-none fixed inset-x-0 top-0 z-50 flex flex-col items-center gap-2 p-3 safe-top">
{#each toast.items as item (item.id)}
<div class="rounded-lg px-4 py-2 text-sm text-white shadow-lg {colors[item.type]}">
{item.text}
</div>
{/each}
</div>

View file

@ -0,0 +1,95 @@
<script lang="ts">
import type { Tool } from '$lib/tools/types';
import type { Device, Protocol } from '$lib/types';
import { X } from 'lucide-svelte';
let {
tool,
protocol,
device = undefined,
onclose,
onrun,
}: {
tool: Tool;
protocol: Protocol;
device?: Device;
onclose: () => void;
onrun: (params: Record<string, string | number>) => Promise<void>;
} = $props();
// Parameter mit Vorgabewerten füllen
let params = $state<Record<string, string | number>>(
Object.fromEntries(tool.params.map((p) => [p.key, p.default ?? ''])),
);
let busy = $state(false);
let error = $state('');
async function execute() {
busy = true;
error = '';
try {
await onrun({ ...params });
onclose();
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Ausführen';
} finally {
busy = false;
}
}
</script>
<div class="fixed inset-0 z-40 flex items-end bg-black/60" role="presentation" onclick={onclose}>
<div
class="w-full rounded-t-2xl bg-zinc-900 p-4 safe-bottom"
role="dialog"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={() => {}}
>
<div class="mb-3 flex items-center justify-between">
<h2 class="font-semibold">{tool.name}</h2>
<button onclick={onclose} aria-label="Schließen"><X size={20} /></button>
</div>
<p class="mb-3 text-xs text-zinc-400">{tool.description}</p>
{#if device}
<p class="mb-3 text-sm text-sky-400">Gerät: {device.ip}</p>
{/if}
<div class="flex flex-col gap-3">
{#each tool.params as field (field.key)}
<label class="flex flex-col gap-1 text-sm">
<span class="text-zinc-400">{field.label}</span>
{#if field.type === 'select'}
<select
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
bind:value={params[field.key]}
>
{#each field.options ?? [] as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{:else}
<input
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
type={field.type === 'number' ? 'number' : 'text'}
placeholder={field.placeholder ?? ''}
bind:value={params[field.key]}
/>
{/if}
</label>
{/each}
</div>
{#if error}
<p class="mt-3 text-sm text-red-400">{error}</p>
{/if}
<button
class="mt-4 w-full rounded-lg bg-sky-600 py-2.5 font-semibold text-white active:bg-sky-700 disabled:opacity-50"
onclick={execute}
disabled={busy}
>
{busy ? 'Messung läuft …' : 'Ausführen'}
</button>
</div>
</div>

97
src/lib/db.ts Normal file
View file

@ -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<void> {
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<void> {
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<Protocol | null> {
if (useSqlite && db) {
const res = await db.query('SELECT json FROM protocols WHERE uuid = ?', [uuid]);
const row = res.values?.[0];
return row ? (JSON.parse(row.json) as Protocol) : null;
}
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<Protocol[]> {
let list: Protocol[] = [];
if (useSqlite && db) {
const res = await db.query('SELECT json FROM protocols ORDER BY updated_at DESC');
list = (res.values ?? []).map((r) => JSON.parse(r.json) as Protocol);
} 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<Protocol[]> {
return (await getAllProtocols()).filter((p) => p.dirty);
}
/** Protokoll löschen */
export async function deleteProtocol(uuid: string): Promise<void> {
if (useSqlite && db) {
await db.run('DELETE FROM protocols WHERE uuid = ?', [uuid]);
} else {
localStorage.removeItem(LS_PREFIX + uuid);
}
}

67
src/lib/protocols.ts Normal file
View file

@ -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<Protocol> {
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<Device, 'clientId'> & { 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, 'clientId'>,
): Measurement {
const created: Measurement = { ...m, clientId: uid() };
protocol.measurements.push(created);
return created;
}

169
src/lib/scanner.ts Normal file
View file

@ -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<PingQuality>;
/** 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<string, string>;
}>;
/** 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<ThroughputResult>;
/** 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<NetDiagScannerPlugin>('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<string, string> = {};
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;

88
src/lib/sync.svelte.ts Normal file
View file

@ -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<SyncStatus>('idle');
pendingCount = $state(0);
lastError = $state('');
online = $state(true);
private timer: ReturnType<typeof setInterval> | null = null;
/** Sync-Dienst starten: Netz-Listener + periodischer Lauf */
async start(): Promise<void> {
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<void> {
this.pendingCount = (await getDirtyProtocols()).length;
}
/**
* Alle offenen Protokolle synchronisieren.
* Läuft still im Hintergrund; Fehler werden gemerkt, nicht geworfen.
*/
async syncNow(): Promise<void> {
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();

36
src/lib/toast.svelte.ts Normal file
View file

@ -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<ToastItem[]>([]);
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();

55
src/lib/tools/index.ts Normal file
View file

@ -0,0 +1,55 @@
/**
* Tool-Registry zentrale Sammlung aller Diagnose-Werkzeuge.
*
* Ein neues Tool hinzufügen:
* 1. Datei unter tools/<kategorie>/<id>.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';

View file

@ -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,
};
},
};

View file

@ -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,
};
},
};

View file

@ -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,
})),
};
},
};

View file

@ -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,
};
},
};

View file

@ -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,
};
},
};

View file

@ -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<string, string> = {
'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<string, string> = {};
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,
};
},
};

View file

@ -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,
};
},
};

View file

@ -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,
};
},
};

View file

@ -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,
};
},
};

73
src/lib/tools/types.ts Normal file
View file

@ -0,0 +1,73 @@
/**
* Vertrag der Tool-Plattform.
*
* Ein Tool ist ein eigenständiger Baustein: eine Datei unter tools/<kategorie>/
* 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<string, string | number>;
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<string, unknown>;
/** 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<ToolRunResult>;
}
/** Beschriftung einer Kategorie */
export const CATEGORY_LABELS: Record<ToolCategory, string> = {
netzwerk: 'Netzwerk',
internet: 'Internet',
telefonie: 'Telefonie',
};

94
src/lib/types.ts Normal file
View file

@ -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<Customer>;
}
/** 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<string, unknown>;
result: Record<string, unknown>;
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;
}

68
src/lib/updater.ts Normal file
View file

@ -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<UpdateInfo | null> {
// 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');
}

79
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,79 @@
<script lang="ts">
import '../app.css';
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { auth } from '$lib/auth.svelte';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import { initDb } from '$lib/db';
import { registerBackListener, removeBackListener } from '$lib/backButton.svelte';
import { checkForUpdate, openUpdate, type UpdateInfo } from '$lib/updater';
import Toast from '$lib/components/Toast.svelte';
let { children } = $props();
let booted = $state(false);
let updateInfo = $state<UpdateInfo | null>(null);
const HOME = '/auftraege/';
onMount(async () => {
await auth.init();
await initDb();
if (auth.loggedIn) await sync.start();
// Hardware-Backbutton (Modul-Scope, Single-Instance — KB #480/#549)
registerBackListener({
handleOverlay: () => false,
isHomeRoute: () => {
const p = $page.url.pathname;
return p === HOME || p === '/' || p === '/login/';
},
goBack: () => history.back(),
showExitHint: () => toast.show('Nochmal drücken zum Beenden'),
});
// Auf neue APK prüfen (KB #363)
updateInfo = await checkForUpdate();
booted = true;
});
onDestroy(() => {
removeBackListener();
sync.stop();
});
// Auth-Gate: nicht angemeldet -> Login
$effect(() => {
if (!booted) return;
const path = $page.url.pathname;
const onLogin = path.startsWith('/login');
if (!auth.loggedIn && !onLogin) {
goto('/login/');
} else if (auth.loggedIn && onLogin) {
goto(HOME);
}
});
</script>
<div class="flex min-h-screen flex-col">
{#if !booted}
<div class="flex flex-1 items-center justify-center text-zinc-500">
<span>NetDiag startet …</span>
</div>
{:else}
{#if updateInfo}
<button
class="bg-sky-700 px-4 py-2 text-sm text-white safe-top"
onclick={() => updateInfo && openUpdate(updateInfo)}
>
Neue Version {updateInfo.version} verfügbar — tippen zum Aktualisieren
</button>
{/if}
{@render children()}
{/if}
</div>
<Toast />

8
src/routes/+layout.ts Normal file
View file

@ -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';

9
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
// Einstiegspunkt -> direkt zur Auftragsliste
onMount(() => goto('/auftraege/'));
</script>
<div class="flex min-h-screen items-center justify-center text-zinc-500">NetDiag …</div>

View file

@ -0,0 +1,144 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { Preferences } from '@capacitor/preferences';
import AppHeader from '$lib/components/AppHeader.svelte';
import { listOrders, ApiError } from '$lib/api';
import { getAllProtocols } from '$lib/db';
import { createProtocol } from '$lib/protocols';
import { toast } from '$lib/toast.svelte';
import type { Order } from '$lib/types';
import { Search, Users, Settings, FileStack } from 'lucide-svelte';
let orders = $state<Order[]>([]);
let search = $state('');
let showAll = $state(false);
let loading = $state(false);
let loadError = $state('');
let searchTimer: ReturnType<typeof setTimeout>;
async function load() {
loading = true;
loadError = '';
try {
const res = await listOrders({ open: !showAll, q: search.trim() || undefined });
orders = res.orders;
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'Laden fehlgeschlagen';
} finally {
loading = false;
}
}
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(load, 300);
}
async function toggleShowAll() {
showAll = !showAll;
await Preferences.set({ key: 'nd_show_all', value: showAll ? '1' : '0' });
load();
}
// Auftrag öffnen: vorhandenes Protokoll wiederverwenden, sonst neu anlegen
async function openOrder(order: Order) {
try {
const all = await getAllProtocols();
const existing = all.find((p) => p.orderId === order.id);
if (existing) {
goto(`/protokoll/${existing.clientUuid}/`);
return;
}
const p = await createProtocol({
socId: order.customer?.id ?? null,
socName: order.customer?.name ?? '',
orderId: order.id,
orderRef: order.ref,
});
goto(`/protokoll/${p.clientUuid}/`);
} catch {
toast.show('Protokoll konnte nicht geöffnet werden', 'error');
}
}
onMount(async () => {
showAll = (await Preferences.get({ key: 'nd_show_all' })).value === '1';
load();
});
</script>
<AppHeader title="Aufträge" />
<div class="flex flex-1 flex-col">
<div class="flex items-center gap-2 border-b border-zinc-800 px-3 py-2">
<div class="flex flex-1 items-center gap-2 rounded-lg bg-zinc-800 px-3 py-2">
<Search size={16} class="text-zinc-500" />
<input
class="flex-1 bg-transparent text-sm outline-none"
placeholder="Auftrag oder Kunde suchen"
bind:value={search}
oninput={onSearchInput}
/>
</div>
</div>
<label class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-400">
<input type="checkbox" checked={showAll} onchange={toggleShowAll} />
Auch abgeschlossene Aufträge anzeigen
</label>
<div class="flex-1 overflow-y-auto">
{#if loading}
<p class="p-6 text-center text-sm text-zinc-500">Lädt …</p>
{:else if loadError}
<div class="p-6 text-center text-sm">
<p class="text-red-400">{loadError}</p>
<button class="mt-2 rounded bg-zinc-800 px-3 py-1" onclick={load}>Erneut</button>
</div>
{:else if orders.length === 0}
<p class="p-6 text-center text-sm text-zinc-500">Keine Aufträge gefunden.</p>
{:else}
{#each orders as order (order.id)}
<button
class="flex w-full items-center gap-3 border-b border-zinc-800/60 px-3 py-3 text-left active:bg-zinc-800"
onclick={() => openOrder(order)}
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-medium">{order.ref}</span>
{#if !order.open}
<span class="rounded bg-zinc-700 px-1.5 py-0.5 text-[10px] text-zinc-300"
>abgeschlossen</span
>
{/if}
</div>
<div class="truncate text-sm text-zinc-400">{order.customer?.name ?? ''}</div>
<div class="truncate text-xs text-zinc-500">
{order.customer?.zip ?? ''}
{order.customer?.town ?? ''}
</div>
</div>
{#if order.protocolCount && order.protocolCount > 0}
<span class="flex items-center gap-1 text-xs text-sky-400">
<FileStack size={14} />{order.protocolCount}
</span>
{/if}
</button>
{/each}
{/if}
</div>
<nav class="flex border-t border-zinc-800 safe-bottom">
<a class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs text-zinc-300" href="/kunden/">
<Users size={20} />Kunden
</a>
<a
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs text-zinc-300"
href="/einstellungen/"
>
<Settings size={20} />Einstellungen
</a>
</nav>
</div>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AppHeader from '$lib/components/AppHeader.svelte';
import { auth } from '$lib/auth.svelte';
import { getServerUrl } from '$lib/api';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import { APP_VERSION, checkForUpdate, openUpdate } from '$lib/updater';
let checking = $state(false);
async function logout() {
await auth.logout();
sync.stop();
goto('/login/');
}
async function checkUpdate() {
checking = true;
const upd = await checkForUpdate();
checking = false;
if (upd) openUpdate(upd);
else toast.show('App ist aktuell', 'success');
}
</script>
<AppHeader title="Einstellungen" back />
<div class="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
<section class="rounded-lg bg-zinc-900 p-4">
<h2 class="mb-2 text-sm font-semibold text-zinc-300">Konto</h2>
<p class="text-sm">{auth.user?.name ?? '—'}</p>
<p class="text-xs text-zinc-500">{auth.user?.email ?? ''}</p>
<p class="mt-1 text-xs text-zinc-500">
{auth.user?.canWrite ? 'Schreibrecht vorhanden' : 'Nur Lesezugriff'}
</p>
</section>
<section class="rounded-lg bg-zinc-900 p-4">
<h2 class="mb-2 text-sm font-semibold text-zinc-300">Server</h2>
<p class="break-all text-sm text-zinc-400">{getServerUrl() || '(Browser-Proxy)'}</p>
<p class="mt-2 text-xs text-zinc-500">
Offene Protokolle: {sync.pendingCount} · Status: {sync.status}
</p>
<button class="mt-2 rounded bg-zinc-800 px-3 py-1.5 text-sm" onclick={() => sync.syncNow()}>
Jetzt synchronisieren
</button>
</section>
<section class="rounded-lg bg-zinc-900 p-4">
<h2 class="mb-2 text-sm font-semibold text-zinc-300">App</h2>
<p class="text-sm text-zinc-400">Version {APP_VERSION}</p>
<button
class="mt-2 rounded bg-zinc-800 px-3 py-1.5 text-sm"
onclick={checkUpdate}
disabled={checking}
>
{checking ? 'Prüfe …' : 'Auf Update prüfen'}
</button>
</section>
<button class="mt-2 rounded-lg bg-red-700 py-2.5 font-semibold text-white" onclick={logout}>
Abmelden
</button>
</div>

View file

@ -0,0 +1,77 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AppHeader from '$lib/components/AppHeader.svelte';
import { searchCustomers, ApiError } from '$lib/api';
import { createProtocol } from '$lib/protocols';
import { toast } from '$lib/toast.svelte';
import type { Customer } from '$lib/types';
import { Search } from 'lucide-svelte';
let query = $state('');
let customers = $state<Customer[]>([]);
let loading = $state(false);
let searchTimer: ReturnType<typeof setTimeout>;
function onInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(run, 300);
}
async function run() {
if (query.trim().length < 2) {
customers = [];
return;
}
loading = true;
try {
const res = await searchCustomers(query.trim());
customers = res.customers;
} catch (e) {
toast.show(e instanceof ApiError ? e.message : 'Suche fehlgeschlagen', 'error');
} finally {
loading = false;
}
}
// Neue Diagnose ohne Auftrag, direkt am Kunden
async function newDiagnosis(c: Customer) {
const p = await createProtocol({ socId: c.id, socName: c.name });
goto(`/protokoll/${p.clientUuid}/`);
}
</script>
<AppHeader title="Kunden" back />
<div class="flex flex-1 flex-col">
<div class="border-b border-zinc-800 px-3 py-2">
<div class="flex items-center gap-2 rounded-lg bg-zinc-800 px-3 py-2">
<Search size={16} class="text-zinc-500" />
<input
class="flex-1 bg-transparent text-sm outline-none"
placeholder="Kunde suchen (Name, Ort, Nr.)"
bind:value={query}
oninput={onInput}
/>
</div>
</div>
<div class="flex-1 overflow-y-auto">
{#if loading}
<p class="p-6 text-center text-sm text-zinc-500">Lädt …</p>
{:else if query.trim().length < 2}
<p class="p-6 text-center text-sm text-zinc-500">Mindestens 2 Zeichen eingeben.</p>
{:else if customers.length === 0}
<p class="p-6 text-center text-sm text-zinc-500">Kein Kunde gefunden.</p>
{:else}
{#each customers as c (c.id)}
<button
class="flex w-full flex-col gap-0.5 border-b border-zinc-800/60 px-3 py-3 text-left active:bg-zinc-800"
onclick={() => newDiagnosis(c)}
>
<span class="font-medium">{c.name}</span>
<span class="text-xs text-zinc-500">{c.zip ?? ''} {c.town ?? ''}</span>
</button>
{/each}
{/if}
</div>
</div>

View file

@ -0,0 +1,85 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { auth } from '$lib/auth.svelte';
import { sync } from '$lib/sync.svelte';
import { getServerUrl, setServerUrl, ApiError } from '$lib/api';
import { toast } from '$lib/toast.svelte';
import { Capacitor } from '@capacitor/core';
let server = $state(getServerUrl());
let loginName = $state('');
let password = $state('');
let busy = $state(false);
// Server-URL nur auf dem Gerät nötig (im Browser läuft der Vite-Proxy)
const needsServer = Capacitor.isNativePlatform();
async function submit(e: Event) {
e.preventDefault();
if (busy) return;
busy = true;
try {
if (needsServer) {
if (!server.trim()) throw new ApiError(0, 'Server-Adresse fehlt');
await setServerUrl(server.trim());
}
await auth.login(loginName.trim(), password);
await sync.start();
toast.show('Angemeldet', 'success');
goto('/auftraege/');
} catch (err) {
toast.show(err instanceof ApiError ? err.message : 'Anmeldung fehlgeschlagen', 'error');
} finally {
busy = false;
}
}
</script>
<div class="flex min-h-screen flex-col items-center justify-center gap-6 p-6 safe-top safe-bottom">
<div class="text-center">
<h1 class="text-2xl font-bold text-sky-400">NetDiag</h1>
<p class="text-sm text-zinc-400">Netzwerk-Diagnose</p>
</div>
<form class="flex w-full max-w-sm flex-col gap-3" onsubmit={submit}>
{#if needsServer}
<label class="flex flex-col gap-1 text-sm">
<span class="text-zinc-400">Dolibarr-Server</span>
<input
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
type="url"
placeholder="https://dolibarr.example.de"
bind:value={server}
autocomplete="url"
/>
</label>
{/if}
<label class="flex flex-col gap-1 text-sm">
<span class="text-zinc-400">Benutzer</span>
<input
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
type="text"
bind:value={loginName}
autocomplete="username"
required
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-zinc-400">Passwort</span>
<input
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
type="password"
bind:value={password}
autocomplete="current-password"
required
/>
</label>
<button
class="mt-2 rounded-lg bg-sky-600 py-2.5 font-semibold text-white active:bg-sky-700 disabled:opacity-50"
type="submit"
disabled={busy}
>
{busy ? 'Anmelden …' : 'Anmelden'}
</button>
</form>
</div>

View file

@ -0,0 +1,276 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import AppHeader from '$lib/components/AppHeader.svelte';
import ToolDialog from '$lib/components/ToolDialog.svelte';
import { getProtocol, saveProtocol, deleteProtocol } from '$lib/db';
import { addMeasurement, upsertDevice } from '$lib/protocols';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import { TOOLS, getTool } from '$lib/tools';
import type { Tool } from '$lib/tools/types';
import type { Device, Protocol } from '$lib/types';
import * as Icons from 'lucide-svelte';
let protocol = $state<Protocol | null>(null);
let activeTool = $state<Tool | null>(null);
let activeDevice = $state<Device | undefined>(undefined);
let saving = $state(false);
const protocolTools = TOOLS.filter((t) => t.scope === 'protocol');
const deviceTools = TOOLS.filter((t) => t.scope === 'device');
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500'];
onMount(async () => {
const uuid = $page.params.id;
const p = await getProtocol(uuid);
if (!p) {
toast.show('Protokoll nicht gefunden', 'error');
goto('/auftraege/');
return;
}
protocol = p;
});
/** Protokoll als geändert markieren und lokal speichern */
async function persist() {
if (!protocol) return;
protocol.dirty = true;
await saveProtocol($state.snapshot(protocol) as Protocol);
await sync.refreshPending();
}
/** Lucide-Icon dynamisch holen (Tool-Icon-Name ist kebab-case) */
function icon(name: string) {
const pascal = name
.split('-')
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join('');
const map = Icons as unknown as Record<string, unknown>;
return (map[pascal] ?? Icons.Wrench) as typeof Icons.Wrench;
}
function openTool(tool: Tool, device?: Device) {
activeTool = tool;
activeDevice = device;
}
/** Tool ausführen, Ergebnis ins Protokoll übernehmen */
async function runTool(params: Record<string, string | number>) {
if (!protocol || !activeTool) return;
const tool = activeTool;
const result = await tool.run({ params, protocol, device: activeDevice });
// Neu gefundene Geräte übernehmen (z.B. IP-Scan)
if (result.devices) {
for (const d of result.devices) {
upsertDevice(protocol, {
ip: d.ip,
mac: d.mac,
hostname: d.hostname,
vendor: d.vendor,
deviceType: d.deviceType,
});
}
}
addMeasurement(protocol, {
deviceClientId: activeDevice?.clientId ?? null,
tool: tool.id,
category: tool.category,
label: result.label,
params,
result: result.result,
measureStatus: result.measureStatus,
dateMeasure: Date.now(),
});
await persist();
toast.show(`${tool.name}: ${result.label}`, result.measureStatus === 2 ? 'error' : 'success');
}
/** Protokoll abschließen und synchronisieren */
async function finish() {
if (!protocol) return;
saving = true;
protocol.status = 1;
await persist();
await sync.syncNow();
saving = false;
toast.show(
sync.status === 'error' ? 'Gespeichert — Sync folgt bei Verbindung' : 'Abgeschlossen & synchronisiert',
sync.status === 'error' ? 'info' : 'success',
);
}
async function removeProtocol() {
if (!protocol) return;
if (!confirm('Dieses Protokoll wirklich löschen?')) return;
await deleteProtocol(protocol.clientUuid);
await sync.refreshPending();
goto('/auftraege/');
}
function measurementsFor(deviceClientId: string) {
return protocol?.measurements.filter((m) => m.deviceClientId === deviceClientId) ?? [];
}
function protocolMeasurements() {
return protocol?.measurements.filter((m) => !m.deviceClientId) ?? [];
}
function resultText(r: Record<string, unknown>): string {
return Object.entries(r)
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`)
.join(' · ');
}
</script>
{#if protocol}
<AppHeader
title={protocol.label}
subtitle={protocol.socName || protocol.orderRef || 'Diagnose'}
back
/>
<div class="flex-1 overflow-y-auto pb-24">
<!-- Stammdaten -->
<section class="flex flex-col gap-2 border-b border-zinc-800 p-3">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Standort
<input
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
bind:value={protocol.location}
onblur={persist}
placeholder="Gebäude / Raum"
/>
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Netzbereich
<input
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
bind:value={protocol.subnet}
onblur={persist}
placeholder="192.168.1.0/24"
/>
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Notiz
<textarea
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
rows="2"
bind:value={protocol.note}
onblur={persist}
></textarea>
</label>
</section>
<!-- Werkzeuge -->
<section class="p-3">
<h2 class="mb-2 text-sm font-semibold text-zinc-300">Werkzeuge</h2>
<div class="grid grid-cols-2 gap-2">
{#each protocolTools as tool (tool.id)}
{@const IconC = icon(tool.icon)}
<button
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 text-left active:bg-zinc-700"
onclick={() => openTool(tool)}
>
<IconC size={20} class="text-sky-400" />
<span class="text-sm font-medium">{tool.name}</span>
<span class="text-[11px] leading-tight text-zinc-500">{tool.description}</span>
</button>
{/each}
</div>
</section>
<!-- Protokoll-Messungen -->
{#if protocolMeasurements().length > 0}
<section class="px-3 pb-3">
<h2 class="mb-2 text-sm font-semibold text-zinc-300">Messungen</h2>
{#each protocolMeasurements() as m (m.clientId)}
<div class="mb-1.5 rounded-lg bg-zinc-900 p-2.5">
<div class="flex items-center gap-2">
<span class="h-2.5 w-2.5 shrink-0 rounded-full {ampelDot[m.measureStatus]}"></span>
<span class="text-sm font-medium">{getTool(m.tool)?.name ?? m.tool}</span>
</div>
<p class="mt-1 text-xs {ampel[m.measureStatus]}">{m.label}</p>
<p class="mt-0.5 break-words text-[11px] text-zinc-500">{resultText(m.result)}</p>
</div>
{/each}
</section>
{/if}
<!-- Geräte -->
<section class="px-3 pb-3">
<h2 class="mb-2 text-sm font-semibold text-zinc-300">
Geräte ({protocol.devices.length})
</h2>
{#if protocol.devices.length === 0}
<p class="text-xs text-zinc-500">
Noch keine Geräte — IP-Scanner ausführen, um das Netz zu erfassen.
</p>
{/if}
{#each protocol.devices as device (device.clientId)}
<div class="mb-2 rounded-lg bg-zinc-900 p-3">
<div class="flex items-baseline justify-between">
<span class="font-medium">{device.ip}</span>
<span class="text-xs text-zinc-500">{device.vendor ?? ''}</span>
</div>
<div class="text-xs text-zinc-500">
{device.hostname ?? ''}{device.mac ? ' · ' + device.mac : ''}
</div>
{#each measurementsFor(device.clientId) as m (m.clientId)}
<div class="mt-1.5 flex items-start gap-2 border-t border-zinc-800 pt-1.5">
<span class="mt-1 h-2 w-2 shrink-0 rounded-full {ampelDot[m.measureStatus]}"></span>
<div class="min-w-0">
<p class="text-xs {ampel[m.measureStatus]}">{m.label}</p>
<p class="break-words text-[11px] text-zinc-500">{resultText(m.result)}</p>
</div>
</div>
{/each}
<div class="mt-2 flex flex-wrap gap-1.5">
{#each deviceTools as tool (tool.id)}
<button
class="rounded bg-zinc-800 px-2 py-1 text-xs text-sky-300 active:bg-zinc-700"
onclick={() => openTool(tool, device)}
>
{tool.name}
</button>
{/each}
</div>
</div>
{/each}
</section>
<div class="px-3">
<button class="text-xs text-red-400 underline" onclick={removeProtocol}>
Protokoll löschen
</button>
</div>
</div>
<!-- Abschluss-Leiste -->
<div class="fixed inset-x-0 bottom-0 border-t border-zinc-800 bg-zinc-900 p-3 safe-bottom">
<button
class="w-full rounded-lg bg-emerald-600 py-2.5 font-semibold text-white active:bg-emerald-700 disabled:opacity-50"
onclick={finish}
disabled={saving}
>
{protocol.status === 1 ? 'Erneut synchronisieren' : 'Abschließen & synchronisieren'}
</button>
</div>
{#if activeTool}
<ToolDialog
tool={activeTool}
{protocol}
device={activeDevice}
onclose={() => (activeTool = null)}
onrun={runTool}
/>
{/if}
{:else}
<div class="flex min-h-screen items-center justify-center text-zinc-500">Lädt …</div>
{/if}

16
svelte.config.js Normal file
View file

@ -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;

15
tsconfig.json Normal file
View file

@ -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"
}
}

21
vite.config.ts Normal file
View file

@ -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 },
});