From 06b01533d42fa8b52de4bc00783ae4ab053acb04 Mon Sep 17 00:00:00 2001 From: bonamin Date: Wed, 20 May 2026 14:06:36 +0300 Subject: [PATCH] feat: waiter domain + local IP tracking - Site model: add waiter_domain and last_seen_local_ip columns - HeartbeatRequest: accept optional local_ip field from local backend - HeartbeatResponse: return waiter_domain to local backend - heartbeat router: persist local_ip on each check-in - SiteDetailPage: show Public IP / Local IP separately, add Waiter Domain card with inline edit modal Co-Authored-By: Claude Sonnet 4.6 --- cloud_backend/models/site.py | 2 + cloud_backend/routers/heartbeat.py | 5 ++ cloud_backend/schemas/site.py | 6 ++ sysadmin_panel/src/pages/SiteDetailPage.jsx | 65 ++++++++++++++++++++- 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/cloud_backend/models/site.py b/cloud_backend/models/site.py index 32de4d9..5a1c87a 100644 --- a/cloud_backend/models/site.py +++ b/cloud_backend/models/site.py @@ -19,3 +19,5 @@ class Site(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) last_seen_at = Column(DateTime(timezone=True), nullable=True) last_seen_ip = Column(String, nullable=True) + last_seen_local_ip = Column(String, nullable=True) + waiter_domain = Column(String, nullable=True) diff --git a/cloud_backend/routers/heartbeat.py b/cloud_backend/routers/heartbeat.py index feefcfa..eeef21b 100644 --- a/cloud_backend/routers/heartbeat.py +++ b/cloud_backend/routers/heartbeat.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Header, Request, status from passlib.context import CryptContext from sqlalchemy.orm import Session +from config import settings from database import get_db from models.site import Site from schemas.site import HeartbeatRequest, HeartbeatResponse @@ -26,6 +27,8 @@ def heartbeat( now = datetime.now(timezone.utc) site.last_seen_at = now site.last_seen_ip = request.client.host if request.client else None + if body.local_ip: + site.last_seen_local_ip = body.local_ip db.commit() licensed = site.is_active and (site.license_expires_at.replace(tzinfo=timezone.utc) > now) @@ -34,4 +37,6 @@ def heartbeat( locked=site.is_locked, lock_reason=site.lock_reason, expires_at=site.license_expires_at, + latest_version=settings.LATEST_VERSION, + waiter_domain=site.waiter_domain, ) diff --git a/cloud_backend/schemas/site.py b/cloud_backend/schemas/site.py index b7e8f55..257f92b 100644 --- a/cloud_backend/schemas/site.py +++ b/cloud_backend/schemas/site.py @@ -14,6 +14,7 @@ class SiteUpdate(BaseModel): owner_name: str | None = None contact_email: str | None = None license_expires_at: datetime | None = None + waiter_domain: str | None = None class SiteOut(BaseModel): @@ -29,6 +30,8 @@ class SiteOut(BaseModel): created_at: datetime last_seen_at: datetime | None last_seen_ip: str | None + last_seen_local_ip: str | None + waiter_domain: str | None model_config = {"from_attributes": True} @@ -44,6 +47,7 @@ class LockRequest(BaseModel): class HeartbeatRequest(BaseModel): version: str = "1.0.0" uptime_seconds: int = 0 + local_ip: str | None = None class HeartbeatResponse(BaseModel): @@ -51,3 +55,5 @@ class HeartbeatResponse(BaseModel): locked: bool lock_reason: str | None expires_at: datetime + latest_version: str | None = None + waiter_domain: str | None = None diff --git a/sysadmin_panel/src/pages/SiteDetailPage.jsx b/sysadmin_panel/src/pages/SiteDetailPage.jsx index 76daf55..5a7be57 100644 --- a/sysadmin_panel/src/pages/SiteDetailPage.jsx +++ b/sysadmin_panel/src/pages/SiteDetailPage.jsx @@ -26,9 +26,10 @@ export default function SiteDetailPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState('') - const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' + const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain' const [lockReason, setLockReason] = useState('') const [newExpiry, setNewExpiry] = useState('') + const [newDomain, setNewDomain] = useState('') const [acting, setActing] = useState(false) useEffect(() => { @@ -93,6 +94,22 @@ export default function SiteDetailPage() { } } + async function doSaveDomain() { + setActing(true) + try { + const { data } = await client.put(`/api/sites/${siteId}`, { + waiter_domain: newDomain.trim() || null, + }) + setSite(data) + setModal(null) + toast.success('Waiter domain updated') + } catch (e) { + toast.error(e.response?.data?.detail || 'Failed to update domain') + } finally { + setActing(false) + } + } + async function doDelete() { setActing(true) try { @@ -182,9 +199,32 @@ export default function SiteDetailPage() { {fmtDt(site.last_seen_at)}
- Last IP + Public IP (WAN) {site.last_seen_ip || '—'}
+
+ Local IP (LAN) + {site.last_seen_local_ip || '—'} +
+ + + + {/* Waiter Domain */} +
+
+

Waiter Domain

+ +
+
+ {site.waiter_domain + ? {site.waiter_domain} + : Not set + }
@@ -288,6 +328,27 @@ export default function SiteDetailPage() { onConfirm={doDelete} /> )} + + {modal === 'domain' && ( + setModal(null)} + onConfirm={doSaveDomain} + > +
+ + setNewDomain(e.target.value)} + placeholder="http://xenia-pos.example.gr" + className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600" + /> +

Leave empty to clear the domain.

+
+
+ )} ) }