Compare commits

...
2 Commits
Author SHA1 Message Date
SvenandClaude Fable 5 0fa690a67d Oberfläche entschlackt: Adressdaten ohne Neu-laden und Erklärtexte, Diagnose ohne Rohnachricht
Der Adressdaten-Bereich verliert den Neu-laden-Knopf samt
Telefonbuch-Abgleich und Wartedialog – die Ablage hält sich über
Cache-Push und die Abfragen der Suche aktuell. Anrufen erscheint nur
noch bei bestehender Verbindung zur Tray-App, eine Suche ohne Treffer
zeigt nichts weiter an. Erklärtexte in Adressdaten und Tabs entfernt,
ebenso das Rohnachricht-Formular im Diagnose-Bereich.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:31:13 +02:00
SvenandClaude Fable 5 607cbaecbc Null-Safety-Warnungen der IDE behoben: Methodenreferenzen durch Lambdas ersetzt
Bei einer Methodenreferenz wird der Empfänger zum Parameter des
funktionalen Interfaces, dessen @NonNull-Annotation die Eclipse-Analyse
nicht zusichern kann; als Lambda übernimmt der Parameter die Annotation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:15:54 +02:00
7 changed files with 23 additions and 141 deletions
@@ -86,7 +86,7 @@ public class WebhookService {
@PreDestroy @PreDestroy
void shutdown() { void shutdown() {
heartbeat.shutdownNow(); heartbeat.shutdownNow();
subscribers.forEach(SseEmitter::complete); subscribers.forEach(emitter -> emitter.complete());
subscribers.clear(); subscribers.clear();
} }
@@ -359,7 +359,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
String needle = wanted.toLowerCase(); String needle = wanted.toLowerCase();
List<MockContact> hits = MOCK_CONTACTS.stream() List<MockContact> hits = MOCK_CONTACTS.stream()
.filter(contact -> contact.matches(needle)) .filter(contact -> contact.matches(needle))
.sorted(Comparator.comparing(MockContact::name).thenComparing(MockContact::number)) .sorted(Comparator.comparing((MockContact contact) -> contact.name())
.thenComparing(contact -> contact.number()))
.limit(CONTACT_RESULT_LIMIT) .limit(CONTACT_RESULT_LIMIT)
.toList(); .toList();
log.info("SwyxTray-Mock: {} Adresseintrag/-einträge zu '{}'", hits.size(), wanted); log.info("SwyxTray-Mock: {} Adresseintrag/-einträge zu '{}'", hits.size(), wanted);
@@ -58,7 +58,7 @@ class AddressEntryTests {
"type":"addresses"}"""); "type":"addresses"}""");
assertThat(entries).hasSize(2); assertThat(entries).hasSize(2);
assertThat(entries).extracting(AddressEntry::number).containsExactly("7587", "5215"); assertThat(entries).extracting(entry -> entry.number()).containsExactly("7587", "5215");
} }
/** /**
@@ -10,7 +10,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import de.appcreation.swyxweb.storage.ArchiveService; import de.appcreation.swyxweb.storage.ArchiveService;
import de.appcreation.swyxweb.webhook.WebhookEvent;
import de.appcreation.swyxweb.webhook.WebhookService; import de.appcreation.swyxweb.webhook.WebhookService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
@@ -112,7 +111,7 @@ class WebhookControllerTests {
.andExpect(status().isOk()); .andExpect(status().isOk());
assertThat(service.history()) assertThat(service.history())
.extracting(WebhookEvent::channel) .extracting(event -> event.channel())
.containsExactly("kunden", "kuriere", "jobs"); .containsExactly("kunden", "kuriere", "jobs");
// Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job. // Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job.
@@ -185,10 +185,6 @@ export default function BrowserTabsPanel({ active, disabled, busy, onList, onOpe
</div> </div>
</form> </form>
<p className="note">
Die Tabs gehören zum Firefox auf dem Rechner der SwyxTray-App. Ohne verbundenes Plugin
beantwortet die App die drei Kommandos nach etwa fünf Sekunden mit einem Fehler.
</p>
{hint && <p className="note note--error">{hint}</p>} {hint && <p className="note note--error">{hint}</p>}
{outcome && <p className="note note--success">{outcome}</p>} {outcome && <p className="note note--success">{outcome}</p>}
</> </>
+16 -103
View File
@@ -1,13 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { describeContact, type Contact } from '../swyx/protocol' import { describeContact, type Contact } from '../swyx/protocol'
import type { Directory, DirectoryProgress, SweepOptions } from '../swyx/directory'
import { fetchAddresses } from '../addresses' import { fetchAddresses } from '../addresses'
import LoadingDialog from './LoadingDialog'
interface Props { interface Props {
/** Panel ist sichtbar erst dann wird abgefragt. */ /** Panel ist sichtbar erst dann wird abgefragt. */
active: boolean active: boolean
/** Keine Verbindung zur SwyxTray-App Anrufen und Telefonbuch-Abgleich entfallen. */ /** Keine Verbindung zur SwyxTray-App Anrufen entfällt. */
disabled: boolean disabled: boolean
/** /**
* Adress-Cache, den die App unaufgefordert schickt. Er wird hier nicht * Adress-Cache, den die App unaufgefordert schickt. Er wird hier nicht
@@ -15,7 +13,6 @@ interface Props {
* hat ihn gerade hineingeschrieben. * hat ihn gerade hineingeschrieben.
*/ */
cache: Contact[] | null cache: Contact[] | null
onLoad: (options: SweepOptions) => Promise<Directory | null>
onDial: (number: string) => void onDial: (number: string) => void
} }
@@ -36,30 +33,22 @@ const SEARCH_DEBOUNCE_MS = 300
* Abfrageergebnis; der Bereich funktioniert damit auch ohne Verbindung zur * Abfrageergebnis; der Bereich funktioniert damit auch ohne Verbindung zur
* SwyxTray-App. * SwyxTray-App.
* *
* Das Telefonbuch der App fließt auf zwei Wegen in die Ablage: * Das Telefonbuch der App fließt über den **Cache-Push** beim Verbinden
* * (Protokoll 8) in die Ablage die Startseite meldet ihn ans Backend; das
* 1. Der **Cache-Push** beim Verbinden (Protokoll 8) die Startseite meldet * Panel fragt die Ablage danach neu ab.
* ihn ans Backend; das Panel fragt die Ablage danach neu ab.
* 2. „Neu laden" bei bestehender Verbindung: erst das Telefonbuch der App
* holen (Cache oder rund 110 Einzelabfragen), das Backend legt es ab,
* dann die Ablage abfragen.
*/ */
export default function ContactsPanel({ active, disabled, cache, onLoad, onDial }: Props) { export default function ContactsPanel({ active, disabled, cache, onDial }: Props) {
// Das jeweils letzte Abfrageergebnis kein Bestand, nur die Anzeige. // Das jeweils letzte Abfrageergebnis kein Bestand, nur die Anzeige.
const [results, setResults] = useState<Contact[] | null>(null) const [results, setResults] = useState<Contact[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
// Warnung aus dem letzten Telefonbuch-Abgleich: dort fehlen evtl. Einträge.
const [incomplete, setIncomplete] = useState(false)
const [progress, setProgress] = useState<DirectoryProgress | null>(null)
// Bewusst nicht gemerkt: Nach einem Neustart beginnt die Suche leer. // Bewusst nicht gemerkt: Nach einem Neustart beginnt die Suche leer.
const [filter, setFilter] = useState('') const [filter, setFilter] = useState('')
const abortRef = useRef<AbortController | null>(null)
// Zählt die Abfragen: Eine überholte darf das Ergebnis einer neueren nicht // Zählt die Abfragen: Eine überholte darf das Ergebnis einer neueren nicht
// mehr überschreiben Antworten kommen nicht zwingend in Reihenfolge. // mehr überschreiben Antworten kommen nicht zwingend in Reihenfolge.
const queryIdRef = useRef(0) const queryIdRef = useRef(0)
// Der aktuelle Suchbegriff für Abfragen außerhalb des Eingabe-Effekts // Der aktuelle Suchbegriff für Abfragen außerhalb des Eingabe-Effekts
// (Cache-Push, „Neu laden") ohne den Effekt neu anzustoßen. // (Cache-Push) ohne den Effekt neu anzustoßen.
const filterRef = useRef(filter) const filterRef = useRef(filter)
filterRef.current = filter filterRef.current = filter
@@ -89,42 +78,6 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, filter, search]) }, [active, filter, search])
/**
* „Neu laden": Bei bestehender Verbindung zuerst das Telefonbuch der App
* holen das Backend schreibt es dabei in die Ablage , danach in jedem
* Fall die Ablage frisch abfragen.
*/
const reload = useCallback(async () => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
try {
if (!disabled) {
setProgress({ done: 0, total: 0, found: 0 })
const result = await onLoad({
signal: controller.signal,
onProgress: (next) => {
if (abortRef.current === controller) setProgress(next)
},
})
if (abortRef.current !== controller) return
// null heißt: Fehler der steht bereits in der Fehleranzeige der Seite.
setIncomplete(result !== null && !result.complete)
// Das Backend schreibt nebenbei; kurz warten, damit die Abfrage den
// frischen Stand schon sieht.
await new Promise((resolve) => setTimeout(resolve, ARCHIVE_WRITE_DELAY_MS))
if (abortRef.current !== controller) return
}
await search()
} finally {
if (abortRef.current === controller) {
abortRef.current = null
setProgress(null)
}
}
}, [disabled, onLoad, search])
// Telefonbuch-Push der App: Die Startseite hat ihn ans Backend gemeldet; // Telefonbuch-Push der App: Die Startseite hat ihn ans Backend gemeldet;
// nach einer kurzen Schreibfrist die Ablage neu abfragen. // nach einer kurzen Schreibfrist die Ablage neu abfragen.
useEffect(() => { useEffect(() => {
@@ -133,25 +86,9 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [cache, search]) }, [cache, search])
// Ein laufender Telefonbuch-Abgleich soll nicht weiterfragen, wenn die
// Seite verschwindet.
useEffect(() => () => abortRef.current?.abort(), [])
const loading = progress !== null
return ( return (
<> <>
<div className="card__header"> <h2>Adressdaten</h2>
<h2>Adressdaten</h2>
<button
type="button"
className="button button--ghost"
onClick={() => void reload()}
disabled={loading}
>
{loading ? 'Lädt …' : 'Neu laden'}
</button>
</div>
{/* Die `.row` ist nicht bloß Zierde: `.field` wächst (`flex: 1 1 260px`) {/* Die `.row` ist nicht bloß Zierde: `.field` wächst (`flex: 1 1 260px`)
und würde als direktes Kind der Karte einer Spalte in die *Höhe* und würde als direktes Kind der Karte einer Spalte in die *Höhe*
@@ -180,22 +117,15 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
<> <>
<p className="note"> <p className="note">
{filter.trim() ? `${results.length} Treffer` : `${results.length} Einträge`} {filter.trim() ? `${results.length} Treffer` : `${results.length} Einträge`}
{' · direkt aus der Adress-Ablage des Backends'}
</p> </p>
{incomplete && (
<p className="note note--error">
Der letzte Telefonbuch-Abgleich war unvollständig; es können Einträge fehlen.
Neu laden" versucht es erneut.
</p>
)}
{results.length === 0 ? ( {results.length === 0 ? (
<p className="note"> !filter.trim() && (
{filter.trim() <p className="note">
? `Kein Eintrag zu „${filter.trim()}".` Die Ablage ist noch leer sie füllt sich über die Webhooks und über das
: 'Die Ablage ist noch leer sie füllt sich über die Webhooks und über das Telefonbuch der SwyxTray-App.'} Telefonbuch der SwyxTray-App.
</p> </p>
)
) : ( ) : (
<ul className="contacts"> <ul className="contacts">
{results.map((contact, index) => ( {results.map((contact, index) => (
@@ -212,12 +142,13 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
)} )}
</span> </span>
{contact.number && <span className="contacts__number">{contact.number}</span>} {contact.number && <span className="contacts__number">{contact.number}</span>}
{contact.number && ( {/* Ohne Verbindung zur Tray-App gibt es nichts zu wählen
der Knopf erscheint dann gar nicht erst. */}
{contact.number && !disabled && (
<button <button
type="button" type="button"
className="button button--small" className="button button--small"
onClick={() => onDial(contact.number!)} onClick={() => onDial(contact.number!)}
disabled={disabled}
> >
Anrufen Anrufen
</button> </button>
@@ -228,24 +159,6 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
)} )}
</> </>
)} )}
<p className="note">
Jede Suche fragt die Adress-Ablage des Backends (MongoDB) direkt ab die per Webhook
gemeldeten Kunden und Kuriere sowie das Telefonbuch des Swyx-Clients; im Browser wird
kein Bestand vorgehalten. Das Telefonbuch fließt bei bestehender Verbindung zur
SwyxTray-App in die Ablage ein beim Verbinden von selbst, bei „Neu laden" auf
Anforderung.
</p>
{loading && (
<LoadingDialog
title="Telefonbuch wird abgeglichen"
done={progress.done}
total={progress.total}
detail={progress.found > 0 ? `${progress.found} Einträge bisher` : undefined}
onCancel={() => abortRef.current?.abort()}
/>
)}
</> </>
) )
} }
+2 -29
View File
@@ -38,7 +38,6 @@ export default function HomePage() {
const [url, setUrl] = useState(DEFAULT_WS_URL) const [url, setUrl] = useState(DEFAULT_WS_URL)
const [configNote, setConfigNote] = useState<string | null>(null) const [configNote, setConfigNote] = useState<string | null>(null)
const [tab, setTab] = useState<TabName>('calls') const [tab, setTab] = useState<TabName>('calls')
const [draft, setDraft] = useState('')
const tray = useSwyxTray() const tray = useSwyxTray()
const { status, error, log, history, calls, ringingCall, busy, tray: appState } = tray const { status, error, log, history, calls, ringingCall, busy, tray: appState } = tray
@@ -119,11 +118,6 @@ export default function HomePage() {
tray.disconnect() tray.disconnect()
} }
function handleSendRaw(event: FormEvent) {
event.preventDefault()
const message = draft.trim()
if (message && tray.sendRaw(message)) setDraft('')
}
return ( return (
<div className="page"> <div className="page">
@@ -193,9 +187,6 @@ export default function HomePage() {
active={tab === 'contacts'} active={tab === 'contacts'}
disabled={!isConnected} disabled={!isConnected}
cache={tray.addressCache} cache={tray.addressCache}
// Unverpackt weitergereicht: der Rückruf ist stabil, damit das
// Panel nicht bei jedem Rendern neu lädt.
onLoad={tray.loadDirectory}
onDial={(number) => void tray.dial(number)} onDial={(number) => void tray.dial(number)}
/> />
{error && <p className="note note--error">{error}</p>} {error && <p className="note note--error">{error}</p>}
@@ -279,26 +270,8 @@ export default function HomePage() {
<section className="card"> <section className="card">
<h2>Diagnose</h2> <h2>Diagnose</h2>
<MessageLog entries={log} /> <MessageLog entries={log} />
<form className="row" onSubmit={handleSendRaw}> <div className="row">
<label className="field">
<span className="field__label">Rohnachricht senden</span>
<input
className="field__input"
type="text"
value={draft}
disabled={!isConnected}
placeholder={'{"id":1,"cmd":"status"}'}
onChange={(e) => setDraft(e.target.value)}
/>
</label>
<div className="row__actions"> <div className="row__actions">
<button
type="submit"
className="button button--primary"
disabled={!isConnected || !draft.trim()}
>
Senden
</button>
<button <button
type="button" type="button"
className="button" className="button"
@@ -316,7 +289,7 @@ export default function HomePage() {
Log leeren Log leeren
</button> </button>
</div> </div>
</form> </div>
</section> </section>
</TabPanel> </TabPanel>
</main> </main>