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