Beim Wiederholungsbesuch die erste Frage des Kunden ("da ist doch nichts
dazugekommen, oder?") und die einzige, die ein einzelnes Protokoll nicht
beantworten kann. Das Werkzeug holt das juengste fruehere Protokoll desselben
Kunden vom Server und vergleicht die Geraetelisten.
KEINE Wiedererkennung ueber MAC: /proc/net/arp ist ab Android 10 gesperrt, im
ausgelieferten Protokoll ND2026-0015 hatten ALLE 68 Geraete keine MAC. Ein
MAC-Diff haette dort bei jedem Besuch "alles neu, alles verschwunden"
gemeldet - schlimmer als gar keine Aussage, weil es im Kundendokument nach
einem umgebauten Netz aussieht. Stattdessen eine Kaskade: MAC (falls
ausnahmsweise da), mDNS-Name, NetBIOS-Name, Hostname, IP+Portmuster. Jede
Zeile nennt, worueber sie zugeordnet wurde - "erkannt ueber IP und Portmuster"
ist eine schwaechere Aussage als "ueber Netzwerkname".
Im Emulator gegen echte Serverdaten geprueft. Dabei ein Fehler gefunden und
behoben: dieselbe Adresse stand gleichzeitig unter "neu" und "nicht mehr
erreichbar", weil sich nur das Portmuster geaendert hatte. Formal richtig, im
Kundendokument aber Unsinn - liest sich wie ein kaputtes Werkzeug. Solche
Faelle haben jetzt eine eigene Kategorie "Gleiche Adresse, andere offene
Ports", die sogar der interessantere Befund ist.
Bewertung bewusst ohne Rot: ein neues Geraet ist erst einmal eine Tatsache,
womoeglich hat der Techniker es selbst angeschlossen. Gelb nur, wenn etwas
fehlt oder die Zuordnung wackelt (ab 50 % schwache Zuordnung).
tools/pruefe-geraetediff.mjs: 20 Faelle, alle mit Geraeten OHNE MAC - so wie
sie beim Kunden ankommen. Der Browser-Mock gibt jedem Geraet eine MAC, dort
saehe auch ein naiver MAC-Diff perfekt aus.
Ausserdem: Server-Adresse wird jetzt geprueft. Eine http://-Adresse wird
abgewiesen, solange die App selbst ueber https laeuft - sie wuerde sonst stumm
scheitern ("Keine Verbindung zum Server", wahrer Grund nur im logcat). Ein
Debug-Build auf http://localhost darf HTTP dagegen, sonst waere der Testbau
unbenutzbar. tools/pruefe-serveradresse.mjs: 11 Faelle.
Neu in api.ts: getProtocol() laedt ein Protokoll samt Geraeten vom Server -
nur lesend, das fruehere Protokoll wird nie ueberschrieben.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
303 lines
9.3 KiB
TypeScript
303 lines
9.3 KiB
TypeScript
/**
|
|
* 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)}`);
|
|
}
|
|
|
|
/** Kurzform eines Protokolls, wie sie in der Kundenübersicht steht */
|
|
export interface ProtocolListItem {
|
|
id: number;
|
|
ref?: string;
|
|
label?: string;
|
|
clientUuid?: string;
|
|
socId?: number | null;
|
|
orderId?: number | null;
|
|
/** Unix-Sekunden (nicht Millisekunden — so liefert es die API) */
|
|
dateDiag?: number;
|
|
location?: string;
|
|
subnet?: string;
|
|
status?: number;
|
|
deviceCount?: number;
|
|
/** heißt serverseitig `measureCount` — nicht `measurementCount` (netdiag_api.lib.php) */
|
|
measureCount?: number;
|
|
}
|
|
|
|
/** Einzelnen Kunden mit Aufträgen und Protokollen laden */
|
|
export function getCustomer(
|
|
id: number,
|
|
): Promise<{ customer: Customer; orders: Order[]; protocols: ProtocolListItem[] }> {
|
|
return request(`customers.php?id=${id}`);
|
|
}
|
|
|
|
/** Ein Gerät, wie es der Server im Protokoll liefert */
|
|
export interface ServerDevice {
|
|
id: number;
|
|
ip: string;
|
|
mac?: string | null;
|
|
hostname?: string | null;
|
|
netbiosName?: string | null;
|
|
mdnsName?: string | null;
|
|
mdnsServices?: string[];
|
|
customName?: string | null;
|
|
vendor?: string | null;
|
|
deviceType?: string | null;
|
|
openPorts?: number[];
|
|
foundVia?: string | null;
|
|
lastSeen?: number | null;
|
|
note?: string | null;
|
|
}
|
|
|
|
/**
|
|
* Ein Protokoll vom Server laden — samt Geräten und Messungen.
|
|
*
|
|
* Gebraucht vom Geräte-Vergleich, der die Liste des letzten Besuchs braucht.
|
|
* Bewusst NICHT in den lokalen Bestand übernommen: das frühere Protokoll wird
|
|
* nur gelesen, nie überschrieben.
|
|
*/
|
|
export function getProtocol(
|
|
id: number,
|
|
): Promise<{ protocol: Record<string, unknown>; devices: ServerDevice[]; measurements: unknown[] }> {
|
|
return request(`protocols.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 ?? '',
|
|
// Diese Merkmale ermittelt der IP-Scan, sie blieben aber bisher auf
|
|
// dem Gerät: im Kundenprotokoll stand nur die IP, während der
|
|
// Techniker in der App „Drucker HP, Port 9100" sah.
|
|
netbiosName: d.netbiosName ?? '',
|
|
mdnsName: d.mdnsName ?? '',
|
|
mdnsServices: d.mdnsServices ?? [],
|
|
customName: d.customName ?? '',
|
|
openPorts: d.openPorts ?? [],
|
|
foundVia: d.foundVia ?? '',
|
|
lastSeen: d.lastSeen ?? null,
|
|
})),
|
|
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)}`;
|
|
}
|
|
|
|
export interface DebugLogUpload {
|
|
entries: { ts: number; level: string; msg: string }[];
|
|
appVersion: string;
|
|
device: string;
|
|
}
|
|
|
|
/** Debug-Log-Einträge zum Server übertragen (Endpoint applog.php) */
|
|
export function uploadDebugLog(payload: DebugLogUpload): Promise<{ ok: boolean; stored: number }> {
|
|
return request<{ ok: boolean; stored: number }>('applog.php', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Neueste verfügbare App-Version erfragen. Der Server (update.php) prüft die
|
|
* Registry — die App muss die private Registry nicht selbst erreichen.
|
|
*/
|
|
export function checkAppUpdate(): Promise<{ version: string | null }> {
|
|
return request<{ version: string | null }>('update.php');
|
|
}
|
|
|
|
/** Authentifizierte Download-URL der aktuellsten APK (über den Server-Proxy) */
|
|
export function updateDownloadUrl(): string {
|
|
return `${serverUrl}${API_PATH}/update.php?download=1&jwt=${encodeURIComponent(token)}`;
|
|
}
|