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:
@@ -1,5 +1,6 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify'
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
|
import { captured } from './travelline'
|
||||||
|
|
||||||
type SlugParam = { Params: { slug: string } }
|
type SlugParam = { Params: { slug: string } }
|
||||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||||
@@ -237,8 +238,32 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Внутренний хелпер: вызывается из bookings route при смене статуса ───
|
// ── GET /api/hotels/:slug/netup/log — просмотр запросов от NetUP ──────────
|
||||||
// Экспортируем для использования в bookings.ts
|
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 ──────────────
|
// ── Отдельная функция для check-in/check-out из bookings.ts ──────────────
|
||||||
|
|||||||
@@ -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.
|
* NetUP polls this URL every N minutes (configured as "TravelLine" integration type in NetUP).
|
||||||
* Phase 2 — real implementation: respond with actual reservation/room data.
|
* We log every request so we can see exactly what NetUP sends, and respond with active bookings.
|
||||||
*
|
*
|
||||||
* Configure in NetUP:
|
* Configure in NetUP:
|
||||||
* Integration type: TravelLine
|
* Integration type: TravelLine
|
||||||
* API URL: https://api.hotelsync.ru/travelline
|
* API URL: https://api.hotelsync.ru/netup-pms
|
||||||
* Token: <any token stored in netup_settings for the hotel>
|
* Token: <tl_token from netup_settings>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FastifyPluginAsync } from 'fastify'
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
import { db } from '../db'
|
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 MAX_CAPTURE = 100
|
||||||
const captured: {
|
export const captured: {
|
||||||
ts: string
|
ts: string
|
||||||
method: string
|
method: string
|
||||||
url: string
|
url: string
|
||||||
@@ -24,126 +24,62 @@ const captured: {
|
|||||||
body: unknown
|
body: unknown
|
||||||
}[] = []
|
}[] = []
|
||||||
|
|
||||||
const travelline: FastifyPluginAsync = async (fastify) => {
|
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,
|
||||||
|
query: request.query as Record<string, unknown>,
|
||||||
|
body: request.body,
|
||||||
|
})
|
||||||
|
if (captured.length > MAX_CAPTURE) captured.shift()
|
||||||
|
}
|
||||||
|
|
||||||
// ── GET /travelline/_log — view captured requests (auth required) ──────────
|
const netupPms: FastifyPluginAsync = async (fastify) => {
|
||||||
fastify.get(
|
|
||||||
'/travelline/_log',
|
|
||||||
{ onRequest: [fastify.authenticate] },
|
|
||||||
async () => ({ count: captured.length, requests: captured }),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── DELETE /travelline/_log — clear capture buffer ────────────────────────
|
// Catch /netup-pms and /netup-pms/* — everything NetUP might call
|
||||||
fastify.delete(
|
for (const pattern of ['/netup-pms', '/netup-pms/*']) {
|
||||||
'/travelline/_log',
|
fastify.all(pattern, async (request, reply) => {
|
||||||
{ onRequest: [fastify.authenticate] },
|
recordRequest(request as Parameters<typeof recordRequest>[0])
|
||||||
async () => { captured.length = 0; return { ok: true } },
|
fastify.log.info({ method: request.method, url: request.url }, 'NetUP PMS poll')
|
||||||
)
|
|
||||||
|
|
||||||
// ── 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 = {
|
|
||||||
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')
|
|
||||||
|
|
||||||
// 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)')
|
|
||||||
|
|
||||||
const token = extractToken(request)
|
const token = extractToken(request)
|
||||||
if (!token) return reply.code(401).send({ error: 'Unauthorized' })
|
if (!token) return reply.code(401).send({ error: 'Unauthorized' })
|
||||||
|
|
||||||
const { rows: [row] } = await db.query(
|
const { rows: [row] } = await db.query(
|
||||||
`SELECT hs.hotel_id, h.slug
|
`SELECT hs.hotel_id FROM netup_settings hs WHERE hs.tl_token = $1`,
|
||||||
FROM netup_settings hs
|
|
||||||
JOIN hotels h ON h.id = hs.hotel_id
|
|
||||||
WHERE hs.tl_token = $1`,
|
|
||||||
[token],
|
[token],
|
||||||
).catch(() => ({ rows: [] as { hotel_id: string; slug: string }[] }))
|
).catch(() => ({ rows: [] as { hotel_id: string }[] }))
|
||||||
|
|
||||||
if (!row) return reply.send(buildEmptyResponse())
|
if (!row) return reply.send(buildEmptyResponse())
|
||||||
|
|
||||||
return reply.send(await buildResponse(row.hotel_id))
|
return reply.send(await buildResponse(row.hotel_id))
|
||||||
},
|
})
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function extractToken(request: { headers: Record<string, string | string[] | undefined>; query: unknown }): string | null {
|
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
|
const auth = request.headers['authorization'] as string | undefined
|
||||||
if (auth?.startsWith('Bearer ')) return auth.slice(7)
|
if (auth?.startsWith('Bearer ')) return auth.slice(7)
|
||||||
// Try Authorization: Token <token>
|
if (auth?.startsWith('Token ')) return auth.slice(6)
|
||||||
if (auth?.startsWith('Token ')) return auth.slice(6)
|
|
||||||
// Try query param ?token=... or ?api_key=...
|
|
||||||
const q = request.query as Record<string, string>
|
const q = request.query as Record<string, string>
|
||||||
return q?.token ?? q?.api_key ?? null
|
return q?.token ?? q?.api_key ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEmptyResponse() {
|
function buildEmptyResponse() {
|
||||||
// Return a structure that looks like a valid TravelLine/PMS response with zero data
|
return { success: true, reservations: [], rooms: [] }
|
||||||
// so NetUP won't crash. We'll update this once we see what format NetUP actually expects.
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
reservations: [],
|
|
||||||
rooms: [],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildResponse(hotelId: string) {
|
async function buildResponse(hotelId: string) {
|
||||||
// Fetch active bookings
|
|
||||||
const { rows: bookings } = await db.query(
|
const { rows: bookings } = await db.query(
|
||||||
`SELECT b.id, b.guest_name, b.guest_email, b.guest_phone,
|
`SELECT b.id, b.guest_name, b.guest_email, b.guest_phone,
|
||||||
b.check_in, b.check_out, b.status, b.adults, b.children,
|
b.check_in, b.check_out, b.status, b.adults, b.children,
|
||||||
@@ -159,21 +95,21 @@ async function buildResponse(hotelId: string) {
|
|||||||
[hotelId],
|
[hotelId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const reservations = bookings.map(b => ({
|
const reservations = bookings.map((b: Record<string, unknown>) => ({
|
||||||
id: b.id,
|
id: b.id,
|
||||||
status: b.status === 'checked_in' ? 'CheckedIn' : 'Confirmed',
|
status: b.status === 'checked_in' ? 'CheckedIn' : 'Confirmed',
|
||||||
guestName: b.guest_name,
|
guestName: b.guest_name,
|
||||||
guestEmail: b.guest_email ?? '',
|
guestEmail: b.guest_email ?? '',
|
||||||
guestPhone: b.guest_phone ?? '',
|
guestPhone: b.guest_phone ?? '',
|
||||||
roomNumber: b.netup_room_number ?? b.room_number,
|
roomNumber: b.netup_room_number ?? b.room_number,
|
||||||
pmsRoomId: b.room_id,
|
pmsRoomId: b.room_id,
|
||||||
checkIn: b.check_in,
|
checkIn: b.check_in,
|
||||||
checkOut: b.check_out,
|
checkOut: b.check_out,
|
||||||
adults: b.adults,
|
adults: b.adults,
|
||||||
children: b.children ?? 0,
|
children: b.children ?? 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return { success: true, reservations }
|
return { success: true, reservations }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default travelline
|
export default netupPms
|
||||||
|
|||||||
@@ -287,6 +287,13 @@ export const api = {
|
|||||||
|
|
||||||
sendMessage: (slug: string, roomId: string, message: string, guestName?: string) =>
|
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 }),
|
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`),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Tv2, Plug, Map, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, Copy, Link2 } from 'lucide-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 { cn } from '../lib/utils'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
type Tab = 'connection' | 'rooms'
|
type Tab = 'connection' | 'rooms' | 'log'
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
{ value: 'ru_RU', label: 'Русский' },
|
{ value: 'ru_RU', label: 'Русский' },
|
||||||
@@ -14,6 +14,17 @@ const LANGUAGES = [
|
|||||||
{ value: 'ar_AE', label: 'العربية' },
|
{ 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() {
|
export function TvWelcomePage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const slug = user?.hotelSlug ?? ''
|
const slug = user?.hotelSlug ?? ''
|
||||||
@@ -39,6 +50,11 @@ export function TvWelcomePage() {
|
|||||||
const [roomsSaving, setRoomsSaving] = useState(false)
|
const [roomsSaving, setRoomsSaving] = useState(false)
|
||||||
const [roomsSaved, setRoomsSaved] = 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
|
// Load settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
@@ -62,6 +78,21 @@ export function TvWelcomePage() {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [tab, slug])
|
}, [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 () => {
|
const handleSaveConnection = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setTestResult(null)
|
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) =>
|
const updateRoomNetup = (id: string, val: string) =>
|
||||||
setRooms(prev => prev.map(r => r.id === id ? { ...r, netupRoomNumber: val } : r))
|
setRooms(prev => prev.map(r => r.id === id ? { ...r, netupRoomNumber: val } : r))
|
||||||
|
|
||||||
@@ -137,8 +174,9 @@ export function TvWelcomePage() {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-800 rounded-xl w-fit">
|
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-800 rounded-xl w-fit">
|
||||||
{([
|
{([
|
||||||
{ id: 'connection', label: 'Подключение', icon: Plug },
|
{ id: 'connection', label: 'Подключение', icon: Plug },
|
||||||
{ id: 'rooms', label: 'Сопоставление номеров', icon: Map },
|
{ id: 'rooms', label: 'Сопоставление номеров', icon: Map },
|
||||||
|
{ id: 'log', label: 'Диагностика', icon: Activity },
|
||||||
] as const).map(t => (
|
] as const).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
@@ -251,14 +289,15 @@ export function TvWelcomePage() {
|
|||||||
|
|
||||||
<hr className="border-slate-100 dark:border-slate-700" />
|
<hr className="border-slate-100 dark:border-slate-700" />
|
||||||
|
|
||||||
{/* TravelLine / Pull integration */}
|
{/* Pull integration */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">
|
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">
|
||||||
Интеграция через TravelLine (pull-режим)
|
Pull-интеграция (NetUP опрашивает наш сервер)
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
Альтернативный метод: NetUP сам опрашивает наш сервер. Настройте в NetUP тип интеграции «TravelLine» и укажите адрес ниже.
|
Настройте в NetUP тип интеграции «TravelLine» — укажите адрес ниже и токен.
|
||||||
|
NetUP будет сам забирать данные о бронях каждые 30–60 секунд.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -266,19 +305,19 @@ export function TvWelcomePage() {
|
|||||||
<div className="space-y-1.5">
|
<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">
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
|
||||||
<Link2 size={13} />
|
<Link2 size={13} />
|
||||||
API URL для NetUP
|
API URL (вставить в NetUP)
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
readOnly
|
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"
|
className="input w-full font-mono text-xs bg-slate-50 dark:bg-slate-700/50 text-slate-500 dark:text-slate-400"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText('https://api.hotelsync.ru/travelline')
|
navigator.clipboard.writeText(PMS_API_URL)
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
}}
|
}}
|
||||||
@@ -363,15 +402,9 @@ export function TvWelcomePage() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-100 dark:border-slate-700">
|
<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">
|
<th className="px-5 py-3 text-left font-medium text-slate-500 dark:text-slate-400">Номер в PMS</th>
|
||||||
Номер в PMS
|
<th className="px-5 py-3 text-left font-medium text-slate-500 dark:text-slate-400">Тип</th>
|
||||||
</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">
|
|
||||||
Тип
|
|
||||||
</th>
|
|
||||||
<th className="px-5 py-3 text-left font-medium text-slate-500 dark:text-slate-400">
|
|
||||||
Номер в NetUP
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -383,12 +416,8 @@ export function TvWelcomePage() {
|
|||||||
i % 2 === 0 ? '' : 'bg-slate-50/50 dark:bg-slate-700/20',
|
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">
|
<td className="px-5 py-3 font-medium text-slate-900 dark:text-slate-100">{room.number}</td>
|
||||||
{room.number}
|
<td className="px-5 py-3 text-slate-500 dark:text-slate-400">{room.type}</td>
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3 text-slate-500 dark:text-slate-400">
|
|
||||||
{room.type}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -421,6 +450,123 @@ export function TvWelcomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user