Improve NetUP diagnostics: push event log + test check-in button

- notifyNetupCheckin/Checkout now log every attempt (ok/error/skipped)
  with room number, NetUP room, URL, HTTP status, error message
- Push log exposed via GET /netup/log alongside pull request log
- New POST /netup/test-checkin: manually trigger a test check-in from UI
- Diagnostics tab split into two sections:
  - Outgoing (push): table of check-in/check-out events with status dots
  - Incoming (pull): NetUP poll requests (if TravelLine integration works)
- Test button picks first mapped room and fires a real check-in call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:19:48 +03:00
parent 142be49211
commit 44fa9d24f1
3 changed files with 314 additions and 134 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { Tv2, Plug, Map, Activity, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, Copy, Link2, RefreshCw, Trash2 } from 'lucide-react'
import { Tv2, Plug, Map, Activity, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, Copy, Link2, RefreshCw, Trash2, ArrowUpRight, ArrowDownLeft } from 'lucide-react'
import { cn } from '../lib/utils'
import { api } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
@@ -16,7 +16,7 @@ const LANGUAGES = [
const PMS_API_URL = 'https://api.hotelsync.ru/netup-pms'
type LogEntry = {
type PullEntry = {
ts: string
method: string
url: string
@@ -25,6 +25,17 @@ type LogEntry = {
body: unknown
}
type PushEvent = {
ts: string
action: string
roomNumber: string
netupRoom: string
url: string
status: 'ok' | 'error' | 'skipped'
httpStatus?: number
error?: string
}
export function TvWelcomePage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
@@ -51,9 +62,13 @@ export function TvWelcomePage() {
const [roomsSaved, setRoomsSaved] = useState(false)
// Log
const [logEntries, setLogEntries] = useState<LogEntry[]>([])
const [logLoading, setLogLoading] = useState(false)
const [selectedEntry, setSelectedEntry] = useState<LogEntry | null>(null)
const [pushEvents, setPushEvents] = useState<PushEvent[]>([])
const [pullEntries, setPullEntries] = useState<PullEntry[]>([])
const [logLoading, setLogLoading] = useState(false)
const [selectedPull, setSelectedPull] = useState<PullEntry | null>(null)
// Test check-in
const [testingCheckin, setTestingCheckin] = useState(false)
const [testCheckinResult, setTestCheckinResult] = useState<{ ok: boolean; msg: string } | null>(null)
// Load settings
useEffect(() => {
@@ -70,20 +85,24 @@ export function TvWelcomePage() {
.catch(() => setSettingsLoaded(true))
}, [slug])
// Load room mappings when tab switches
// Load room mappings when tab switches (also needed on 'log' for test button)
useEffect(() => {
if (tab !== 'rooms' || !slug) return
if ((tab !== 'rooms' && tab !== 'log') || !slug) return
if (rooms.length > 0) return // already loaded
api.netup.getRoomMappings(slug)
.then(setRooms)
.catch(() => {})
}, [tab, slug])
}, [tab, slug, rooms.length])
// Load log when tab switches
const loadLog = useCallback(() => {
if (!slug) return
setLogLoading(true)
api.netup.getLog(slug)
.then(r => setLogEntries(r.requests))
.then(r => {
setPushEvents(r.pushEvents)
setPullEntries(r.pullRequests)
})
.catch(() => {})
.finally(() => setLogLoading(false))
}, [slug])
@@ -150,8 +169,28 @@ export function TvWelcomePage() {
const handleClearLog = async () => {
await api.netup.clearLog(slug).catch(() => {})
setLogEntries([])
setSelectedEntry(null)
setPushEvents([])
setPullEntries([])
setSelectedPull(null)
}
const handleTestCheckin = async () => {
if (!rooms.length) return
// pick first room that has a netup mapping
const room = rooms.find(r => r.netupRoomNumber) ?? rooms[0]
setTestingCheckin(true)
setTestCheckinResult(null)
try {
const res = await api.netup.testCheckin(slug, room.id)
setTestCheckinResult({ ok: true, msg: res.message })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка'
setTestCheckinResult({ ok: false, msg })
} finally {
setTestingCheckin(false)
// Refresh log after test
setTimeout(loadLog, 500)
}
}
const updateRoomNetup = (id: string, val: string) =>
@@ -454,29 +493,26 @@ export function TvWelcomePage() {
{/* ── Tab: Diagnostics / Log ───────────────────────────────────────────── */}
{tab === 'log' && (
<div className="space-y-4">
{/* ── Push events (наш сервер → NetUP) ── */}
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-5">
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">Входящие запросы от NetUP</p>
<p className="font-medium text-slate-900 dark:text-slate-100 flex items-center gap-2">
<ArrowUpRight size={15} className="text-violet-500" />
Исходящие вызовы в NetUP (push)
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Здесь отображаются все запросы, которые NetUP отправил на наш сервер (pull-режим).
Нажмите на строку увидите детали запроса.
Заселения и выезды, которые мы отправляли в NetUP
</p>
</div>
<div className="flex gap-2">
<button
onClick={loadLog}
disabled={logLoading}
className="btn-secondary flex items-center gap-1.5 text-sm"
>
<button onClick={loadLog} disabled={logLoading} className="btn-secondary flex items-center gap-1.5 text-sm">
{logLoading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
Обновить
</button>
{logEntries.length > 0 && (
<button
onClick={handleClearLog}
className="btn-secondary flex items-center gap-1.5 text-sm text-red-500 dark:text-red-400"
>
{(pushEvents.length > 0 || pullEntries.length > 0) && (
<button onClick={handleClearLog} className="btn-secondary flex items-center gap-1.5 text-sm text-red-500 dark:text-red-400">
<Trash2 size={13} />
Очистить
</button>
@@ -484,87 +520,119 @@ export function TvWelcomePage() {
</div>
</div>
{logEntries.length === 0 ? (
<div className="py-10 text-center">
<Activity size={32} className="mx-auto mb-3 text-slate-300 dark:text-slate-600" />
<p className="text-sm text-slate-500 dark:text-slate-400">Запросов ещё не было</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
Настройте NetUP (тип TravelLine, URL: <span className="font-mono">{PMS_API_URL}</span>)
и нажмите «Обновить» через минуту
</p>
</div>
{/* Test check-in button */}
<div className="mb-4 pb-4 border-b border-slate-100 dark:border-slate-700 flex items-center gap-3 flex-wrap">
<button
onClick={handleTestCheckin}
disabled={testingCheckin || rooms.length === 0}
className="btn-secondary flex items-center gap-2 text-sm"
>
{testingCheckin ? <Loader2 size={13} className="animate-spin" /> : <Plug size={13} />}
Тест: отправить заселение в NetUP
</button>
{rooms.length === 0 && (
<p className="text-xs text-slate-400">Сначала настройте сопоставление номеров</p>
)}
{testCheckinResult && (
<span className={cn('flex items-center gap-1.5 text-sm', testCheckinResult.ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
{testCheckinResult.ok ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
{testCheckinResult.msg}
</span>
)}
</div>
{pushEvents.length === 0 ? (
<p className="text-sm text-slate-400 dark:text-slate-500 py-4 text-center">
Исходящих вызовов ещё не было. Измените статус брони на «Заселён» или нажмите «Тест» выше.
</p>
) : (
<div className="divide-y divide-slate-100 dark:divide-slate-700">
{logEntries.map((entry, i) => (
<button
key={i}
onClick={() => setSelectedEntry(selectedEntry === entry ? null : entry)}
className={cn(
'w-full text-left px-3 py-2.5 rounded-lg transition-colors text-sm',
selectedEntry === entry
? 'bg-violet-50 dark:bg-violet-900/20'
: 'hover:bg-slate-50 dark:hover:bg-slate-700/40',
)}
>
<div className="flex items-center gap-3">
<span className="font-mono text-xs font-bold text-violet-600 dark:text-violet-400 w-12 shrink-0">
{entry.method}
</span>
<span className="font-mono text-xs text-slate-600 dark:text-slate-300 truncate flex-1">
{entry.url}
</span>
<span className="text-xs text-slate-400 dark:text-slate-500 shrink-0">
{new Date(entry.ts).toLocaleTimeString('ru-RU')}
</span>
</div>
</button>
<div className="divide-y divide-slate-100 dark:divide-slate-700/50">
{pushEvents.map((e, i) => (
<div key={i} className="flex items-center gap-3 py-2.5 px-1 text-sm">
<span className={cn(
'w-2 h-2 rounded-full shrink-0',
e.status === 'ok' ? 'bg-emerald-500' : e.status === 'skipped' ? 'bg-amber-400' : 'bg-red-500',
)} />
<span className="font-medium text-slate-700 dark:text-slate-200 w-20 shrink-0">
{e.action === 'check-in' ? 'Заселение' : 'Выезд'}
</span>
<span className="text-slate-500 dark:text-slate-400">
Номер <span className="font-medium text-slate-700 dark:text-slate-200">{e.roomNumber}</span>
{e.netupRoom && <> NetUP <span className="font-mono text-xs">{e.netupRoom}</span></>}
</span>
{e.error && <span className="text-red-500 dark:text-red-400 text-xs flex-1 truncate">{e.error}</span>}
{e.httpStatus && e.status === 'ok' && <span className="text-xs text-emerald-600 dark:text-emerald-400">HTTP {e.httpStatus}</span>}
<span className="ml-auto text-xs text-slate-400 shrink-0">
{new Date(e.ts).toLocaleTimeString('ru-RU')}
</span>
</div>
))}
</div>
)}
</div>
{/* Selected entry details */}
{selectedEntry && (
<div className="bg-slate-900 dark:bg-slate-950 rounded-2xl p-5 space-y-4 text-xs font-mono">
<div>
<p className="text-slate-400 mb-1 font-sans text-xs font-semibold uppercase tracking-wide">Время</p>
<p className="text-slate-200">{new Date(selectedEntry.ts).toLocaleString('ru-RU')}</p>
{/* ── Pull requests (NetUP → наш сервер) ── */}
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-5">
<p className="font-medium text-slate-900 dark:text-slate-100 flex items-center gap-2 mb-1">
<ArrowDownLeft size={15} className="text-blue-500" />
Входящие запросы от NetUP (pull)
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-3">
Запросы, которые NetUP отправлял на <span className="font-mono">{PMS_API_URL}</span>
</p>
{pullEntries.length === 0 ? (
<p className="text-sm text-slate-400 dark:text-slate-500 py-4 text-center">
Входящих запросов не было
</p>
) : (
<div className="divide-y divide-slate-100 dark:divide-slate-700/50">
{pullEntries.map((entry, i) => (
<button
key={i}
onClick={() => setSelectedPull(selectedPull === entry ? null : entry)}
className={cn(
'w-full text-left px-2 py-2.5 rounded-lg transition-colors text-sm',
selectedPull === entry ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-slate-50 dark:hover:bg-slate-700/40',
)}
>
<div className="flex items-center gap-3">
<span className="font-mono text-xs font-bold text-blue-600 dark:text-blue-400 w-10 shrink-0">{entry.method}</span>
<span className="font-mono text-xs text-slate-600 dark:text-slate-300 truncate flex-1">{entry.url}</span>
<span className="text-xs text-slate-400 shrink-0">{new Date(entry.ts).toLocaleTimeString('ru-RU')}</span>
</div>
</button>
))}
</div>
<div>
<p className="text-slate-400 mb-1 font-sans text-xs font-semibold uppercase tracking-wide">URL</p>
<p className="text-emerald-400">{selectedEntry.method} {selectedEntry.url}</p>
</div>
{Object.keys(selectedEntry.query ?? {}).length > 0 && (
)}
{selectedPull && (
<div className="mt-3 bg-slate-900 dark:bg-slate-950 rounded-xl p-4 space-y-3 text-xs font-mono">
<p className="text-emerald-400">{selectedPull.method} {selectedPull.url}</p>
{Object.keys(selectedPull.query ?? {}).length > 0 && (
<div>
<p className="text-slate-400 font-sans text-xs font-semibold mb-1">Query</p>
<pre className="text-slate-200 whitespace-pre-wrap break-all">{JSON.stringify(selectedPull.query, null, 2)}</pre>
</div>
)}
<div>
<p className="text-slate-400 mb-1 font-sans text-xs font-semibold uppercase tracking-wide">Query параметры</p>
<pre className="text-slate-200 whitespace-pre-wrap break-all">
{JSON.stringify(selectedEntry.query, null, 2)}
<p className="text-slate-400 font-sans text-xs font-semibold mb-1">Заголовки</p>
<pre className="text-slate-300 whitespace-pre-wrap break-all">
{JSON.stringify(
Object.fromEntries(Object.entries(selectedPull.headers).filter(([k]) => !['host','connection','accept-encoding'].includes(k))),
null, 2
)}
</pre>
</div>
)}
<div>
<p className="text-slate-400 mb-1 font-sans text-xs font-semibold uppercase tracking-wide">Заголовки</p>
<pre className="text-slate-300 whitespace-pre-wrap break-all">
{JSON.stringify(
Object.fromEntries(
Object.entries(selectedEntry.headers).filter(([k]) =>
!['host', 'connection', 'accept-encoding'].includes(k)
)
),
null, 2
)}
</pre>
{selectedPull.body != null && (
<div>
<p className="text-slate-400 font-sans text-xs font-semibold mb-1">Body</p>
<pre className="text-slate-200 whitespace-pre-wrap break-all">{JSON.stringify(selectedPull.body, null, 2)}</pre>
</div>
)}
</div>
{selectedEntry.body != null && (
<div>
<p className="text-slate-400 mb-1 font-sans text-xs font-semibold uppercase tracking-wide">Тело запроса</p>
<pre className="text-slate-200 whitespace-pre-wrap break-all">
{JSON.stringify(selectedEntry.body, null, 2)}
</pre>
</div>
)}
</div>
)}
)}
</div>
</div>
)}
</div>