From 373081d33dfda11018dfc41670a7ccd8fa923a85 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Mon, 21 Sep 2026 10:52:55 +0200 Subject: [PATCH] =?UTF-8?q?Dashboard=20mit=20Anrufhistorie=20und=20Dev-Sch?= =?UTF-8?q?alter=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 + .../swyxweb/storage/ArchiveService.java | 6 + .../swyxweb/web/CallController.java | 7 + .../swyxweb/CallControllerTests.java | 29 ++ .../appcreation/swyxweb/CallHistoryTests.java | 31 ++ frontend/src/calls.test.ts | 42 +++ frontend/src/calls.ts | 38 +++ frontend/src/components/CallDashboard.tsx | 147 +++++++++ frontend/src/hooks/useCallHistory.ts | 46 +++ frontend/src/index.css | 191 +++++++++++ frontend/src/pages/HomePage.tsx | 307 ++++++++++-------- 11 files changed, 713 insertions(+), 144 deletions(-) create mode 100644 backend/src/test/java/de/appcreation/swyxweb/CallHistoryTests.java create mode 100644 frontend/src/calls.test.ts create mode 100644 frontend/src/components/CallDashboard.tsx create mode 100644 frontend/src/hooks/useCallHistory.ts diff --git a/README.md b/README.md index ba8f35d..8783b80 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,19 @@ React-Frontend mit Spring-Boot-Backend. Die Startseite verbindet sich **aus dem per WebSocket mit der **SwyxTray-App** (`ws://127.0.0.1:17654/ws`), meldet eingehende Anrufe und startet ausgehende Anrufe. +Standardmäßig öffnet sich ein **Dashboard mit der Historie eingegangener Anrufe**: +Rufnummer, Name (falls übermittelt), Datum, Uhrzeit und Leitung, neueste zuerst. +Die Historie lässt sich nach Rufnummer oder Name durchsuchen; Kennzahlen zeigen +alle eingegangenen Anrufe, die heutigen Anrufe und unterschiedliche Rufnummern. +`GET /api/calls` liest dafür die gespeicherten `incoming`-Ereignisse aus MongoDB, +auch aus früheren Sitzungen. Das Dashboard aktualisiert sich alle fünf Sekunden. +Verbindungs- und Ende-Ereignisse erscheinen nicht als zusätzliche Anrufe. + +Der Schalter **Dev** oben in der Kopfzeile blendet die bisherige Oberfläche mit +Anrufen, Adressdaten, Webhook, Tabs und Verbindung ein. Er ist beim Laden der Seite +ausgeschaltet. Beim Umschalten bleiben Eingaben und die SwyxTray-Verbindung erhalten; +Anruf-Popups und Benachrichtigungen funktionieren auch im Dashboard. + ``` SwyxWeb/ ├── backend/ Spring Boot 4.1 (Java 21, Maven Wrapper) diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java b/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java index 0979986..ddf756c 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java @@ -141,6 +141,12 @@ public class ArchiveService { submit("Anrufdaten", () -> calls.forEach(mongo::save)); } + /** Historie der eingehenden Anrufe, auch aus früheren Browser-Sitzungen. */ + public List incomingCalls() { + return mongo.find(new Query(Criteria.where("event").is("incoming")) + .with(Sort.by(Sort.Direction.DESC, "receivedAt", "id")), StoredCall.class); + } + private void submit(String what, Runnable write) { worker.execute(() -> { try { diff --git a/backend/src/main/java/de/appcreation/swyxweb/web/CallController.java b/backend/src/main/java/de/appcreation/swyxweb/web/CallController.java index 7387bdf..39c282b 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/web/CallController.java +++ b/backend/src/main/java/de/appcreation/swyxweb/web/CallController.java @@ -5,6 +5,7 @@ import java.util.List; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -37,6 +38,12 @@ public class CallController { this.archive = archive; } + /** Eingegangene Anrufe, neueste zuerst; Verbindungs- und Ende-Ereignisse zählen nicht erneut. */ + @GetMapping + public List list() { + return archive.incomingCalls(); + } + /** Ein Anruf-Ereignis, wie es das Frontend schickt – Felder wie im CallEvent. */ public record CallEventBody( Integer line, diff --git a/backend/src/test/java/de/appcreation/swyxweb/CallControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/CallControllerTests.java index e0ab169..725062a 100644 --- a/backend/src/test/java/de/appcreation/swyxweb/CallControllerTests.java +++ b/backend/src/test/java/de/appcreation/swyxweb/CallControllerTests.java @@ -2,10 +2,15 @@ package de.appcreation.swyxweb; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.time.Instant; +import java.util.List; + import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; @@ -15,6 +20,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import de.appcreation.swyxweb.storage.ArchiveService; +import de.appcreation.swyxweb.storage.StoredCall; /** * Der Endpunkt nimmt die Anruf-Ereignisse der Startseite an und bestätigt nur @@ -31,6 +37,29 @@ class CallControllerTests { @MockitoBean ArchiveService archive; + @Test + void returnsArchivedIncomingCallsWithoutAnActiveTrayConnection() throws Exception { + when(archive.incomingCalls()).thenReturn(List.of(new StoredCall( + "call-1", Instant.parse("2026-09-20T08:30:00Z"), 1, "incoming", "incoming", + "Muster GmbH (+493012345)", "+493012345", "Muster GmbH", "klingelt", "LSRinging"))); + + mvc.perform(get("/api/calls")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value("call-1")) + .andExpect(jsonPath("$[0].receivedAt").value("2026-09-20T08:30:00Z")) + .andExpect(jsonPath("$[0].peerNumber").value("+493012345")) + .andExpect(jsonPath("$[0].peerName").value("Muster GmbH")); + } + + @Test + void returnsEmptyHistoryAsAnEmptyArray() throws Exception { + when(archive.incomingCalls()).thenReturn(List.of()); + mvc.perform(get("/api/calls")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + @Test void acceptsEventsAndReturnsCount() throws Exception { mvc.perform(post("/api/calls") diff --git a/backend/src/test/java/de/appcreation/swyxweb/CallHistoryTests.java b/backend/src/test/java/de/appcreation/swyxweb/CallHistoryTests.java new file mode 100644 index 0000000..b780c6e --- /dev/null +++ b/backend/src/test/java/de/appcreation/swyxweb/CallHistoryTests.java @@ -0,0 +1,31 @@ +package de.appcreation.swyxweb; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; + +import de.appcreation.swyxweb.storage.ArchiveService; +import de.appcreation.swyxweb.storage.StoredCall; + +class CallHistoryTests { + + @Test + void readsOnlyIncomingEventsInNewestFirstOrder() { + MongoTemplate mongo = mock(MongoTemplate.class); + new ArchiveService(mongo).incomingCalls(); + + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(mongo).find(query.capture(), eq(StoredCall.class)); + // direction=incoming würde auch connected/ended zählen und Anrufe verdreifachen. + assertThat(query.getValue().getQueryObject()).containsOnlyKeys("event") + .containsEntry("event", "incoming"); + assertThat(query.getValue().getSortObject()).containsEntry("receivedAt", -1) + .containsEntry("id", -1); + } +} diff --git a/frontend/src/calls.test.ts b/frontend/src/calls.test.ts new file mode 100644 index 0000000..deeaa2d --- /dev/null +++ b/frontend/src/calls.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { callerNumber, fetchIncomingCalls, matchesCall, type StoredCall } from './calls' + +const call: StoredCall = { + id: 'call-1', + receivedAt: '2026-09-20T08:30:00Z', + line: 1, + peer: 'Muster GmbH (+49 30 123-45)', + peerNumber: '+49 30 123-45', + peerName: 'Muster GmbH', +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('Anrufhistorie', () => { + it('liest archivierte Anrufe und unterscheidet Ladefehler von einer leeren Historie', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify([call]))) + .mockResolvedValueOnce(new Response('[]')) + .mockResolvedValueOnce(new Response('', { status: 503 }))) + + await expect(fetchIncomingCalls()).resolves.toEqual([call]) + await expect(fetchIncomingCalls()).resolves.toEqual([]) + await expect(fetchIncomingCalls()).rejects.toThrow('HTTP 503') + }) + + it('findet Namen unabhängig von Großschreibung und Nummern ohne Formatierung', () => { + expect(matchesCall(call, ' MUSTER ')).toBe(true) + expect(matchesCall(call, '493012345')).toBe(true) + expect(matchesCall(call, '(30) 12345')).toBe(true) + expect(matchesCall(call, 'Andere Firma')).toBe(false) + expect(matchesCall(call, 'Firma 12345')).toBe(false) + expect(matchesCall(call, '')).toBe(true) + }) + + it('zeigt bei älteren Ereignissen die Nummer aus peer, aber erfindet keine unbekannte Nummer', () => { + expect(callerNumber({ ...call, peerNumber: '' })).toBe('+49 30 123-45') + expect(callerNumber({ ...call, peerNumber: null, peer: '5215' })).toBe('5215') + expect(callerNumber({ ...call, peerNumber: null, peer: 'unbekannt' })).toBeNull() + expect(callerNumber({ ...call, peerNumber: null, peer: 'Muster GmbH' })).toBeNull() + }) +}) diff --git a/frontend/src/calls.ts b/frontend/src/calls.ts index 90afa13..20fe972 100644 --- a/frontend/src/calls.ts +++ b/frontend/src/calls.ts @@ -1,5 +1,43 @@ import type { CallEvent } from './swyx/protocol' +/** Ein gespeicherter eingehender Anruf aus GET /api/calls. */ +export interface StoredCall { + id: string + receivedAt: string + line: number | null + peer: string | null + peerNumber: string | null + peerName: string | null +} + +export async function fetchIncomingCalls(signal?: AbortSignal): Promise { + const response = await fetch('/api/calls', { signal, cache: 'no-store' }) + if (!response.ok) { + throw new Error(`Anrufhistorie nicht abrufbar (HTTP ${response.status}).`) + } + return (await response.json()) as StoredCall[] +} + +/** Ältere Snapshots enthalten die Rufnummer teilweise nur in der Anzeigeform. */ +export function callerNumber(call: StoredCall): string | null { + const number = call.peerNumber?.trim() + if (number) return number + const peer = call.peer?.trim() ?? '' + const candidate = peer.match(/\(([^()]+)\)$/)?.[1] ?? peer + return /^[+\d][\d\s()/.-]*$/.test(candidate) ? candidate : null +} + +export function matchesCall(call: StoredCall, query: string): boolean { + const wanted = query.trim().toLocaleLowerCase('de') + if (!wanted) return true + const text = [callerNumber(call), call.peerName, call.peer].filter(Boolean).join(' ') + if (text.toLocaleLowerCase('de').includes(wanted)) return true + // Rufnummern auch ohne die Leerzeichen/Klammern ihrer Anzeigeform finden. + const digits = wanted.replace(/\D/g, '') + return /^[+\d\s()/.-]+$/.test(wanted) && digits.length > 0 && + (callerNumber(call)?.replace(/\D/g, '').includes(digits) ?? false) +} + /** * Meldet Anruf-Ereignisse an das Backend, das sie in der MongoDB ablegt. * diff --git a/frontend/src/components/CallDashboard.tsx b/frontend/src/components/CallDashboard.tsx new file mode 100644 index 0000000..34eee6d --- /dev/null +++ b/frontend/src/components/CallDashboard.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react' +import { callerNumber, matchesCall } from '../calls' +import { useCallHistory } from '../hooks/useCallHistory' + +const dateFormat = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) +const timeFormat = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +const countFormat = new Intl.NumberFormat('de-DE') +const PAGE_SIZE = 25 + +export default function CallDashboard({ active }: { active: boolean }) { + const { calls, error, loading, updatedAt, refresh } = useCallHistory(active) + const [query, setQuery] = useState('') + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE) + const filtered = calls?.filter((call) => matchesCall(call, query)) ?? [] + const today = new Date().toDateString() + const todayCount = calls?.filter((call) => new Date(call.receivedAt).toDateString() === today).length + const numbers = new Set( + calls?.map(callerNumber).filter((number) => number !== null) + .map((number) => number.replace(/^\+/, '00').replace(/\D/g, '')), + ) + + return ( +
+
+
+

Dashboard

+

Alle eingegangenen Anrufe im Überblick.

+
+ +
+ +
+
+
Eingegangene Anrufe
+
+ {calls ? countFormat.format(calls.length) : '–'} + Gesamte Historie +
+
+
+
Heute
+
+ {todayCount !== undefined ? countFormat.format(todayCount) : '–'} + {dateFormat.format(new Date())} +
+
+
+
Unterschiedliche Rufnummern
+
+ {calls ? countFormat.format(numbers.size) : '–'} + Mit übermittelter Rufnummer +
+
+
+ + {error && ( +

+ {error} {calls ? 'Der letzte geladene Stand bleibt sichtbar.' : 'Bitte erneut versuchen.'} +

+ )} + +
+
+
+

Anrufhistorie

+

Neueste Anrufe zuerst · automatische Aktualisierung

+
+ +
+ + {calls === null ? ( +

+ {loading ? 'Anrufhistorie wird geladen …' : 'Die Anrufhistorie ist momentan nicht verfügbar.'} +

+ ) : filtered.length === 0 ? ( +
+ {calls.length === 0 ? 'Noch keine eingegangenen Anrufe' : 'Keine passenden Anrufe'} +

+ {calls.length === 0 + ? 'Sobald ein eingehender Anruf erfasst wurde, erscheint er hier.' + : 'Versuche eine andere Rufnummer oder einen anderen Namen.'} +

+
+ ) : ( + <> +
+ + + + + + + + + + + {filtered.slice(0, visibleCount).map((call) => { + const number = callerNumber(call) + const date = new Date(call.receivedAt) + return ( + + + + + + + ) + })} + +
RufnummerNameZeitpunktLeitung
{number ?? 'Unbekannte Rufnummer'}{call.peerName?.trim() || (!number && call.peer?.trim()) || '–'} + + {call.line ?? '–'}
+
+
+

+ {countFormat.format(Math.min(visibleCount, filtered.length))} von {countFormat.format(filtered.length)} Anrufen +

+ {visibleCount < filtered.length && ( + + )} +
+ + )} + {updatedAt &&

Zuletzt aktualisiert um {timeFormat.format(updatedAt)} Uhr

} +
+
+ ) +} diff --git a/frontend/src/hooks/useCallHistory.ts b/frontend/src/hooks/useCallHistory.ts new file mode 100644 index 0000000..86b890b --- /dev/null +++ b/frontend/src/hooks/useCallHistory.ts @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useState } from 'react' +import { fetchIncomingCalls, type StoredCall } from '../calls' + +/** Liest die dauerhafte Historie; neue Anrufe erscheinen auch ohne Neuladen. */ +export function useCallHistory(active: boolean) { + const [calls, setCalls] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const [updatedAt, setUpdatedAt] = useState(null) + const [revision, setRevision] = useState(0) + const refresh = useCallback(() => setRevision((value) => value + 1), []) + + useEffect(() => { + if (!active) return + const controller = new AbortController() + let timer: ReturnType | undefined + + async function load() { + setLoading(true) + try { + const entries = await fetchIncomingCalls(controller.signal) + if (controller.signal.aborted) return + setCalls(entries) + setUpdatedAt(new Date()) + setError(null) + } catch (e: unknown) { + if (controller.signal.aborted) return + // Bei einem vorübergehenden Fehler bleibt der letzte Stand sichtbar. + setError(e instanceof Error ? e.message : String(e)) + } finally { + if (!controller.signal.aborted) { + setLoading(false) + timer = setTimeout(() => void load(), 5000) + } + } + } + + void load() + return () => { + controller.abort() + clearTimeout(timer) + } + }, [active, revision]) + + return { calls, error, loading, updatedAt, refresh } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index aa0418d..7a3e65c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -142,9 +142,200 @@ code { .page__header-tools { display: flex; align-items: center; + flex-wrap: wrap; gap: 10px; } +.dev-switch { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.dev-switch:hover { + border-color: var(--border-strong); +} + +.dev-switch:focus-visible { + outline: 2px solid var(--brand); + outline-offset: 3px; +} + +.dev-switch__track { + width: 30px; + height: 18px; + padding: 3px; + border-radius: 999px; + background: var(--text-muted); +} + +.dev-switch__track::after { + content: ''; + display: block; + width: 12px; + height: 12px; + border-radius: 50%; + background: #fff; +} + +.dev-switch[aria-checked='true'] .dev-switch__track { + background: var(--brand); +} + +.dev-switch[aria-checked='true'] .dev-switch__track::after { + transform: translateX(12px); +} + +/* Eingaben bleiben beim Umschalten erhalten, die Telefonie läuft weiter. */ +.dev-tools:not([hidden]), +.dashboard { + display: flex; + flex-direction: column; + gap: 20px; +} + +.dashboard__heading, +.dashboard__history-heading, +.dashboard__footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.dashboard__heading h2 { + margin: 0 0 8px; + font-size: 24px; +} + +.dashboard__stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin: 0; +} + +.dashboard__stat { + padding: 18px; + border: 1px solid var(--border); + border-top: 3px solid var(--brand); + border-radius: 8px; + background: var(--surface-2); +} + +.dashboard__stat dt, +.dashboard__stat dd span { + font-size: 12px; + color: var(--text-muted); +} + +.dashboard__stat dt { + font-weight: 600; +} + +.dashboard__stat dd { + margin: 8px 0 0; + font-size: 32px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.dashboard__stat dd span { + display: block; + margin-top: 8px; + font-weight: 400; +} + +.dashboard__history { + min-width: 0; +} + +.dashboard__history-heading .note { + margin-top: 6px; +} + +.dashboard .dashboard__search { + flex: 0 1 280px; +} + +.dashboard__empty { + margin: 0; + padding: 40px 12px; + text-align: center; + background: var(--surface-2); + border-radius: 6px; +} + +.dashboard__empty .note { + margin-top: 8px; +} + +.dashboard__table-scroll { + overflow-x: auto; +} + +.dashboard__table { + width: 100%; + min-width: 560px; + border-collapse: collapse; + text-align: left; + font-size: 14px; +} + +.dashboard__table th, +.dashboard__table td { + padding: 14px 12px; + border-bottom: 1px solid var(--border); +} + +.dashboard__table th { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + background: var(--surface-2); +} + +.dashboard__table td { + overflow-wrap: anywhere; +} + +.dashboard__table tbody tr:hover { + background: var(--brand-soft); +} + +.dashboard__number { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.dashboard__time { + display: flex; + flex-direction: column; + gap: 2px; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +@media (max-width: 600px) { + .dashboard__stats { + grid-template-columns: 1fr; + } + + .dashboard .dashboard__search { + flex-basis: 100%; + } +} + /* Marken-Umschalter im Badge-Stil, damit er neben dem Status nicht aus der Reihe fällt. Die Wahl gilt sofort und bleibt über Neustarts erhalten. */ .brand-switch { diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index b619902..a873198 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -17,6 +17,7 @@ import ContactsPanel from '../components/ContactsPanel' import WebhookPanel from '../components/WebhookPanel' import Tabs, { panelId, tabId, type Tab } from '../components/Tabs' import { ActiveCallList, CallHistory } from '../components/CallList' +import CallDashboard from '../components/CallDashboard' type TabName = 'calls' | 'contacts' | 'webhook' | 'browsertabs' | 'connection' @@ -38,6 +39,7 @@ function TabPanel({ id, active, children }: { id: TabName; active: boolean; chil } export default function HomePage() { + const [dev, setDev] = useState(false) const [url, setUrl] = useState(DEFAULT_WS_URL) const [configNote, setConfigNote] = useState(null) const [tab, setTab] = useState('calls') @@ -95,8 +97,8 @@ export default function HomePage() { const [seenWebhookId, setSeenWebhookId] = useState(0) const newestWebhookId = webhook.events[0]?.id ?? 0 useEffect(() => { - if (tab === 'webhook') setSeenWebhookId(newestWebhookId) - }, [tab, newestWebhookId]) + if (dev && tab === 'webhook') setSeenWebhookId(newestWebhookId) + }, [dev, tab, newestWebhookId]) const unseenWebhook = countUnseen(webhook.events, seenWebhookId) const tabsWithBadges = TABS.map((entry) => @@ -185,158 +187,175 @@ export default function HomePage() { +
- + - -
-

Anrufen

- void tray.dial(number)} /> -

- Die Quittung bestätigt nur die Annahme des Wählauftrags – der Verlauf erscheint - anschließend unter „Leitungen". -

- {error &&

{error}

} -
+
{caller && (