Add Diagnostics tab to TV Welcome: view NetUP poll requests in UI

- Rename pull endpoint /travelline → /netup-pms (cleaner branding)
- Add GET/DELETE /api/hotels/:slug/netup/log endpoints
- TvWelcomePage: new 'Диагностика' tab shows incoming NetUP requests
  with method, URL, timestamp list + detail panel (headers, query, body)
- Remove 'TravelLine' wording from UI, replace with 'Pull-интеграция'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 19:08:57 +03:00
parent 8350309645
commit 142be49211
4 changed files with 254 additions and 140 deletions

View File

@@ -1,5 +1,6 @@
import type { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { captured } from './travelline'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
@@ -237,8 +238,32 @@ const netup: FastifyPluginAsync = async (fastify) => {
},
)
// ── Внутренний хелпер: вызывается из bookings route при смене статуса ───
// Экспортируем для использования в bookings.ts
// ── GET /api/hotels/:slug/netup/log — просмотр запросов от NetUP ──────────
fastify.get<SlugParam>(
'/api/hotels/:slug/netup/log',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
return { count: captured.length, requests: [...captured].reverse() }
},
)
// ── DELETE /api/hotels/:slug/netup/log — очистить лог ────────────────────
fastify.delete<SlugParam>(
'/api/hotels/:slug/netup/log',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
captured.length = 0
return { ok: true }
},
)
}
// ── Отдельная функция для check-in/check-out из bookings.ts ──────────────

View File

@@ -1,21 +1,21 @@
/**
* TravelLine WebPMS compatibility layer for NetUP IPTV integration.
* NetUP PMS pull-integration endpoint.
*
* Phase 1 — request capture: log everything NetUP sends so we can reverse-engineer the format.
* Phase 2 — real implementation: respond with actual reservation/room data.
* NetUP polls this URL every N minutes (configured as "TravelLine" integration type in NetUP).
* We log every request so we can see exactly what NetUP sends, and respond with active bookings.
*
* Configure in NetUP:
* Integration type: TravelLine
* API URL: https://api.hotelsync.ru/travelline
* Token: <any token stored in netup_settings for the hotel>
* API URL: https://api.hotelsync.ru/netup-pms
* Token: <tl_token from netup_settings>
*/
import type { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
// In-memory ring buffer — last 100 captured requests
// In-memory ring buffer — last 100 captured requests (shared with netup.ts via export)
const MAX_CAPTURE = 100
const captured: {
export const captured: {
ts: string
method: string
url: string
@@ -24,126 +24,62 @@ const captured: {
body: unknown
}[] = []
const travelline: FastifyPluginAsync = async (fastify) => {
// ── GET /travelline/_log — view captured requests (auth required) ──────────
fastify.get(
'/travelline/_log',
{ onRequest: [fastify.authenticate] },
async () => ({ count: captured.length, requests: captured }),
)
// ── DELETE /travelline/_log — clear capture buffer ────────────────────────
fastify.delete(
'/travelline/_log',
{ onRequest: [fastify.authenticate] },
async () => { captured.length = 0; return { ok: true } },
)
// ── Catch-all: log every request NetUP makes ──────────────────────────────
// NetUP polls: GET /travelline (or subpath) with token in header/query
fastify.all(
'/travelline',
{ config: { rawBody: false } },
async (request, reply) => {
const entry = {
function recordRequest(request: {
method: string
url: string
headers: Record<string, string | string[] | undefined>
query: unknown
body: unknown
}) {
captured.push({
ts: new Date().toISOString(),
method: request.method,
url: request.url,
headers: request.headers as Record<string, string | string[] | undefined>,
headers: request.headers,
query: request.query as Record<string, unknown>,
body: request.body,
}
captured.push(entry)
})
if (captured.length > MAX_CAPTURE) captured.shift()
}
fastify.log.info({ netup_capture: entry }, 'NetUP TravelLine poll')
const netupPms: FastifyPluginAsync = async (fastify) => {
// Find hotel by token
const token = extractToken(request)
if (!token) {
return reply.code(401).send({ error: 'Unauthorized' })
}
const { rows: [row] } = await db.query(
`SELECT hs.hotel_id, h.slug
FROM netup_settings hs
JOIN hotels h ON h.id = hs.hotel_id
WHERE hs.tl_token = $1`,
[token],
).catch(() => ({ rows: [] as { hotel_id: string; slug: string }[] }))
if (!row) {
// Token not found — still respond with empty data so NetUP logs the attempt
return reply.send(buildEmptyResponse())
}
// Build response with current hotel reservations
return reply.send(await buildResponse(row.hotel_id))
},
)
// Also catch paths like /travelline/something
fastify.all(
'/travelline/*',
{ config: { rawBody: false } },
async (request, reply) => {
const entry = {
ts: new Date().toISOString(),
method: request.method,
url: request.url,
headers: request.headers as Record<string, string | string[] | undefined>,
query: request.query as Record<string, unknown>,
body: request.body,
}
captured.push(entry)
if (captured.length > MAX_CAPTURE) captured.shift()
fastify.log.info({ netup_capture: entry }, 'NetUP TravelLine poll (subpath)')
// Catch /netup-pms and /netup-pms/* — everything NetUP might call
for (const pattern of ['/netup-pms', '/netup-pms/*']) {
fastify.all(pattern, async (request, reply) => {
recordRequest(request as Parameters<typeof recordRequest>[0])
fastify.log.info({ method: request.method, url: request.url }, 'NetUP PMS poll')
const token = extractToken(request)
if (!token) return reply.code(401).send({ error: 'Unauthorized' })
const { rows: [row] } = await db.query(
`SELECT hs.hotel_id, h.slug
FROM netup_settings hs
JOIN hotels h ON h.id = hs.hotel_id
WHERE hs.tl_token = $1`,
`SELECT hs.hotel_id FROM netup_settings hs WHERE hs.tl_token = $1`,
[token],
).catch(() => ({ rows: [] as { hotel_id: string; slug: string }[] }))
).catch(() => ({ rows: [] as { hotel_id: string }[] }))
if (!row) return reply.send(buildEmptyResponse())
return reply.send(await buildResponse(row.hotel_id))
},
)
})
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function extractToken(request: { headers: Record<string, string | string[] | undefined>; query: unknown }): string | null {
// Try Authorization: Bearer <token>
const auth = request.headers['authorization'] as string | undefined
if (auth?.startsWith('Bearer ')) return auth.slice(7)
// Try Authorization: Token <token>
if (auth?.startsWith('Token ')) return auth.slice(6)
// Try query param ?token=... or ?api_key=...
const q = request.query as Record<string, string>
return q?.token ?? q?.api_key ?? null
}
function buildEmptyResponse() {
// Return a structure that looks like a valid TravelLine/PMS response with zero data
// so NetUP won't crash. We'll update this once we see what format NetUP actually expects.
return {
success: true,
reservations: [],
rooms: [],
}
return { success: true, reservations: [], rooms: [] }
}
async function buildResponse(hotelId: string) {
// Fetch active bookings
const { rows: bookings } = await db.query(
`SELECT b.id, b.guest_name, b.guest_email, b.guest_phone,
b.check_in, b.check_out, b.status, b.adults, b.children,
@@ -159,7 +95,7 @@ async function buildResponse(hotelId: string) {
[hotelId],
)
const reservations = bookings.map(b => ({
const reservations = bookings.map((b: Record<string, unknown>) => ({
id: b.id,
status: b.status === 'checked_in' ? 'CheckedIn' : 'Confirmed',
guestName: b.guest_name,
@@ -176,4 +112,4 @@ async function buildResponse(hotelId: string) {
return { success: true, reservations }
}
export default travelline
export default netupPms

View File

@@ -287,6 +287,13 @@ export const api = {
sendMessage: (slug: string, roomId: string, message: string, guestName?: string) =>
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/netup/message`, { room_id: roomId, message, guest_name: guestName }),
getLog: (slug: string) =>
req<{ count: number; requests: { ts: string; method: string; url: string; headers: Record<string, unknown>; query: Record<string, unknown>; body: unknown }[] }>(
'GET', `/api/hotels/${slug}/netup/log`),
clearLog: (slug: string) =>
req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/netup/log`),
},
}

View File

@@ -1,10 +1,10 @@
import { useState, useEffect } from 'react'
import { Tv2, Plug, Map, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, Copy, Link2 } from 'lucide-react'
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 { cn } from '../lib/utils'
import { api } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
type Tab = 'connection' | 'rooms'
type Tab = 'connection' | 'rooms' | 'log'
const LANGUAGES = [
{ value: 'ru_RU', label: 'Русский' },
@@ -14,6 +14,17 @@ const LANGUAGES = [
{ value: 'ar_AE', label: 'العربية' },
]
const PMS_API_URL = 'https://api.hotelsync.ru/netup-pms'
type LogEntry = {
ts: string
method: string
url: string
headers: Record<string, unknown>
query: Record<string, unknown>
body: unknown
}
export function TvWelcomePage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
@@ -39,6 +50,11 @@ export function TvWelcomePage() {
const [roomsSaving, setRoomsSaving] = useState(false)
const [roomsSaved, setRoomsSaved] = useState(false)
// Log
const [logEntries, setLogEntries] = useState<LogEntry[]>([])
const [logLoading, setLogLoading] = useState(false)
const [selectedEntry, setSelectedEntry] = useState<LogEntry | null>(null)
// Load settings
useEffect(() => {
if (!slug) return
@@ -62,6 +78,21 @@ export function TvWelcomePage() {
.catch(() => {})
}, [tab, slug])
// Load log when tab switches
const loadLog = useCallback(() => {
if (!slug) return
setLogLoading(true)
api.netup.getLog(slug)
.then(r => setLogEntries(r.requests))
.catch(() => {})
.finally(() => setLogLoading(false))
}, [slug])
useEffect(() => {
if (tab !== 'log') return
loadLog()
}, [tab, loadLog])
const handleSaveConnection = async () => {
setSaving(true)
setTestResult(null)
@@ -117,6 +148,12 @@ export function TvWelcomePage() {
}
}
const handleClearLog = async () => {
await api.netup.clearLog(slug).catch(() => {})
setLogEntries([])
setSelectedEntry(null)
}
const updateRoomNetup = (id: string, val: string) =>
setRooms(prev => prev.map(r => r.id === id ? { ...r, netupRoomNumber: val } : r))
@@ -139,6 +176,7 @@ export function TvWelcomePage() {
{([
{ id: 'connection', label: 'Подключение', icon: Plug },
{ id: 'rooms', label: 'Сопоставление номеров', icon: Map },
{ id: 'log', label: 'Диагностика', icon: Activity },
] as const).map(t => (
<button
key={t.id}
@@ -251,14 +289,15 @@ export function TvWelcomePage() {
<hr className="border-slate-100 dark:border-slate-700" />
{/* TravelLine / Pull integration */}
{/* Pull integration */}
<div className="space-y-3">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">
Интеграция через TravelLine (pull-режим)
Pull-интеграция (NetUP опрашивает наш сервер)
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Альтернативный метод: NetUP сам опрашивает наш сервер. Настройте в NetUP тип интеграции «TravelLine» и укажите адрес ниже.
Настройте в NetUP тип интеграции «TravelLine» укажите адрес ниже и токен.
NetUP будет сам забирать данные о бронях каждые 3060 секунд.
</p>
</div>
@@ -266,19 +305,19 @@ export function TvWelcomePage() {
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
<Link2 size={13} />
API URL для NetUP
API URL (вставить в NetUP)
</label>
<div className="flex gap-2">
<input
type="text"
readOnly
value="https://api.hotelsync.ru/travelline"
value={PMS_API_URL}
className="input w-full font-mono text-xs bg-slate-50 dark:bg-slate-700/50 text-slate-500 dark:text-slate-400"
/>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText('https://api.hotelsync.ru/travelline')
navigator.clipboard.writeText(PMS_API_URL)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
@@ -363,15 +402,9 @@ export function TvWelcomePage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 dark:border-slate-700">
<th className="px-5 py-3 text-left font-medium text-slate-500 dark:text-slate-400">
Номер в PMS
</th>
<th className="px-5 py-3 text-left font-medium text-slate-500 dark:text-slate-400">
Тип
</th>
<th className="px-5 py-3 text-left font-medium text-slate-500 dark:text-slate-400">
Номер в NetUP
</th>
<th className="px-5 py-3 text-left font-medium text-slate-500 dark:text-slate-400">Номер в PMS</th>
<th className="px-5 py-3 text-left font-medium text-slate-500 dark:text-slate-400">Тип</th>
<th className="px-5 py-3 text-left font-medium text-slate-500 dark:text-slate-400">Номер в NetUP</th>
</tr>
</thead>
<tbody>
@@ -383,12 +416,8 @@ export function TvWelcomePage() {
i % 2 === 0 ? '' : 'bg-slate-50/50 dark:bg-slate-700/20',
)}
>
<td className="px-5 py-3 font-medium text-slate-900 dark:text-slate-100">
{room.number}
</td>
<td className="px-5 py-3 text-slate-500 dark:text-slate-400">
{room.type}
</td>
<td className="px-5 py-3 font-medium text-slate-900 dark:text-slate-100">{room.number}</td>
<td className="px-5 py-3 text-slate-500 dark:text-slate-400">{room.type}</td>
<td className="px-5 py-3">
<input
type="text"
@@ -421,6 +450,123 @@ export function TvWelcomePage() {
</div>
</div>
)}
{/* ── Tab: Diagnostics / Log ───────────────────────────────────────────── */}
{tab === 'log' && (
<div className="space-y-4">
<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="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Здесь отображаются все запросы, которые NetUP отправил на наш сервер (pull-режим).
Нажмите на строку увидите детали запроса.
</p>
</div>
<div className="flex gap-2">
<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"
>
<Trash2 size={13} />
Очистить
</button>
)}
</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>
) : (
<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>
)}
</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>
</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 && (
<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)}
</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>
</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>
)
}