Initiales Commit — NetDiag App vollständig implementiert [apk]
Some checks failed
Build APK / build-apk (push) Failing after 11m29s
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:
commit
bf01b4cd21
46 changed files with 3575 additions and 0 deletions
143
.forgejo/workflows/build.yml
Normal file
143
.forgejo/workflows/build.yml
Normal 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
19
.gitignore
vendored
Normal 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
73
README.md
Normal 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
17
capacitor.config.ts
Normal 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;
|
||||||
551
native-plugin/NetDiagScannerPlugin.kt
Normal file
551
native-plugin/NetDiagScannerPlugin.kt
Normal 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
72
native-plugin/README.md
Normal 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
138
native-plugin/Snmp.kt
Normal 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
39
package.json
Normal 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
35
src/app.css
Normal 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
21
src/app.d.ts
vendored
Normal 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
13
src/app.html
Normal 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
217
src/lib/api.ts
Normal 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
59
src/lib/auth.svelte.ts
Normal 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();
|
||||||
85
src/lib/backButton.svelte.ts
Normal file
85
src/lib/backButton.svelte.ts
Normal 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;
|
||||||
|
}
|
||||||
43
src/lib/components/AppHeader.svelte
Normal file
43
src/lib/components/AppHeader.svelte
Normal 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>
|
||||||
17
src/lib/components/Toast.svelte
Normal file
17
src/lib/components/Toast.svelte
Normal 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>
|
||||||
95
src/lib/components/ToolDialog.svelte
Normal file
95
src/lib/components/ToolDialog.svelte
Normal 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
97
src/lib/db.ts
Normal 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
67
src/lib/protocols.ts
Normal 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
169
src/lib/scanner.ts
Normal 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
88
src/lib/sync.svelte.ts
Normal 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
36
src/lib/toast.svelte.ts
Normal 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
55
src/lib/tools/index.ts
Normal 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';
|
||||||
55
src/lib/tools/internet/iperf.ts
Normal file
55
src/lib/tools/internet/iperf.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
35
src/lib/tools/netzwerk/dhcpcheck.ts
Normal file
35
src/lib/tools/netzwerk/dhcpcheck.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
39
src/lib/tools/netzwerk/ipscan.ts
Normal file
39
src/lib/tools/netzwerk/ipscan.ts
Normal 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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
46
src/lib/tools/netzwerk/ping.ts
Normal file
46
src/lib/tools/netzwerk/ping.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
47
src/lib/tools/netzwerk/portscan.ts
Normal file
47
src/lib/tools/netzwerk/portscan.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
59
src/lib/tools/netzwerk/snmp.ts
Normal file
59
src/lib/tools/netzwerk/snmp.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
59
src/lib/tools/netzwerk/stresstest.ts
Normal file
59
src/lib/tools/netzwerk/stresstest.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
28
src/lib/tools/netzwerk/traceroute.ts
Normal file
28
src/lib/tools/netzwerk/traceroute.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
28
src/lib/tools/netzwerk/wifiscan.ts
Normal file
28
src/lib/tools/netzwerk/wifiscan.ts
Normal 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
73
src/lib/tools/types.ts
Normal 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
94
src/lib/types.ts
Normal 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
68
src/lib/updater.ts
Normal 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
79
src/routes/+layout.svelte
Normal 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
8
src/routes/+layout.ts
Normal 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
9
src/routes/+page.svelte
Normal 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>
|
||||||
144
src/routes/auftraege/+page.svelte
Normal file
144
src/routes/auftraege/+page.svelte
Normal 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>
|
||||||
65
src/routes/einstellungen/+page.svelte
Normal file
65
src/routes/einstellungen/+page.svelte
Normal 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>
|
||||||
77
src/routes/kunden/+page.svelte
Normal file
77
src/routes/kunden/+page.svelte
Normal 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>
|
||||||
85
src/routes/login/+page.svelte
Normal file
85
src/routes/login/+page.svelte
Normal 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>
|
||||||
276
src/routes/protokoll/[id]/+page.svelte
Normal file
276
src/routes/protokoll/[id]/+page.svelte
Normal 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
16
svelte.config.js
Normal 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
15
tsconfig.json
Normal 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
21
vite.config.ts
Normal 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 },
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue