feat(waiter): native app finds the venue server again after its IP changes

When the saved server address stops answering, the native app re-checks it
once and then probes every address in the same /24 (same scheme and port,
nearest first, 48 in parallel, 1.5s timeout) for /api/system/identity,
switching only to the server that reports THIS venue's site_id - never to
"any Xenia server". A found address is saved on the venue and the app
reloads onto the same screen (/offline → start page) after a short
"Ο server άλλαξε διεύθυνση" notice; the offline queue syncs after reload.

- src/native/rediscovery.js: subnet candidates + scan (pure, injectable)
- src/native/autoRediscover.js: re-check first, one scan at a time,
  automatic attempts at most once a minute; skipped for dev / site-less venues
- ServerRediscovery: triggers when the connection is confirmed offline
  (retry every 2 min) or any request fails with a network error
- Offline page: manual "Αναζήτηση server στο δίκτυο" with progress and
  "not found → scan the QR" guidance

Tests: 11 unit checks (ordering, port kept, non-IP hosts, other venue never
chosen, early stop, progress). Emulator E2E with a real address change
(.99 → .2): logged in → found in ~3.5s, back on /tables with live WS;
logged out → found in ~6.5s, waiter list loads; server really down →
address untouched, manual search reports not found. Web modes unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 17:00:44 +03:00
co-authored by Claude Opus 5.5
parent cae5d75005
commit ab82598859
5 changed files with 232 additions and 1 deletions
+2
View File
@@ -26,6 +26,7 @@ import AppShell from './components/AppShell'
import UpdatePrompt from './components/UpdatePrompt' import UpdatePrompt from './components/UpdatePrompt'
import InstallAppBanner from './components/InstallAppBanner' import InstallAppBanner from './components/InstallAppBanner'
import VenuesPage from './pages/VenuesPage' import VenuesPage from './pages/VenuesPage'
import ServerRediscovery from './components/ServerRediscovery'
import { deliveryMode, getActiveVenue } from './config/server' import { deliveryMode, getActiveVenue } from './config/server'
// ─── Utility ───────────────────────────────────────────────────────────────── // ─── Utility ─────────────────────────────────────────────────────────────────
@@ -446,6 +447,7 @@ export default function App() {
{/* Native builds ship their UI in the app bundle — no service worker to update */} {/* Native builds ship their UI in the app bundle — no service worker to update */}
{deliveryMode() !== 'native' && <UpdatePrompt />} {deliveryMode() !== 'native' && <UpdatePrompt />}
<InstallAppBanner /> <InstallAppBanner />
{deliveryMode() === 'native' && <ServerRediscovery />}
<Routes> <Routes>
<Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} /> <Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} />
<Route path="/offline" element={<OfflinePage />} /> <Route path="/offline" element={<OfflinePage />} />
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react'
import useConnectionStore from '../store/connectionStore'
import { canRediscover, rediscover } from '../native/autoRediscover'
const RETRY_WHILE_OFFLINE_MS = 2 * 60_000
const RELOAD_DELAY_MS = 1500
/**
* Native app: when the venue server stops answering, look for it at a new
* address on the same network and reconnect automatically (plan step 5).
*
* Triggers: the connection is confirmed offline (logged in), or any request
* fails with a network error (login / offline screens). Retries every 2 minutes
* while offline. On success it shows a short notice and reloads into the new
* address — the offline queue lives in IndexedDB and syncs after the reload.
*/
export default function ServerRediscovery() {
const status = useConnectionStore(s => s.status)
const [movedTo, setMovedTo] = useState(null)
const enabled = canRediscover()
useEffect(() => {
if (!enabled) return
let cancelled = false
async function attempt() {
const result = await rediscover()
if (cancelled || result.status !== 'moved') return
setMovedTo(result.baseUrl)
// Stay on the waiter's current screen — except /offline, which only
// exists because the server was unreachable; start over from there.
const target = window.location.pathname === '/offline' ? '/' : window.location.href
setTimeout(() => window.location.replace(target), RELOAD_DELAY_MS)
}
window.addEventListener('backend-offline', attempt)
let timer = null
if (status === 'offline') {
attempt()
timer = setInterval(attempt, RETRY_WHILE_OFFLINE_MS)
}
return () => {
cancelled = true
window.removeEventListener('backend-offline', attempt)
clearInterval(timer)
}
}, [enabled, status])
if (!movedTo) return null
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 10001, background: 'rgba(15,23,42,0.92)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 32, textAlign: 'center',
}}>
<div>
<div style={{ fontSize: 44, marginBottom: 12 }}>🔄</div>
<p style={{ color: '#e2e8f0', fontSize: 17, fontWeight: 600, marginBottom: 6 }}>
Ο server άλλαξε διεύθυνση
</p>
<p style={{ color: '#94a3b8', fontSize: 14, fontFamily: 'monospace' }}>{movedTo}</p>
<p style={{ color: '#94a3b8', fontSize: 14, marginTop: 10 }}>Επανασύνδεση…</p>
</div>
</div>
)
}
+48
View File
@@ -0,0 +1,48 @@
/**
* When to look for the venue server at a new address (native app only).
*
* rediscover() first re-checks the saved address — a server that's just
* restarting shouldn't trigger a scan result — and only then scans the subnet
* (./rediscovery.js). One scan at a time; automatic attempts at most once a
* minute. A found server is saved on the venue; the caller reloads the app so
* every URL picks up the new address.
*/
import { deliveryMode, getActiveVenue, saveVenue } from '../config/server'
import { probeIdentity, scanForVenue } from './rediscovery'
const MIN_INTERVAL_MS = 60_000
let running = null
let lastRun = 0
/** Only native venues with a real site id can be matched safely. */
export function canRediscover() {
const venue = getActiveVenue()
return deliveryMode() === 'native' && !!venue && venue.siteId !== 'dev' && !venue.siteId.startsWith('local-')
}
/**
* Resolves to { status: 'reachable' | 'moved' | 'not-found' | 'skipped', baseUrl? }.
* `force` bypasses the once-a-minute throttle (manual "search" button).
*/
export function rediscover({ force = false, onProgress } = {}) {
if (!canRediscover()) return Promise.resolve({ status: 'skipped' })
if (running) return running
if (!force && Date.now() - lastRun < MIN_INTERVAL_MS) return Promise.resolve({ status: 'skipped' })
lastRun = Date.now()
running = (async () => {
const venue = getActiveVenue()
const current = await probeIdentity(venue.baseUrl)
if (current?.site_id === venue.siteId) return { status: 'reachable' }
const baseUrl = await scanForVenue(venue.baseUrl, venue.siteId, { onProgress })
if (!baseUrl) return { status: 'not-found' }
saveVenue({ ...venue, baseUrl })
return { status: 'moved', baseUrl }
})().finally(() => {
running = null
})
return running
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Finding the venue server again after its IP changed (native app).
*
* The phone stores http://<ip>:<port> at pairing time. If the router later hands
* the server a new address, the app would be stranded until someone re-scans
* the QR. Instead: when the saved address stops answering, probe every address
* in the same /24 (same scheme and port) for /api/system/identity and switch to
* the one reporting THIS venue's site_id — never to "any Xenia server".
*
* A DHCP change almost always stays inside the same /24; anything bigger
* (new router, different subnet) still has the manual fallback: scan the QR.
* Contract: docs/reference/lan-access.md
*/
import { SUPPORTED_API_VERSIONS } from './pairing'
const PROBE_TIMEOUT_MS = 1500
const CONCURRENCY = 48
/** Same-/24 candidate origins for a base URL, nearest addresses first. [] if not an IPv4 URL. */
export function subnetCandidates(baseUrl) {
let url
try {
url = new URL(baseUrl)
} catch {
return []
}
const m = url.hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
if (!m) return []
const [a, b, c, d] = m.slice(1).map(Number)
const port = url.port ? `:${url.port}` : ''
const hosts = []
for (let i = 1; i <= 254; i++) if (i !== d) hosts.push(i)
// Nearest first: DHCP pools hand out neighbouring addresses more often than not
hosts.sort((x, y) => Math.abs(x - d) - Math.abs(y - d) || x - y)
return hosts.map(h => `${url.protocol}//${a}.${b}.${c}.${h}${port}`)
}
/** Identity of a server, or null (unreachable / not Xenia / unsupported API). */
export async function probeIdentity(origin, { timeoutMs = PROBE_TIMEOUT_MS, signal } = {}) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
const onAbort = () => controller.abort()
signal?.addEventListener('abort', onAbort)
try {
const res = await fetch(`${origin}/api/system/identity`, { signal: controller.signal })
if (!res.ok) return null
const identity = await res.json()
return identity?.app === 'xenia-pos' && SUPPORTED_API_VERSIONS.includes(identity.api_version) ? identity : null
} catch {
return null
} finally {
clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
}
}
/**
* Scan the old address's /24 for the server reporting `siteId`.
* Resolves to the new base URL, or null. Stops as soon as it's found.
* `probe` is injectable for tests.
*/
export async function scanForVenue(baseUrl, siteId, { probe = probeIdentity, concurrency = CONCURRENCY, onProgress } = {}) {
const queue = subnetCandidates(baseUrl)
const total = queue.length
if (!siteId || total === 0) return null
const stop = new AbortController()
let found = null
let done = 0
async function worker() {
while (!found && queue.length) {
const origin = queue.shift()
const identity = await probe(origin, { signal: stop.signal })
done++
onProgress?.(done, total)
if (!found && identity?.site_id === siteId) {
found = origin
stop.abort()
}
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, total) }, worker))
return found
}
+32 -1
View File
@@ -1,9 +1,11 @@
import client from '../api/client' import client from '../api/client'
import { deliveryMode } from '../config/server' import { deliveryMode } from '../config/server'
import { canRediscover, rediscover } from '../native/autoRediscover'
import { useState } from 'react' import { useState } from 'react'
export default function OfflinePage() { export default function OfflinePage() {
const [checking, setChecking] = useState(false) const [checking, setChecking] = useState(false)
const [search, setSearch] = useState(null) // null | { done, total } | 'not-found'
async function retry() { async function retry() {
setChecking(true) setChecking(true)
@@ -15,6 +17,19 @@ export default function OfflinePage() {
} }
} }
// Native app: look for this venue's server at a new address on the same network
async function searchNetwork() {
setSearch({ done: 0, total: 253 })
const result = await rediscover({ force: true, onProgress: (done, total) => setSearch({ done, total }) })
if (result.status === 'moved' || result.status === 'reachable') {
window.location.href = '/'
} else {
setSearch('not-found')
}
}
const searching = search && search !== 'not-found'
return ( return (
<div className="page page--centered"> <div className="page page--centered">
<div style={{ textAlign: 'center', maxWidth: 300 }}> <div style={{ textAlign: 'center', maxWidth: 300 }}>
@@ -23,14 +38,30 @@ export default function OfflinePage() {
<p style={{ color: '#64748b', marginBottom: 32 }}> <p style={{ color: '#64748b', marginBottom: 32 }}>
Δεν είναι δυνατή η επικοινωνία με το σύστημα. Ελέγξτε τη σύνδεση WiFi. Δεν είναι δυνατή η επικοινωνία με το σύστημα. Ελέγξτε τη σύνδεση WiFi.
</p> </p>
<button className="btn btn--primary btn--lg" onClick={retry} disabled={checking}> <button className="btn btn--primary btn--lg" onClick={retry} disabled={checking || searching}>
{checking ? 'Έλεγχος…' : 'Επανάληψη'} {checking ? 'Έλεγχος…' : 'Επανάληψη'}
</button> </button>
{canRediscover() && (
<button
className="btn btn--secondary btn--lg"
style={{ marginTop: 12 }}
onClick={searchNetwork}
disabled={searching}
>
{searching ? `Αναζήτηση… ${search.done}/${search.total}` : 'Αναζήτηση server στο δίκτυο'}
</button>
)}
{search === 'not-found' && (
<p style={{ color: '#94a3b8', fontSize: 13, marginTop: 12, lineHeight: 1.45 }}>
Ο server δεν βρέθηκε στο δίκτυο. Ελέγξτε ότι είναι αναμμένος, ή σαρώστε ξανά το QR από τον διαχειριστή.
</p>
)}
{deliveryMode() === 'native' && ( {deliveryMode() === 'native' && (
<button <button
className="btn btn--secondary btn--lg" className="btn btn--secondary btn--lg"
style={{ marginTop: 12 }} style={{ marginTop: 12 }}
onClick={() => { window.location.href = '/venues' }} onClick={() => { window.location.href = '/venues' }}
disabled={searching}
> >
Αλλαγή καταστήματος Αλλαγή καταστήματος
</button> </button>