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 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 14:06:36 +03:00
parent bbbd421aec
commit 06b01533d4
4 changed files with 76 additions and 2 deletions

View File

@@ -19,3 +19,5 @@ class Site(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
last_seen_at = Column(DateTime(timezone=True), nullable=True) last_seen_at = Column(DateTime(timezone=True), nullable=True)
last_seen_ip = Column(String, nullable=True) last_seen_ip = Column(String, nullable=True)
last_seen_local_ip = Column(String, nullable=True)
waiter_domain = Column(String, nullable=True)

View File

@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Header, Request, status
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from config import settings
from database import get_db from database import get_db
from models.site import Site from models.site import Site
from schemas.site import HeartbeatRequest, HeartbeatResponse from schemas.site import HeartbeatRequest, HeartbeatResponse
@@ -26,6 +27,8 @@ def heartbeat(
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
site.last_seen_at = now site.last_seen_at = now
site.last_seen_ip = request.client.host if request.client else None 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() db.commit()
licensed = site.is_active and (site.license_expires_at.replace(tzinfo=timezone.utc) > now) licensed = site.is_active and (site.license_expires_at.replace(tzinfo=timezone.utc) > now)
@@ -34,4 +37,6 @@ def heartbeat(
locked=site.is_locked, locked=site.is_locked,
lock_reason=site.lock_reason, lock_reason=site.lock_reason,
expires_at=site.license_expires_at, expires_at=site.license_expires_at,
latest_version=settings.LATEST_VERSION,
waiter_domain=site.waiter_domain,
) )

View File

@@ -14,6 +14,7 @@ class SiteUpdate(BaseModel):
owner_name: str | None = None owner_name: str | None = None
contact_email: str | None = None contact_email: str | None = None
license_expires_at: datetime | None = None license_expires_at: datetime | None = None
waiter_domain: str | None = None
class SiteOut(BaseModel): class SiteOut(BaseModel):
@@ -29,6 +30,8 @@ class SiteOut(BaseModel):
created_at: datetime created_at: datetime
last_seen_at: datetime | None last_seen_at: datetime | None
last_seen_ip: str | None last_seen_ip: str | None
last_seen_local_ip: str | None
waiter_domain: str | None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -44,6 +47,7 @@ class LockRequest(BaseModel):
class HeartbeatRequest(BaseModel): class HeartbeatRequest(BaseModel):
version: str = "1.0.0" version: str = "1.0.0"
uptime_seconds: int = 0 uptime_seconds: int = 0
local_ip: str | None = None
class HeartbeatResponse(BaseModel): class HeartbeatResponse(BaseModel):
@@ -51,3 +55,5 @@ class HeartbeatResponse(BaseModel):
locked: bool locked: bool
lock_reason: str | None lock_reason: str | None
expires_at: datetime expires_at: datetime
latest_version: str | None = None
waiter_domain: str | None = None

View File

@@ -26,9 +26,10 @@ export default function SiteDetailPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') 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 [lockReason, setLockReason] = useState('')
const [newExpiry, setNewExpiry] = useState('') const [newExpiry, setNewExpiry] = useState('')
const [newDomain, setNewDomain] = useState('')
const [acting, setActing] = useState(false) const [acting, setActing] = useState(false)
useEffect(() => { 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() { async function doDelete() {
setActing(true) setActing(true)
try { try {
@@ -182,9 +199,32 @@ export default function SiteDetailPage() {
<span className="text-gray-300">{fmtDt(site.last_seen_at)}</span> <span className="text-gray-300">{fmtDt(site.last_seen_at)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">Last IP</span> <span className="text-gray-500">Public IP (WAN)</span>
<span className="text-gray-300 font-mono text-xs">{site.last_seen_ip || '—'}</span> <span className="text-gray-300 font-mono text-xs">{site.last_seen_ip || '—'}</span>
</div> </div>
<div className="flex justify-between">
<span className="text-gray-500">Local IP (LAN)</span>
<span className="text-gray-300 font-mono text-xs">{site.last_seen_local_ip || '—'}</span>
</div>
</div>
</div>
{/* Waiter Domain */}
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mb-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Waiter Domain</h2>
<button
onClick={() => { setNewDomain(site.waiter_domain || ''); setModal('domain') }}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
>
Edit
</button>
</div>
<div className="text-sm">
{site.waiter_domain
? <span className="text-gray-300 font-mono text-xs">{site.waiter_domain}</span>
: <span className="text-gray-600 italic">Not set</span>
}
</div> </div>
</div> </div>
@@ -288,6 +328,27 @@ export default function SiteDetailPage() {
onConfirm={doDelete} onConfirm={doDelete}
/> />
)} )}
{modal === 'domain' && (
<ConfirmModal
title="Waiter Domain"
confirmLabel={acting ? 'Saving…' : 'Save'}
onCancel={() => setModal(null)}
onConfirm={doSaveDomain}
>
<div className="mb-2">
<label className="block text-xs text-gray-400 mb-1.5">Domain URL (e.g. http://xenia-pos.example.gr)</label>
<input
type="text"
value={newDomain}
onChange={e => 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"
/>
<p className="text-xs text-gray-500 mt-1.5">Leave empty to clear the domain.</p>
</div>
</ConfirmModal>
)}
</div> </div>
) )
} }