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:
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user