feat(manager): "Τοπικό δίκτυο" card - server IP source, override editor, DHCP-change warning

App Info gets its own LAN card: the IP phones use and where it comes from
(manual / automatic detection / .env / browser address), an inline editor
to pin it or return to automatic, a note when detection is unavailable
(Docker Desktop, helper stopped/stale), and an amber warning with a one-click
fix when the pinned address is no longer this machine's. Waiter URL + pairing
QR and manager URL moved here from the System grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 16:13:17 +03:00
co-authored by Claude Opus 5.5
parent 76aac203d6
commit 676a725807
@@ -323,12 +323,133 @@ function QRModal({ url, caption = 'Σαρώστε με το κινητό για
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/
// Address phones use on the LAN. HOST_IP from the server wins; otherwise, if this
// dashboard was itself opened by IP (http://192.168.x.x:8081), that IP is the server.
// Address phones use on the LAN. The server resolves it (manual override →
// live detection → HOST_IP, see local_backend/services/lan_ip.py); if it knows
// nothing and this dashboard was itself opened by IP, that IP is the server.
function resolveLanIp(status) {
if (status?.lan_ip) return status.lan_ip
if (status?.lan_ip) return { ip: status.lan_ip, source: status.lan_ip_info?.source ?? 'env' }
const host = window.location.hostname
return IPV4.test(host) && !host.startsWith('127.') ? host : null
return IPV4.test(host) && !host.startsWith('127.') ? { ip: host, source: 'browser' } : { ip: null, source: null }
}
const LAN_SOURCE_LABEL = {
override: 'Χειροκίνητη ρύθμιση',
detected: 'Αυτόματη ανίχνευση',
env: 'Από το .env (HOST_IP)',
browser: 'Από τη διεύθυνση του browser',
}
const DETECTION_NOTE = {
unsupported: 'Η αυτόματη ανίχνευση λειτουργεί μόνο σε Linux server (όχι σε Docker Desktop).',
stale: 'Η αυτόματη ανίχνευση σταμάτησε να ενημερώνεται — ελέγξτε το container netinfo.',
unavailable: 'Η αυτόματη ανίχνευση δεν είναι διαθέσιμη (το container netinfo δεν τρέχει).',
}
const SMALL_BTN = 'flex items-center gap-1 h-6 px-2 rounded-md border border-gray-300 bg-white text-gray-600 text-xs font-medium hover:bg-gray-50 transition-colors flex-shrink-0 disabled:opacity-50'
function LanAccessSection({ status, onShowQr }) {
const qc = useQueryClient()
const info = status?.lan_ip_info
const { ip, source } = resolveLanIp(status)
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const saveMut = useMutation({
mutationFn: newIp => client.put('/api/system/lan-ip-override', { ip: newIp }).then(r => r.data),
onSuccess: (_, newIp) => {
toast.success(newIp ? 'Η IP αποθηκεύτηκε' : 'Επαναφορά σε αυτόματη ανίχνευση')
setEditing(false)
qc.invalidateQueries({ queryKey: ['system-status'] })
},
onError: err => toast.error(err.response?.data?.detail || 'Αποτυχία αποθήκευσης'),
})
function startEdit() {
setValue(info?.override || ip || '')
setEditing(true)
}
return (
<div className="card p-5 space-y-3">
<h2 className="font-semibold text-gray-700">Τοπικό δίκτυο</h2>
{info?.mismatch && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 space-y-2">
<p>
Χρησιμοποιείται η IP <span className="font-mono font-semibold">{ip}</span>, όμως ο server έχει τώρα
την <span className="font-mono font-semibold">{info.detected}</span>. Τα κινητά και το QR δεν θα
συνδέονται μέχρι να διορθωθεί.
</p>
<button className={SMALL_BTN} disabled={saveMut.isPending} onClick={() => saveMut.mutate(null)}>
Χρήση αυτόματης ανίχνευσης ({info.detected})
</button>
</div>
)}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="text-gray-500">IP server</div>
<div className="space-y-1">
{editing ? (
<div className="flex items-center gap-2 flex-wrap">
<input
value={value}
onChange={e => setValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && value.trim() && saveMut.mutate(value.trim())}
placeholder={info?.detected || '192.168.1.50'}
className="input h-7 text-xs font-mono w-36"
autoFocus
/>
<button className={SMALL_BTN} disabled={saveMut.isPending || !value.trim()} onClick={() => saveMut.mutate(value.trim())}>
Αποθήκευση
</button>
{info?.override && (
<button className={SMALL_BTN} disabled={saveMut.isPending} onClick={() => saveMut.mutate(null)}>
Αυτόματα
</button>
)}
<button className={SMALL_BTN} onClick={() => setEditing(false)}>Ακύρωση</button>
</div>
) : (
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-800 text-xs font-mono">{ip ?? '—'}</span>
{source && (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">{LAN_SOURCE_LABEL[source]}</span>
)}
<button className={SMALL_BTN} onClick={startEdit}>Αλλαγή</button>
</div>
)}
{!editing && info && info.detection !== 'ok' && (
<p className="text-xs text-gray-400">{DETECTION_NOTE[info.detection]}</p>
)}
{!ip && !editing && (
<p className="text-xs text-amber-700">
Άγνωστη IP — ορίστε την εδώ (π.χ. 192.168.1.50) ή <span className="font-mono">HOST_IP</span> στο .env.
</p>
)}
</div>
{ip && (
<>
<div className="text-gray-500">Σερβιτόροι (WiFi)</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-800 text-xs font-mono break-all">http://{ip}</span>
<button
onClick={() => onShowQr({
url: waiterPairUrl(ip, status?.site_id),
caption: 'Σαρώστε με την κάμερα του κινητού — ανοίγει την εφαρμογή σερβιτόρου. Το κινητό πρέπει να είναι στο ίδιο WiFi.',
})}
className={SMALL_BTN}
>
QR Code
</button>
</div>
<div className="text-gray-500">Διαχείριση (WiFi)</div>
<div className="font-medium text-gray-800 text-xs font-mono break-all">http://{ip}/manager</div>
</>
)}
</div>
</div>
)
}
// Pairing URL: opens the waiter app when scanned with the phone camera, and the
@@ -428,39 +549,6 @@ export default function AppInfoTab() {
<div className="font-medium text-gray-800">{new Date(status.expires_at).toLocaleDateString('el-GR')}</div>
</>
)}
{(() => {
const lanIp = resolveLanIp(status)
if (!lanIp) {
return (
<>
<div className="text-gray-500">Τοπική πρόσβαση</div>
<div className="text-xs text-amber-700">
Άγνωστη IP του server — ορίστε <span className="font-mono">HOST_IP</span> στο .env
</div>
</>
)
}
const pairUrl = waiterPairUrl(lanIp, status?.site_id)
return (
<>
<div className="text-gray-500">Σερβιτόροι (WiFi)</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-800 text-xs font-mono break-all">http://{lanIp}</span>
<button
onClick={() => setQr({
url: pairUrl,
caption: 'Σαρώστε με την κάμερα του κινητού — ανοίγει την εφαρμογή σερβιτόρου. Το κινητό πρέπει να είναι στο ίδιο WiFi.',
})}
className="flex items-center gap-1 h-6 px-2 rounded-md border border-gray-300 bg-white text-gray-600 text-xs font-medium hover:bg-gray-50 transition-colors flex-shrink-0"
>
QR Code
</button>
</div>
<div className="text-gray-500">Διαχείριση (WiFi)</div>
<div className="font-medium text-gray-800 text-xs font-mono break-all">http://{lanIp}/manager</div>
</>
)
})()}
{status?.waiter_domain && (
<>
<div className="text-gray-500">Waiter Domain</div>
@@ -478,6 +566,8 @@ export default function AppInfoTab() {
</div>
</div>
<LanAccessSection status={status} onShowQr={setQr} />
<TimezoneSection />
<StatsSection />