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,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))
|
||||
|
||||
@@ -137,8 +174,9 @@ export function TvWelcomePage() {
|
||||
{/* Tabs */}
|
||||
<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: '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 будет сам забирать данные о бронях каждые 30–60 секунд.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user