Phase 6: Lesbarkeit und Bedienung — Ampel mit Form, Klartext-Werte, Toasts
Ein Befund davon ist ein Fehler aus Phase 4/5, den erst die Bestandsprüfung zutage gebracht hat: - MeasurementResult konnte nur Skalare und Textlisten. Die WLAN-Kanalmessung besteht aber aus verschachtelten Objekten (eigenesNetz, netze, empfehlung24GHz) — auf dem Handy stand dort "[object Object]", während dieselbe Messung in Dolibarr als saubere Tabelle aussah. Jetzt: "Eigenes Netz: FRITZ!Box 7590 SM · Kanal 6 · 2.4 GHz · -50 dBm · sehr gut". Weiter: - Ampel mit Form UND Zeichen statt nur Farbe (AmpelBadge.svelte): Kreis ✓, Dreieck !, Quadrat ✕, Raute ?. Rot-Grün-Sehschwäche betrifft rund 9 % der Männer, und im Sonnenlicht sind gesättigte Farben auf dem Handy ohnehin kaum zu unterscheiden. Ersetzt drei duplizierte Farbpunkt-Stellen. - Zeitstempel je Messung — bei mehreren Läufen desselben Werkzeugs war sonst nicht erkennbar, welche von wann ist. - messfelder.ts: deutsche Bezeichnungen mit Einheiten, Gegenstück zu netdiagKundenfelder() im Modul. Aus "verlustProzent: 0 · minMs: 4.4" wird "Paketverlust: 0 % · Kürzeste Antwortzeit: 4.4 ms". Bewusst KEINE Whitelist wie im Kundendokument — die App ist die Technikeransicht, unbekannte Schlüssel werden lesbar gemacht statt ausgeblendet. - Werkzeug-Klarnamen auch in der App: Messungen aus eigenen Routen (WLAN-Kanalanalyse, Monitor, IP-Test) haben keine Registry-ID, dort stand die rohe ID "wifikanal". - Fehler-Toasts bleiben 12 s statt 3 s stehen, erscheinen unten im Daumenbereich statt oben am Notch und lassen sich zum Schließen antippen. Sie weichen einer festen Aktionsleiste aus, statt den "Abschließen"-Knopf zu verdecken. - Kontrast: Messwerte von text-zinc-500 (3,67:1, unter WCAG-Minimum) auf text-zinc-400 (6,91:1), 11 -> 12 px, Zahlen mit tabular-nums. - setKeepAwake (nativ, FLAG_KEEP_SCREEN_ON): Bildschirm bleibt an, solange eine Messung läuft. Der WakeLock aus Phase 1 hält nur die CPU wach — das Display ging trotzdem aus, und man musste beim Zusehen ständig antippen. Im Emulator geprüft: WLAN-Kanalmessung wird vollständig lesbar dargestellt (Kennzahlen, Kanalempfehlung, Hinweise, Netzliste), Ampel als Kreis mit Haken, Zeitstempel, Klarname.
This commit is contained in:
parent
c98f2b1152
commit
69a4c50711
10 changed files with 416 additions and 21 deletions
|
|
@ -1007,6 +1007,36 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildschirm anlassen, solange eine Messung läuft.
|
||||||
|
*
|
||||||
|
* Der in Phase 1 eingeführte PARTIAL_WAKE_LOCK hält nur die CPU wach —
|
||||||
|
* der Bildschirm geht trotzdem aus. Für einen Dauertest ist das richtig
|
||||||
|
* (er läuft im Vordergrunddienst weiter), aber während der Techniker einem
|
||||||
|
* laufenden Scan zusieht, ist ein ausgehendes Display schlicht lästig: er
|
||||||
|
* muss das Handy alle 30 Sekunden antippen, oft mit Handschuhen.
|
||||||
|
*
|
||||||
|
* `FLAG_KEEP_SCREEN_ON` ist dafür der richtige Weg (kein zusätzliches
|
||||||
|
* Recht nötig, Android räumt das Flag beim Beenden der Activity selbst
|
||||||
|
* ab). Muss im UI-Thread gesetzt werden.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun setKeepAwake(call: PluginCall) {
|
||||||
|
val an = call.getBoolean("on") ?: false
|
||||||
|
try {
|
||||||
|
activity.runOnUiThread {
|
||||||
|
if (an) {
|
||||||
|
activity.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
} else {
|
||||||
|
activity.window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(call, JSObject().put("on", an))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
call.reject("setKeepAwake: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Öffnet die System-Ortungseinstellungen (für den Klartext-Hinweis bei `isLocationEnabled()==false`). */
|
/** Öffnet die System-Ortungseinstellungen (für den Klartext-Hinweis bei `isLocationEnabled()==false`). */
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun openLocationSettings(call: PluginCall) {
|
fun openLocationSettings(call: PluginCall) {
|
||||||
|
|
|
||||||
64
src/lib/components/AmpelBadge.svelte
Normal file
64
src/lib/components/AmpelBadge.svelte
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Ampel-Anzeige einer Messung — mit Form UND Zeichen, nicht nur Farbe.
|
||||||
|
*
|
||||||
|
* Warum: Rot-Grün-Sehschwäche betrifft rund 9 % der Männer, und auf einer
|
||||||
|
* Baustelle im Sonnenlicht sind gesättigte Farben auf dem Handy ohnehin
|
||||||
|
* kaum auseinanderzuhalten. Vorher unterschieden sich die vier Zustände
|
||||||
|
* ausschließlich durch die Füllfarbe eines Punktes — bei einem Ausdruck in
|
||||||
|
* Graustufen (Kunde druckt das PDF) fiel der Unterschied ganz weg.
|
||||||
|
*
|
||||||
|
* Deshalb bekommt jeder Zustand zusätzlich eine eigene Form und ein Zeichen:
|
||||||
|
* ok Kreis ✓
|
||||||
|
* Warnung Dreieck !
|
||||||
|
* Fehler Quadrat ✕
|
||||||
|
* nicht messbar Raute ?
|
||||||
|
*/
|
||||||
|
import type { MeasureStatus } from '$lib/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
status,
|
||||||
|
text = false,
|
||||||
|
size = 14,
|
||||||
|
}: {
|
||||||
|
status: MeasureStatus;
|
||||||
|
/** zusätzlich den Klartext danebenstellen */
|
||||||
|
text?: boolean;
|
||||||
|
size?: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const STUFEN = [
|
||||||
|
{ label: 'OK', zeichen: '✓', farbe: 'text-emerald-400', form: 'kreis' },
|
||||||
|
{ label: 'Warnung', zeichen: '!', farbe: 'text-amber-300', form: 'dreieck' },
|
||||||
|
{ label: 'Fehler', zeichen: '✕', farbe: 'text-red-400', form: 'quadrat' },
|
||||||
|
{ label: 'Nicht messbar', zeichen: '?', farbe: 'text-zinc-300', form: 'raute' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const s = $derived(STUFEN[status] ?? STUFEN[1]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="inline-flex shrink-0 items-center gap-1 {s.farbe}" title={s.label}>
|
||||||
|
<svg width={size} height={size} viewBox="0 0 16 16" role="img" aria-label={s.label}>
|
||||||
|
{#if s.form === 'kreis'}
|
||||||
|
<circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
{:else if s.form === 'dreieck'}
|
||||||
|
<path d="M8 1.5 L15 14 L1 14 Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
|
||||||
|
{:else if s.form === 'quadrat'}
|
||||||
|
<rect x="1.5" y="1.5" width="13" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
{:else}
|
||||||
|
<path d="M8 1 L15 8 L8 15 L1 8 Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
|
||||||
|
{/if}
|
||||||
|
<text
|
||||||
|
x="8"
|
||||||
|
y="11.5"
|
||||||
|
text-anchor="middle"
|
||||||
|
fill="currentColor"
|
||||||
|
style="font-size: 8px; font-weight: 700"
|
||||||
|
>
|
||||||
|
{s.zeichen}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
{#if text}
|
||||||
|
<span class="text-xs font-medium">{s.label}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
*/
|
*/
|
||||||
import { Star, Pencil } from 'lucide-svelte';
|
import { Star, Pencil } from 'lucide-svelte';
|
||||||
import MeasurementResult from './MeasurementResult.svelte';
|
import MeasurementResult from './MeasurementResult.svelte';
|
||||||
|
import AmpelBadge from './AmpelBadge.svelte';
|
||||||
import type { Device, Measurement } from '$lib/types';
|
import type { Device, Measurement } from '$lib/types';
|
||||||
import type { Tool } from '$lib/tools/types';
|
import type { Tool } from '$lib/tools/types';
|
||||||
|
|
||||||
|
|
@ -122,7 +123,7 @@
|
||||||
|
|
||||||
{#each measurements as m (m.clientId)}
|
{#each measurements as m (m.clientId)}
|
||||||
<div class="mt-1.5 flex items-start gap-2 border-t border-zinc-800 pt-1.5">
|
<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>
|
<span class="mt-0.5"><AmpelBadge status={m.measureStatus} size={12} /></span>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-xs {ampel[m.measureStatus]}">{m.label}</p>
|
<p class="text-xs {ampel[m.measureStatus]}">{m.label}</p>
|
||||||
<MeasurementResult result={m.result} />
|
<MeasurementResult result={m.result} />
|
||||||
|
|
|
||||||
|
|
@ -2,44 +2,73 @@
|
||||||
/**
|
/**
|
||||||
* Stellt das `result`-Objekt einer Messung lesbar dar.
|
* Stellt das `result`-Objekt einer Messung lesbar dar.
|
||||||
*
|
*
|
||||||
* Skalare Werte (Latenz, Anzahl, …) kommen kompakt in eine Zeile. Arrays
|
* Drei Fälle, weil die Werkzeuge alle drei liefern:
|
||||||
* — WLAN-Netze, Traceroute-Hops — werden als echte, zeilenweise Liste
|
* - Skalare (Latenz, Anzahl) → kompakte Zeile mit Klartext und Einheit
|
||||||
* dargestellt statt in eine endlose Komma-Zeile gequetscht.
|
* - Arrays → zeilenweise Liste, nicht in eine Komma-Wurst gequetscht
|
||||||
|
* - Objekte / Arrays von Objekten → je Eintrag eine verdichtete Zeile
|
||||||
|
*
|
||||||
|
* Der dritte Fall fehlte: `String(v)` machte aus jedem Objekt
|
||||||
|
* „[object Object]". Die WLAN-Kanalmessung besteht genau daraus
|
||||||
|
* (`eigenesNetz`, `netze`, `empfehlung24GHz`) und war auf dem Handy damit
|
||||||
|
* unlesbar — während sie in Dolibarr als Tabelle sauber aussah.
|
||||||
|
*
|
||||||
|
* Die Bezeichnungen kommen aus `messfelder.ts`, dem Gegenstück zur
|
||||||
|
* Feldtabelle des Dolibarr-Moduls. Anders als dort ist hier nichts
|
||||||
|
* ausgeblendet: die App ist die Technikeransicht.
|
||||||
*/
|
*/
|
||||||
|
import { feldLabel, feldWert, objektZeile } from '$lib/messfelder';
|
||||||
|
|
||||||
let { result }: { result: Record<string, unknown> } = $props();
|
let { result }: { result: Record<string, unknown> } = $props();
|
||||||
|
|
||||||
/** Leere Werte ('', null, undefined, leeres Array) ausblenden */
|
|
||||||
function isEmpty(v: unknown): boolean {
|
function isEmpty(v: unknown): boolean {
|
||||||
if (v == null || v === '') return true;
|
if (v == null || v === '') return true;
|
||||||
if (Array.isArray(v)) return v.length === 0;
|
if (Array.isArray(v)) return v.length === 0;
|
||||||
|
if (typeof v === 'object') return Object.keys(v as object).length === 0;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = $derived(Object.entries(result).filter(([, v]) => !isEmpty(v)));
|
const entries = $derived(Object.entries(result).filter(([, v]) => !isEmpty(v)));
|
||||||
|
|
||||||
/** Skalare als eine kompakte „schlüssel: wert · schlüssel: wert"-Zeile */
|
const istObjekt = (v: unknown) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
|
/** Skalare als eine kompakte Zeile */
|
||||||
const scalarText = $derived(
|
const scalarText = $derived(
|
||||||
entries
|
entries
|
||||||
.filter(([, v]) => !Array.isArray(v))
|
.filter(([, v]) => !Array.isArray(v) && !istObjekt(v))
|
||||||
.map(([k, v]) => `${k}: ${String(v)}`)
|
.map(([k, v]) => `${feldLabel(k)}: ${feldWert(k, v)}`)
|
||||||
.join(' · '),
|
.join(' · '),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Array-Werte je als eigene Liste */
|
/** Einzelne Objekte (z.B. „eigenesNetz") als eine verdichtete Zeile */
|
||||||
|
const objekte = $derived(
|
||||||
|
entries.filter(([, v]) => istObjekt(v)) as [string, Record<string, unknown>][],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Arrays — Elemente können Text ODER Objekte sein */
|
||||||
const lists = $derived(entries.filter(([, v]) => Array.isArray(v)) as [string, unknown[]][]);
|
const lists = $derived(entries.filter(([, v]) => Array.isArray(v)) as [string, unknown[]][]);
|
||||||
|
|
||||||
|
function zeile(item: unknown): string {
|
||||||
|
return istObjekt(item) ? objektZeile(item as Record<string, unknown>) : String(item);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if scalarText}
|
{#if scalarText}
|
||||||
<p class="break-words text-[11px] text-zinc-500">{scalarText}</p>
|
<p class="break-words text-xs text-zinc-400 tabular-nums">{scalarText}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#each objekte as [key, obj] (key)}
|
||||||
|
<p class="mt-0.5 break-words text-xs text-zinc-400">
|
||||||
|
<span class="text-zinc-500">{feldLabel(key)}:</span>
|
||||||
|
{objektZeile(obj)}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
|
||||||
{#each lists as [key, items] (key)}
|
{#each lists as [key, items] (key)}
|
||||||
<div class="mt-0.5">
|
<div class="mt-0.5">
|
||||||
<span class="text-[11px] text-zinc-600">{key}:</span>
|
<span class="text-xs text-zinc-500">{feldLabel(key)}:</span>
|
||||||
<ul class="mt-0.5 flex flex-col gap-0.5">
|
<ul class="mt-0.5 flex flex-col gap-0.5">
|
||||||
{#each items as item, i (i)}
|
{#each items as item, i (i)}
|
||||||
<li class="break-words pl-2 text-[11px] text-zinc-400">{String(item)}</li>
|
<li class="break-words pl-2 text-xs text-zinc-400">{zeile(item)}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,30 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Kurzmeldungen.
|
||||||
|
*
|
||||||
|
* Bewusst UNTEN statt oben: auf einem heutigen Handy ist der obere Rand
|
||||||
|
* einhändig nicht erreichbar, und genau dort liegen Statusleiste und
|
||||||
|
* Kamera-Aussparung. Eine Fehlermeldung, die man wegtippen möchte, muss im
|
||||||
|
* Daumenbereich liegen.
|
||||||
|
*
|
||||||
|
* Fehler bleiben deutlich länger stehen (siehe toast.svelte.ts) und lassen
|
||||||
|
* sich antippen, um sie sofort zu schließen — deshalb `pointer-events-auto`
|
||||||
|
* auf der Meldung selbst, während der Container durchlässig bleibt.
|
||||||
|
*/
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
|
import { X } from 'lucide-svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstand nach unten. Seiten mit fester Aktionsleiste (z.B. „Abschließen &
|
||||||
|
* synchronisieren" auf der Protokollseite) markieren diese mit
|
||||||
|
* `style="--leiste: 1"`; die Meldung rückt dann darüber, statt genau den
|
||||||
|
* Knopf zu verdecken, den man als Nächstes drücken will.
|
||||||
|
*/
|
||||||
|
let abstandUnten = $state(16);
|
||||||
|
$effect(() => {
|
||||||
|
const leiste = document.querySelector('[style*="--leiste"]');
|
||||||
|
abstandUnten = leiste ? Math.round(leiste.getBoundingClientRect().height) + 12 : 16;
|
||||||
|
});
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
info: 'bg-zinc-700',
|
info: 'bg-zinc-700',
|
||||||
|
|
@ -8,10 +33,21 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="pointer-events-none fixed inset-x-0 top-0 z-50 flex flex-col items-center gap-2 px-3 pb-3 safe-top">
|
<div
|
||||||
|
class="pointer-events-none fixed inset-x-0 z-50 flex flex-col items-center gap-2 px-3"
|
||||||
|
style="bottom: calc(env(safe-area-inset-bottom, 0px) + {abstandUnten}px)"
|
||||||
|
>
|
||||||
{#each toast.items as item (item.id)}
|
{#each toast.items as item (item.id)}
|
||||||
<div class="rounded-lg px-4 py-2 text-sm text-white shadow-lg {colors[item.type]}">
|
<button
|
||||||
{item.text}
|
class="pointer-events-auto flex max-w-full items-center gap-2 rounded-lg px-4 py-2.5 text-left text-sm text-white shadow-lg {colors[
|
||||||
</div>
|
item.type
|
||||||
|
]}"
|
||||||
|
onclick={() => toast.dismiss(item.id)}
|
||||||
|
>
|
||||||
|
<span class="min-w-0">{item.text}</span>
|
||||||
|
{#if item.type === 'error'}
|
||||||
|
<X size={14} class="shrink-0 opacity-70" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import type { Tool, ToolPreview } from '$lib/tools/types';
|
import type { Tool, ToolPreview } from '$lib/tools/types';
|
||||||
import type { Device, Protocol } from '$lib/types';
|
import type { Device, Protocol } from '$lib/types';
|
||||||
import { debugLog } from '$lib/debuglog.svelte';
|
import { debugLog } from '$lib/debuglog.svelte';
|
||||||
|
import { scanner } from '$lib/scanner';
|
||||||
import { X } from 'lucide-svelte';
|
import { X } from 'lucide-svelte';
|
||||||
|
|
||||||
type LiveProgress = { done: number; total: number; found: number; foundIps?: string[] };
|
type LiveProgress = { done: number; total: number; found: number; foundIps?: string[] };
|
||||||
|
|
@ -78,6 +79,9 @@
|
||||||
progress = tool.supportsProgress ? { done: 0, total: 0, found: 0 } : null;
|
progress = tool.supportsProgress ? { done: 0, total: 0, found: 0 } : null;
|
||||||
if (tool.supportsProgress) step = 'running';
|
if (tool.supportsProgress) step = 'running';
|
||||||
busy = true;
|
busy = true;
|
||||||
|
// Bildschirm anlassen, solange die Messung läuft — sonst geht das Display
|
||||||
|
// mitten im Scan aus und der Techniker muss es wieder aufwecken.
|
||||||
|
void scanner.setKeepAwake({ on: true }).catch(() => {});
|
||||||
try {
|
try {
|
||||||
await onrun(
|
await onrun(
|
||||||
{ ...params },
|
{ ...params },
|
||||||
|
|
@ -99,6 +103,7 @@
|
||||||
step = 'params';
|
step = 'params';
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
|
void scanner.setKeepAwake({ on: false }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
185
src/lib/messfelder.ts
Normal file
185
src/lib/messfelder.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
/**
|
||||||
|
* Klartext-Bezeichnungen und Einheiten für Messergebnis-Felder.
|
||||||
|
*
|
||||||
|
* Gegenstück zu `netdiagKundenfelder()` im Dolibarr-Modul
|
||||||
|
* (dolibarr-modul/netdiag/lib/netdiag.lib.php). **Wird dort ein Feld ergänzt,
|
||||||
|
* gehört es auch hierher** — und umgekehrt.
|
||||||
|
*
|
||||||
|
* Ein wichtiger Unterschied zum Modul: dort ist die Tabelle eine WHITELIST
|
||||||
|
* (was nicht drinsteht, erscheint nicht im Kundendokument). Hier nicht — die
|
||||||
|
* App ist die Technikeransicht, dort darf kein Messwert verschwinden.
|
||||||
|
* Unbekannte Schlüssel werden lesbar aufbereitet statt weggelassen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Feld = { label: string; einheit?: string };
|
||||||
|
|
||||||
|
const FELDER: Record<string, Feld> = {
|
||||||
|
// gemeinsam
|
||||||
|
host: { label: 'Ziel' },
|
||||||
|
ziel: { label: 'Ziel' },
|
||||||
|
subnet: { label: 'Netzbereich' },
|
||||||
|
count: { label: 'Gefundene Geräte' },
|
||||||
|
ip: { label: 'Gerät' },
|
||||||
|
// Ping / Laufzeit
|
||||||
|
gesendet: { label: 'Gesendet', einheit: 'Pakete' },
|
||||||
|
empfangen: { label: 'Empfangen', einheit: 'Pakete' },
|
||||||
|
verlustProzent: { label: 'Paketverlust', einheit: '%' },
|
||||||
|
minMs: { label: 'Kürzeste Antwortzeit', einheit: 'ms' },
|
||||||
|
avgMs: { label: 'Mittlere Antwortzeit', einheit: 'ms' },
|
||||||
|
medianMs: { label: 'Typische Antwortzeit', einheit: 'ms' },
|
||||||
|
maxMs: { label: 'Längste Antwortzeit', einheit: 'ms' },
|
||||||
|
p95Ms: { label: 'Antwortzeit (95 %)', einheit: 'ms' },
|
||||||
|
jitterMs: { label: 'Schwankung', einheit: 'ms' },
|
||||||
|
messaufschlagMs: { label: 'Messaufschlag', einheit: 'ms' },
|
||||||
|
verfahren: { label: 'Messverfahren' },
|
||||||
|
// Durchsatz
|
||||||
|
downMbps: { label: 'Download', einheit: 'Mbit/s' },
|
||||||
|
upMbps: { label: 'Upload', einheit: 'Mbit/s' },
|
||||||
|
downloadMbps: { label: 'Download', einheit: 'Mbit/s' },
|
||||||
|
uploadMbps: { label: 'Upload', einheit: 'Mbit/s' },
|
||||||
|
gegenstelle: { label: 'Gegenstelle' },
|
||||||
|
// Dauertest
|
||||||
|
dauerSekunden: { label: 'Messdauer', einheit: 's' },
|
||||||
|
intervallSek: { label: 'Messabstand', einheit: 's' },
|
||||||
|
laengsterAusfallSek: { label: 'Längster Ausfall', einheit: 's' },
|
||||||
|
// Netz / DHCP / Dose
|
||||||
|
server: { label: 'DHCP-Server' },
|
||||||
|
dhcpServer: { label: 'DHCP-Server' },
|
||||||
|
lease: { label: 'Lease-Dauer', einheit: 's' },
|
||||||
|
leaseSec: { label: 'Lease-Dauer', einheit: 's' },
|
||||||
|
gateway: { label: 'Gateway' },
|
||||||
|
dns: { label: 'DNS-Server' },
|
||||||
|
ipv4: { label: 'IP-Adresse' },
|
||||||
|
prefixLength: { label: 'Präfixlänge' },
|
||||||
|
linkSpeedMbps: { label: 'Verbindungsgeschwindigkeit', einheit: 'Mbit/s' },
|
||||||
|
rxLinkSpeedMbps: { label: 'Empfangsgeschwindigkeit', einheit: 'Mbit/s' },
|
||||||
|
raum: { label: 'Raum / Dose' },
|
||||||
|
iface: { label: 'Schnittstelle' },
|
||||||
|
// Ports / Konflikte
|
||||||
|
scanned: { label: 'Geprüfte Ports' },
|
||||||
|
open: { label: 'Offene Ports' },
|
||||||
|
geprueft: { label: 'Geprüfte Adressen' },
|
||||||
|
runden: { label: 'Messrunden' },
|
||||||
|
konflikte: { label: 'IP-Konflikte' },
|
||||||
|
// SNMP
|
||||||
|
port: { label: 'Switch-Port' },
|
||||||
|
linkSpeed: { label: 'Verbindungsgeschwindigkeit' },
|
||||||
|
eingangsFehler: { label: 'Fehler eingehend' },
|
||||||
|
ausgangsFehler: { label: 'Fehler ausgehend' },
|
||||||
|
sysDescr: { label: 'Gerätebeschreibung' },
|
||||||
|
uptimeSek: { label: 'Betriebszeit', einheit: 's' },
|
||||||
|
// Traceroute
|
||||||
|
reachedTarget: { label: 'Ziel erreicht' },
|
||||||
|
zielErreicht: { label: 'Ziel erreicht' },
|
||||||
|
hops: { label: 'Zwischenstellen' },
|
||||||
|
// IP-Scan (Technikerfelder — im Kundendokument bewusst ausgeblendet)
|
||||||
|
probed: { label: 'Geprüfte Adressen' },
|
||||||
|
answered: { label: 'Haben geantwortet' },
|
||||||
|
arpAvailable: { label: 'ARP-Tabelle lesbar' },
|
||||||
|
durationMs: { label: 'Dauer', einheit: 'ms' },
|
||||||
|
abgebrochen: { label: 'Abgebrochen' },
|
||||||
|
mdnsOk: { label: 'mDNS-Suche' },
|
||||||
|
quelle: { label: 'Quelle des Netzbereichs' },
|
||||||
|
neu: { label: 'Neu hinzugekommen' },
|
||||||
|
nichtMehrErreichbar: { label: 'Nicht mehr erreichbar' },
|
||||||
|
// WLAN
|
||||||
|
anzahlNetze: { label: 'Sichtbare WLAN-Netze' },
|
||||||
|
eigenesNetz: { label: 'Eigenes Netz' },
|
||||||
|
empfehlung24GHz: { label: 'Kanalempfehlung 2,4 GHz' },
|
||||||
|
netze: { label: 'WLAN-Netze' },
|
||||||
|
warnungen: { label: 'Hinweise' },
|
||||||
|
demodaten: { label: 'DEMODATEN — keine echte Messung' },
|
||||||
|
ssid: { label: 'WLAN-Name' },
|
||||||
|
kanal: { label: 'Kanal' },
|
||||||
|
band: { label: 'Band' },
|
||||||
|
rssi: { label: 'Pegel', einheit: 'dBm' },
|
||||||
|
breiteMhz: { label: 'Kanalbreite', einheit: 'MHz' },
|
||||||
|
standard: { label: 'Standard' },
|
||||||
|
sicherheit: { label: 'Verschlüsselung' },
|
||||||
|
bewertung: { label: 'Bewertung' },
|
||||||
|
stoerlast: { label: 'Störlast' },
|
||||||
|
// Fehler / Hinweise
|
||||||
|
fehler: { label: 'Fehler' },
|
||||||
|
hinweis: { label: 'Hinweis' },
|
||||||
|
kundenhinweis: { label: 'Hinweis' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sekundenwert lesbar machen (aus 8123456 wird „94 Tage 1 Std") */
|
||||||
|
export function dauerLesbar(sek: number): string {
|
||||||
|
if (sek < 60) return `${sek} s`;
|
||||||
|
if (sek < 3600) return `${Math.round(sek / 60)} min`;
|
||||||
|
if (sek < 86400) return `${Math.floor(sek / 3600)} Std ${Math.round((sek % 3600) / 60)} min`;
|
||||||
|
return `${Math.floor(sek / 86400)} Tage ${Math.round((sek % 86400) / 3600)} Std`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feldbezeichnung. Unbekannte Schlüssel werden nicht verworfen (anders als im
|
||||||
|
* Kundendokument), sondern aus dem camelCase-Namen lesbar gemacht — so sieht
|
||||||
|
* der Techniker auch Werte neuer Werkzeuge, bevor die Tabelle gepflegt ist.
|
||||||
|
*/
|
||||||
|
export function feldLabel(key: string): string {
|
||||||
|
const f = FELDER[key];
|
||||||
|
if (f) return f.label;
|
||||||
|
const mitLuecken = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
||||||
|
return mitLuecken.charAt(0).toUpperCase() + mitLuecken.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wert mit Einheit, Sekunden lesbar, Wahrheitswerte auf Deutsch */
|
||||||
|
export function feldWert(key: string, value: unknown): string {
|
||||||
|
const f = FELDER[key];
|
||||||
|
if (typeof value === 'boolean') return value ? 'ja' : 'nein';
|
||||||
|
if (value === null || value === undefined || value === '') return '';
|
||||||
|
if (f?.einheit === 's' && typeof value === 'number' && value >= 3600) {
|
||||||
|
return dauerLesbar(value);
|
||||||
|
}
|
||||||
|
return `${value}${f?.einheit ? ' ' + f.einheit : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Objekt (z.B. ein WLAN-Netz aus der Kanalmessung) zu einer Zeile
|
||||||
|
* verdichten: „Gäste-WLAN · Kanal 6 · 2.4 GHz · 20 MHz · −71 dBm".
|
||||||
|
* Ohne das stünde dort `[object Object]`.
|
||||||
|
*/
|
||||||
|
export function objektZeile(o: Record<string, unknown>): string {
|
||||||
|
return Object.entries(o)
|
||||||
|
.filter(([, v]) => v !== null && v !== undefined && v !== '')
|
||||||
|
.map(([k, v]) => {
|
||||||
|
// Bei sprechenden Feldern (Name, Kanal, Pegel) reicht der reine Wert —
|
||||||
|
// „Kanal 6" statt „Kanal: 6" liest sich in einer Zeile flüssiger.
|
||||||
|
if (k === 'ssid' || k === 'name') return String(v);
|
||||||
|
if (k === 'kanal' || k === 'channel') return `Kanal ${v}`;
|
||||||
|
return feldWert(k, v);
|
||||||
|
})
|
||||||
|
.filter((s) => s !== '')
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Klarname eines Werkzeugs für die Anzeige.
|
||||||
|
*
|
||||||
|
* `getTool(id)` findet nur registrierte Werkzeuge. Messungen aus eigenen
|
||||||
|
* Routen (WLAN-Kanalanalyse, Geräte-Monitor, IP-Test) haben keinen Eintrag in
|
||||||
|
* der Registry — dort stand bisher die rohe ID („wifikanal"). Gegenstück zu
|
||||||
|
* `netdiagToolName()` im Dolibarr-Modul.
|
||||||
|
*/
|
||||||
|
const TOOL_NAMEN: Record<string, string> = {
|
||||||
|
ipscan: 'IP-Scanner',
|
||||||
|
portscan: 'Port-Scan',
|
||||||
|
ping: 'Ping / Qualität',
|
||||||
|
ipconflict: 'IP-Konflikt',
|
||||||
|
snmp: 'SNMP-Switch',
|
||||||
|
traceroute: 'Traceroute',
|
||||||
|
stresstest: 'Dauer-/Stresstest',
|
||||||
|
iperf: 'Durchsatz-Test',
|
||||||
|
wifikanal: 'WLAN-Kanalanalyse',
|
||||||
|
monitor: 'Geräte-Monitor',
|
||||||
|
iptest: 'IP-Test (Dose)',
|
||||||
|
wifitrack: 'WLAN-Empfang',
|
||||||
|
// nicht mehr vorhandene Werkzeuge, kommen in Altprotokollen noch vor
|
||||||
|
dhcpcheck: 'DHCP-Prüfung',
|
||||||
|
wifiscan: 'WLAN-Scan',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function werkzeugName(id: string): string {
|
||||||
|
return TOOL_NAMEN[id] ?? id;
|
||||||
|
}
|
||||||
|
|
@ -255,6 +255,12 @@ export interface NetDiagScannerPlugin {
|
||||||
}>;
|
}>;
|
||||||
/** Öffnet die System-Ortungseinstellungen — für den Hinweis, wenn WLAN-Scan wegen ausgeschalteter Ortung leer bleibt (vor Android 13) */
|
/** Öffnet die System-Ortungseinstellungen — für den Hinweis, wenn WLAN-Scan wegen ausgeschalteter Ortung leer bleibt (vor Android 13) */
|
||||||
openLocationSettings(): Promise<{ ok: boolean }>;
|
openLocationSettings(): Promise<{ ok: boolean }>;
|
||||||
|
/**
|
||||||
|
* Bildschirm anlassen, solange eine Messung läuft. Der WakeLock aus Phase 1
|
||||||
|
* hält nur die CPU wach — das Display geht trotzdem aus, und der Techniker
|
||||||
|
* müsste beim Zusehen ständig antippen.
|
||||||
|
*/
|
||||||
|
setKeepAwake(opts: { on: boolean }): Promise<{ on: boolean }>;
|
||||||
/** DHCP-Lease-Info des Geräts (Server, Lease-Dauer, Gateway, DNS) */
|
/** DHCP-Lease-Info des Geräts (Server, Lease-Dauer, Gateway, DNS) */
|
||||||
dhcpInfo(): Promise<DhcpLease>;
|
dhcpInfo(): Promise<DhcpLease>;
|
||||||
/** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */
|
/** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */
|
||||||
|
|
@ -644,6 +650,9 @@ const mock: NetDiagScannerPlugin = {
|
||||||
async openLocationSettings() {
|
async openLocationSettings() {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
|
async setKeepAwake(opts) {
|
||||||
|
return { on: opts.on };
|
||||||
|
},
|
||||||
async dhcpInfo() {
|
async dhcpInfo() {
|
||||||
return {
|
return {
|
||||||
server: '192.168.1.1',
|
server: '192.168.1.1',
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,16 @@ class ToastState {
|
||||||
private lastKey = '';
|
private lastKey = '';
|
||||||
private lastAt = 0;
|
private lastAt = 0;
|
||||||
|
|
||||||
show(text: string, type: ToastType = 'info', durationMs = 3000): void {
|
/**
|
||||||
|
* @param durationMs Anzeigedauer; ohne Angabe bleiben FEHLER deutlich länger
|
||||||
|
* stehen als Erfolgsmeldungen. Eine Fehlermeldung nach 3 s wegzublenden
|
||||||
|
* heißt in der Praxis, dass der Techniker sie verpasst, während er noch
|
||||||
|
* auf den Schaltschrank schaut — und dann nicht weiß, warum die Messung
|
||||||
|
* nichts geliefert hat. Fehler lassen sich außerdem antippen und dadurch
|
||||||
|
* sofort schließen (siehe +layout.svelte).
|
||||||
|
*/
|
||||||
|
show(text: string, type: ToastType = 'info', durationMs?: number): void {
|
||||||
|
const dauer = durationMs ?? (type === 'error' ? 12000 : 3000);
|
||||||
const key = `${type}:${text}`;
|
const key = `${type}:${text}`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Gleiche Meldung binnen 2 s nicht erneut zeigen
|
// Gleiche Meldung binnen 2 s nicht erneut zeigen
|
||||||
|
|
@ -29,7 +38,12 @@ class ToastState {
|
||||||
this.items = [...this.items, { id, text, type }];
|
this.items = [...this.items, { id, text, type }];
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.items = this.items.filter((t) => t.id !== id);
|
this.items = this.items.filter((t) => t.id !== id);
|
||||||
}, durationMs);
|
}, dauer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meldung von Hand schließen (Antippen) */
|
||||||
|
dismiss(id: number): void {
|
||||||
|
this.items = this.items.filter((t) => t.id !== id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||||
import ToolDialog from '$lib/components/ToolDialog.svelte';
|
import ToolDialog from '$lib/components/ToolDialog.svelte';
|
||||||
import MeasurementResult from '$lib/components/MeasurementResult.svelte';
|
import MeasurementResult from '$lib/components/MeasurementResult.svelte';
|
||||||
|
import AmpelBadge from '$lib/components/AmpelBadge.svelte';
|
||||||
import DeviceCard from '$lib/components/DeviceCard.svelte';
|
import DeviceCard from '$lib/components/DeviceCard.svelte';
|
||||||
import TextPromptDialog from '$lib/components/TextPromptDialog.svelte';
|
import TextPromptDialog from '$lib/components/TextPromptDialog.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
import { pushOverlay } from '$lib/overlay.svelte';
|
import { pushOverlay } from '$lib/overlay.svelte';
|
||||||
import { TOOLS, getTool } from '$lib/tools';
|
import { TOOLS, getTool } from '$lib/tools';
|
||||||
|
import { werkzeugName } from '$lib/messfelder';
|
||||||
import type { Tool } from '$lib/tools/types';
|
import type { Tool } from '$lib/tools/types';
|
||||||
import type { Device, Protocol } from '$lib/types';
|
import type { Device, Protocol } from '$lib/types';
|
||||||
import * as Icons from 'lucide-svelte';
|
import * as Icons from 'lucide-svelte';
|
||||||
|
|
@ -46,6 +48,18 @@
|
||||||
const protocolTools = TOOLS.filter((t) => t.scope === 'protocol' && t.id !== 'stresstest');
|
const protocolTools = TOOLS.filter((t) => t.scope === 'protocol' && t.id !== 'stresstest');
|
||||||
const deviceTools = TOOLS.filter((t) => t.scope === 'device');
|
const deviceTools = TOOLS.filter((t) => t.scope === 'device');
|
||||||
|
|
||||||
|
/** Uhrzeit einer Messung (Datum nur, wenn sie nicht von heute ist) */
|
||||||
|
function fmtUhrzeit(ts: number): string {
|
||||||
|
const d = new Date(ts);
|
||||||
|
const heute = new Date();
|
||||||
|
const gleicherTag =
|
||||||
|
d.getDate() === heute.getDate() &&
|
||||||
|
d.getMonth() === heute.getMonth() &&
|
||||||
|
d.getFullYear() === heute.getFullYear();
|
||||||
|
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) +
|
||||||
|
(gleicherTag ? '' : ' · ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }));
|
||||||
|
}
|
||||||
|
|
||||||
/** IP-Adressen numerisch vergleichen (nicht als Text — "…9" vor "…10") */
|
/** IP-Adressen numerisch vergleichen (nicht als Text — "…9" vor "…10") */
|
||||||
function ipCompare(a: string, b: string): number {
|
function ipCompare(a: string, b: string): number {
|
||||||
const pa = a.split('.').map(Number);
|
const pa = a.split('.').map(Number);
|
||||||
|
|
@ -381,8 +395,11 @@
|
||||||
{#each protocolMeasurements() as m (m.clientId)}
|
{#each protocolMeasurements() as m (m.clientId)}
|
||||||
<div class="mb-1.5 rounded-lg bg-zinc-900 p-2.5">
|
<div class="mb-1.5 rounded-lg bg-zinc-900 p-2.5">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="h-2.5 w-2.5 shrink-0 rounded-full {ampelDot[m.measureStatus]}"></span>
|
<AmpelBadge status={m.measureStatus} />
|
||||||
<span class="text-sm font-medium">{getTool(m.tool)?.name ?? m.tool}</span>
|
<span class="text-sm font-medium">{getTool(m.tool)?.name ?? werkzeugName(m.tool)}</span>
|
||||||
|
<!-- Zeitstempel: bei mehreren Läufen desselben Werkzeugs sonst
|
||||||
|
nicht erkennbar, welche Messung von wann ist -->
|
||||||
|
<span class="ml-auto shrink-0 text-[11px] text-zinc-500">{fmtUhrzeit(m.dateMeasure)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs {ampel[m.measureStatus]}">{m.label}</p>
|
<p class="mt-1 text-xs {ampel[m.measureStatus]}">{m.label}</p>
|
||||||
<div class="mt-0.5"><MeasurementResult result={m.result} /></div>
|
<div class="mt-0.5"><MeasurementResult result={m.result} /></div>
|
||||||
|
|
@ -467,7 +484,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Abschluss-Leiste -->
|
<!-- Abschluss-Leiste -->
|
||||||
<div class="fixed inset-x-0 bottom-0 border-t border-zinc-800 bg-zinc-900 p-3 safe-bottom">
|
<!-- Feste Aktionsleiste. Sie setzt --leiste-hoehe, damit die Kurzmeldungen
|
||||||
|
darüber erscheinen statt den Abschließen-Knopf zu verdecken. -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-x-0 bottom-0 border-t border-zinc-800 bg-zinc-900 p-3 safe-bottom"
|
||||||
|
style="--leiste: 1"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
class="w-full rounded-lg bg-emerald-600 py-2.5 font-semibold text-white active:bg-emerald-700 disabled:opacity-50"
|
class="w-full rounded-lg bg-emerald-600 py-2.5 font-semibold text-white active:bg-emerald-700 disabled:opacity-50"
|
||||||
onclick={finish}
|
onclick={finish}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue