Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fa690a67d | ||
|
|
607cbaecbc |
@@ -86,7 +86,7 @@ public class WebhookService {
|
||||
@PreDestroy
|
||||
void shutdown() {
|
||||
heartbeat.shutdownNow();
|
||||
subscribers.forEach(SseEmitter::complete);
|
||||
subscribers.forEach(emitter -> emitter.complete());
|
||||
subscribers.clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -359,7 +359,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
String needle = wanted.toLowerCase();
|
||||
List<MockContact> hits = MOCK_CONTACTS.stream()
|
||||
.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)
|
||||
.toList();
|
||||
log.info("SwyxTray-Mock: {} Adresseintrag/-einträge zu '{}'", hits.size(), wanted);
|
||||
|
||||
@@ -58,7 +58,7 @@ class AddressEntryTests {
|
||||
"type":"addresses"}""");
|
||||
|
||||
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 de.appcreation.swyxweb.storage.ArchiveService;
|
||||
import de.appcreation.swyxweb.webhook.WebhookEvent;
|
||||
import de.appcreation.swyxweb.webhook.WebhookService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
@@ -112,7 +111,7 @@ class WebhookControllerTests {
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat(service.history())
|
||||
.extracting(WebhookEvent::channel)
|
||||
.extracting(event -> event.channel())
|
||||
.containsExactly("kunden", "kuriere", "jobs");
|
||||
|
||||
// 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>
|
||||
</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>}
|
||||
{outcome && <p className="note note--success">{outcome}</p>}
|
||||
</>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { describeContact, type Contact } from '../swyx/protocol'
|
||||
import type { Directory, DirectoryProgress, SweepOptions } from '../swyx/directory'
|
||||
import { fetchAddresses } from '../addresses'
|
||||
import LoadingDialog from './LoadingDialog'
|
||||
|
||||
interface Props {
|
||||
/** Panel ist sichtbar – erst dann wird abgefragt. */
|
||||
active: boolean
|
||||
/** Keine Verbindung zur SwyxTray-App – Anrufen und Telefonbuch-Abgleich entfallen. */
|
||||
/** Keine Verbindung zur SwyxTray-App – Anrufen entfällt. */
|
||||
disabled: boolean
|
||||
/**
|
||||
* Adress-Cache, den die App unaufgefordert schickt. Er wird hier nicht
|
||||
@@ -15,7 +13,6 @@ interface Props {
|
||||
* hat ihn gerade hineingeschrieben.
|
||||
*/
|
||||
cache: Contact[] | null
|
||||
onLoad: (options: SweepOptions) => Promise<Directory | null>
|
||||
onDial: (number: string) => void
|
||||
}
|
||||
|
||||
@@ -36,30 +33,22 @@ const SEARCH_DEBOUNCE_MS = 300
|
||||
* Abfrageergebnis; der Bereich funktioniert damit auch ohne Verbindung zur
|
||||
* SwyxTray-App.
|
||||
*
|
||||
* Das Telefonbuch der App fließt auf zwei Wegen in die Ablage:
|
||||
*
|
||||
* 1. Der **Cache-Push** beim Verbinden (Protokoll 8) – die Startseite meldet
|
||||
* 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.
|
||||
* Das Telefonbuch der App fließt über den **Cache-Push** beim Verbinden
|
||||
* (Protokoll 8) in die Ablage – die Startseite meldet ihn ans Backend; das
|
||||
* Panel fragt die Ablage danach neu ab.
|
||||
*/
|
||||
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.
|
||||
const [results, setResults] = useState<Contact[] | 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.
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
// Zählt die Abfragen: Eine überholte darf das Ergebnis einer neueren nicht
|
||||
// mehr überschreiben – Antworten kommen nicht zwingend in Reihenfolge.
|
||||
const queryIdRef = useRef(0)
|
||||
// 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)
|
||||
filterRef.current = filter
|
||||
|
||||
@@ -89,42 +78,6 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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;
|
||||
// nach einer kurzen Schreibfrist die Ablage neu abfragen.
|
||||
useEffect(() => {
|
||||
@@ -133,25 +86,9 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
|
||||
return () => clearTimeout(timer)
|
||||
}, [cache, search])
|
||||
|
||||
// Ein laufender Telefonbuch-Abgleich soll nicht weiterfragen, wenn die
|
||||
// Seite verschwindet.
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
const loading = progress !== null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card__header">
|
||||
<h2>Adressdaten</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="button button--ghost"
|
||||
onClick={() => void reload()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Lädt …' : 'Neu laden'}
|
||||
</button>
|
||||
</div>
|
||||
<h2>Adressdaten</h2>
|
||||
|
||||
{/* 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*
|
||||
@@ -180,22 +117,15 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
|
||||
<>
|
||||
<p className="note">
|
||||
{filter.trim() ? `${results.length} Treffer` : `${results.length} Einträge`}
|
||||
{' · direkt aus der Adress-Ablage des Backends'}
|
||||
</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 ? (
|
||||
<p className="note">
|
||||
{filter.trim()
|
||||
? `Kein Eintrag zu „${filter.trim()}".`
|
||||
: 'Die Ablage ist noch leer – sie füllt sich über die Webhooks und über das Telefonbuch der SwyxTray-App.'}
|
||||
</p>
|
||||
!filter.trim() && (
|
||||
<p className="note">
|
||||
Die Ablage ist noch leer – sie füllt sich über die Webhooks und über das
|
||||
Telefonbuch der SwyxTray-App.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<ul className="contacts">
|
||||
{results.map((contact, index) => (
|
||||
@@ -212,12 +142,13 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
|
||||
)}
|
||||
</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
|
||||
type="button"
|
||||
className="button button--small"
|
||||
onClick={() => onDial(contact.number!)}
|
||||
disabled={disabled}
|
||||
>
|
||||
Anrufen
|
||||
</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()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ export default function HomePage() {
|
||||
const [url, setUrl] = useState(DEFAULT_WS_URL)
|
||||
const [configNote, setConfigNote] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState<TabName>('calls')
|
||||
const [draft, setDraft] = useState('')
|
||||
|
||||
const tray = useSwyxTray()
|
||||
const { status, error, log, history, calls, ringingCall, busy, tray: appState } = tray
|
||||
@@ -119,11 +118,6 @@ export default function HomePage() {
|
||||
tray.disconnect()
|
||||
}
|
||||
|
||||
function handleSendRaw(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
const message = draft.trim()
|
||||
if (message && tray.sendRaw(message)) setDraft('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
@@ -193,9 +187,6 @@ export default function HomePage() {
|
||||
active={tab === 'contacts'}
|
||||
disabled={!isConnected}
|
||||
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)}
|
||||
/>
|
||||
{error && <p className="note note--error">{error}</p>}
|
||||
@@ -279,26 +270,8 @@ export default function HomePage() {
|
||||
<section className="card">
|
||||
<h2>Diagnose</h2>
|
||||
<MessageLog entries={log} />
|
||||
<form className="row" onSubmit={handleSendRaw}>
|
||||
<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">
|
||||
<div className="row__actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="button button--primary"
|
||||
disabled={!isConnected || !draft.trim()}
|
||||
>
|
||||
Senden
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
@@ -316,7 +289,7 @@ export default function HomePage() {
|
||||
Log leeren
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</TabPanel>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user