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:
@@ -26,6 +26,7 @@ import AppShell from './components/AppShell'
|
||||
import UpdatePrompt from './components/UpdatePrompt'
|
||||
import InstallAppBanner from './components/InstallAppBanner'
|
||||
import VenuesPage from './pages/VenuesPage'
|
||||
import ServerRediscovery from './components/ServerRediscovery'
|
||||
import { deliveryMode, getActiveVenue } from './config/server'
|
||||
|
||||
// ─── Utility ─────────────────────────────────────────────────────────────────
|
||||
@@ -446,6 +447,7 @@ export default function App() {
|
||||
{/* Native builds ship their UI in the app bundle — no service worker to update */}
|
||||
{deliveryMode() !== 'native' && <UpdatePrompt />}
|
||||
<InstallAppBanner />
|
||||
{deliveryMode() === 'native' && <ServerRediscovery />}
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import client from '../api/client'
|
||||
import { deliveryMode } from '../config/server'
|
||||
import { canRediscover, rediscover } from '../native/autoRediscover'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function OfflinePage() {
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [search, setSearch] = useState(null) // null | { done, total } | 'not-found'
|
||||
|
||||
async function retry() {
|
||||
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 (
|
||||
<div className="page page--centered">
|
||||
<div style={{ textAlign: 'center', maxWidth: 300 }}>
|
||||
@@ -23,14 +38,30 @@ export default function OfflinePage() {
|
||||
<p style={{ color: '#64748b', marginBottom: 32 }}>
|
||||
Δεν είναι δυνατή η επικοινωνία με το σύστημα. Ελέγξτε τη σύνδεση WiFi.
|
||||
</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 ? 'Έλεγχος…' : 'Επανάληψη'}
|
||||
</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' && (
|
||||
<button
|
||||
className="btn btn--secondary btn--lg"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => { window.location.href = '/venues' }}
|
||||
disabled={searching}
|
||||
>
|
||||
Αλλαγή καταστήματος
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user