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:
@@ -5,6 +5,26 @@ import { captured } from './travelline'
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||
|
||||
// ── Push event log (check-in / check-out calls to NetUP) ─────────────────────
|
||||
export type PushEvent = {
|
||||
ts: string
|
||||
action: 'check-in' | 'check-out' | 'message'
|
||||
hotelId: string
|
||||
roomNumber: string // PMS room number
|
||||
netupRoom: string // NetUP room number
|
||||
url: string
|
||||
status: 'ok' | 'error' | 'skipped'
|
||||
httpStatus?: number
|
||||
error?: string
|
||||
}
|
||||
const MAX_PUSH = 100
|
||||
export const pushLog: PushEvent[] = []
|
||||
|
||||
function recordPush(e: PushEvent) {
|
||||
pushLog.push(e)
|
||||
if (pushLog.length > MAX_PUSH) pushLog.shift()
|
||||
}
|
||||
|
||||
const netup: FastifyPluginAsync = async (fastify) => {
|
||||
const getHotelId = async (slug: string): Promise<string | null> => {
|
||||
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
||||
@@ -238,7 +258,7 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/hotels/:slug/netup/log — просмотр запросов от NetUP ──────────
|
||||
// ── GET /api/hotels/:slug/netup/log ─────────────────────────────────────
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/netup/log',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
@@ -247,11 +267,18 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
return { count: captured.length, requests: [...captured].reverse() }
|
||||
const hotelId = await getHotelId(slug)
|
||||
// Push events filtered to this hotel
|
||||
const pushEvents = [...pushLog]
|
||||
.filter(e => e.hotelId === hotelId)
|
||||
.reverse()
|
||||
// Pull requests (from NetUP TravelLine polls) — all, no hotel filter possible
|
||||
const pullRequests = [...captured].reverse()
|
||||
return { pushEvents, pullRequests }
|
||||
},
|
||||
)
|
||||
|
||||
// ── DELETE /api/hotels/:slug/netup/log — очистить лог ────────────────────
|
||||
// ── DELETE /api/hotels/:slug/netup/log — очистить оба лога ───────────────
|
||||
fastify.delete<SlugParam>(
|
||||
'/api/hotels/:slug/netup/log',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
@@ -261,66 +288,146 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
captured.length = 0
|
||||
pushLog.length = 0
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/netup/test-checkin — ручной тест заселения ────
|
||||
fastify.post<SlugParam & { Body: { room_id: string } }>(
|
||||
'/api/hotels/:slug/netup/test-checkin',
|
||||
{ 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' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { room_id } = request.body
|
||||
if (!room_id) return reply.code(400).send({ error: 'room_id required' })
|
||||
|
||||
// Get room info
|
||||
const { rows: [room] } = await db.query(
|
||||
'SELECT id, number FROM rooms WHERE id = $1 AND hotel_id = $2',
|
||||
[room_id, hotelId],
|
||||
)
|
||||
if (!room) return reply.code(404).send({ error: 'Room not found' })
|
||||
|
||||
const result = await notifyNetupCheckinInternal(
|
||||
hotelId, room_id, room.number, 'Тестовый гость', 'test-' + Date.now(),
|
||||
)
|
||||
if (result.ok) return { ok: true, message: `Check-in отправлен в NetUP (номер ${result.netupRoom})` }
|
||||
return reply.code(502).send({ error: result.error ?? 'Ошибка' })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ── Отдельная функция для check-in/check-out из bookings.ts ──────────────
|
||||
export async function notifyNetupCheckin(hotelId: string, roomId: string, guestName: string, reservationId: string, language?: string) {
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
async function getNetupCfg(hotelId: string, needEnabled = true) {
|
||||
const cond = needEnabled ? 'AND enabled = true' : ''
|
||||
const { rows: [cfg] } = await db.query(
|
||||
'SELECT server_url, username, password, default_language FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
|
||||
`SELECT server_url, username, password, default_language FROM netup_settings WHERE hotel_id = $1 ${cond}`,
|
||||
[hotelId],
|
||||
)
|
||||
if (!cfg?.server_url) return
|
||||
return cfg as { server_url: string; username: string; password: string; default_language: string } | undefined
|
||||
}
|
||||
|
||||
const { rows: [mapping] } = await db.query(
|
||||
'SELECT netup_room_number FROM netup_room_mapping WHERE hotel_id = $1 AND pms_room_id = $2',
|
||||
async function getMapping(hotelId: string, roomId: string) {
|
||||
const { rows: [m] } = await db.query(
|
||||
`SELECT m.netup_room_number, r.number as room_number
|
||||
FROM netup_room_mapping m
|
||||
JOIN rooms r ON r.id = m.pms_room_id
|
||||
WHERE m.hotel_id = $1 AND m.pms_room_id = $2`,
|
||||
[hotelId, roomId],
|
||||
)
|
||||
if (!mapping) return
|
||||
return m as { netup_room_number: string; room_number: string } | undefined
|
||||
}
|
||||
|
||||
async function notifyNetupCheckinInternal(
|
||||
hotelId: string, roomId: string, roomNumber: string,
|
||||
guestName: string, reservationId: string, language?: string,
|
||||
): Promise<{ ok: boolean; netupRoom?: string; error?: string }> {
|
||||
const cfg = await getNetupCfg(hotelId)
|
||||
if (!cfg?.server_url) {
|
||||
recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: '', url: '', status: 'skipped', error: 'NetUP не настроен или отключён' })
|
||||
return { ok: false, error: 'NetUP не настроен или отключён' }
|
||||
}
|
||||
|
||||
const mapping = await getMapping(hotelId, roomId)
|
||||
if (!mapping) {
|
||||
recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: '', url: '', status: 'skipped', error: 'Сопоставление номера не найдено' })
|
||||
return { ok: false, error: 'Сопоставление номера не найдено' }
|
||||
}
|
||||
|
||||
const base = cfg.server_url.replace(/\/$/, '')
|
||||
const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
|
||||
try {
|
||||
const base = cfg.server_url.replace(/\/$/, '')
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
await fetch(`${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`, {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reservation_id: reservationId,
|
||||
name: guestName,
|
||||
language: language ?? cfg.default_language,
|
||||
}),
|
||||
body: JSON.stringify({ reservation_id: reservationId, name: guestName, language: language ?? cfg.default_language }),
|
||||
signal: AbortSignal.timeout(6000),
|
||||
})
|
||||
} catch {
|
||||
// fire-and-forget: не прерываем заселение если NetUP недоступен
|
||||
const event: PushEvent = {
|
||||
ts: new Date().toISOString(), action: 'check-in', hotelId,
|
||||
roomNumber, netupRoom: mapping.netup_room_number, url,
|
||||
status: res.ok ? 'ok' : 'error', httpStatus: res.status,
|
||||
}
|
||||
if (!res.ok) event.error = `HTTP ${res.status}`
|
||||
recordPush(event)
|
||||
return res.ok ? { ok: true, netupRoom: mapping.netup_room_number } : { ok: false, error: `HTTP ${res.status}` }
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : 'Ошибка соединения'
|
||||
recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, status: 'error', error })
|
||||
return { ok: false, error }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public functions called from bookings.ts ──────────────────────────────────
|
||||
|
||||
export async function notifyNetupCheckin(hotelId: string, roomId: string, guestName: string, reservationId: string, language?: string) {
|
||||
// Get room number for logging
|
||||
const { rows: [r] } = await db.query('SELECT number FROM rooms WHERE id = $1', [roomId]).catch(() => ({ rows: [] }))
|
||||
await notifyNetupCheckinInternal(hotelId, roomId, r?.number ?? roomId, guestName, reservationId, language)
|
||||
}
|
||||
|
||||
export async function notifyNetupCheckout(hotelId: string, roomId: string) {
|
||||
const { rows: [cfg] } = await db.query(
|
||||
'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
|
||||
[hotelId],
|
||||
)
|
||||
const cfg = await getNetupCfg(hotelId)
|
||||
if (!cfg?.server_url) return
|
||||
|
||||
const { rows: [mapping] } = await db.query(
|
||||
'SELECT netup_room_number FROM netup_room_mapping WHERE hotel_id = $1 AND pms_room_id = $2',
|
||||
[hotelId, roomId],
|
||||
)
|
||||
if (!mapping) return
|
||||
const mapping = await getMapping(hotelId, roomId)
|
||||
const { rows: [r] } = await db.query('SELECT number FROM rooms WHERE id = $1', [roomId]).catch(() => ({ rows: [] }))
|
||||
const roomNumber = r?.number ?? roomId
|
||||
|
||||
if (!mapping) {
|
||||
recordPush({ ts: new Date().toISOString(), action: 'check-out', hotelId, roomNumber, netupRoom: '', url: '', status: 'skipped', error: 'Сопоставление номера не найдено' })
|
||||
return
|
||||
}
|
||||
|
||||
const base = cfg.server_url.replace(/\/$/, '')
|
||||
const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
|
||||
try {
|
||||
const base = cfg.server_url.replace(/\/$/, '')
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
await fetch(`${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`, {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Basic ${cred}` },
|
||||
signal: AbortSignal.timeout(6000),
|
||||
})
|
||||
} catch {
|
||||
// fire-and-forget
|
||||
recordPush({
|
||||
ts: new Date().toISOString(), action: 'check-out', hotelId,
|
||||
roomNumber, netupRoom: mapping.netup_room_number, url,
|
||||
status: res.ok ? 'ok' : 'error', httpStatus: res.status,
|
||||
error: res.ok ? undefined : `HTTP ${res.status}`,
|
||||
})
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : 'Ошибка соединения'
|
||||
recordPush({ ts: new Date().toISOString(), action: 'check-out', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, status: 'error', error })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -289,11 +289,16 @@ export const api = {
|
||||
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`),
|
||||
req<{
|
||||
pushEvents: { ts: string; action: string; roomNumber: string; netupRoom: string; url: string; status: string; httpStatus?: number; error?: string }[]
|
||||
pullRequests: { 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`),
|
||||
|
||||
testCheckin: (slug: string, roomId: string) =>
|
||||
req<{ ok: boolean; message: string }>('POST', `/api/hotels/${slug}/netup/test-checkin`, { room_id: roomId }),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [pushEvents, setPushEvents] = useState<PushEvent[]>([])
|
||||
const [pullEntries, setPullEntries] = useState<PullEntry[]>([])
|
||||
const [logLoading, setLogLoading] = useState(false)
|
||||
const [selectedEntry, setSelectedEntry] = useState<LogEntry | null>(null)
|
||||
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,88 +520,120 @@ 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>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{logEntries.map((entry, i) => (
|
||||
{/* 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
|
||||
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',
|
||||
)}
|
||||
onClick={handleTestCheckin}
|
||||
disabled={testingCheckin || rooms.length === 0}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<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}
|
||||
{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>
|
||||
<span className="font-mono text-xs text-slate-600 dark:text-slate-300 truncate flex-1">
|
||||
{entry.url}
|
||||
)}
|
||||
</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/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-xs text-slate-400 dark:text-slate-500 shrink-0">
|
||||
{new Date(entry.ts).toLocaleTimeString('ru-RU')}
|
||||
<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>
|
||||
</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>
|
||||
{/* ── 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>
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
</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 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>
|
||||
<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">Заголовки</p>
|
||||
<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(selectedEntry.headers).filter(([k]) =>
|
||||
!['host', 'connection', 'accept-encoding'].includes(k)
|
||||
)
|
||||
),
|
||||
Object.fromEntries(Object.entries(selectedPull.headers).filter(([k]) => !['host','connection','accept-encoding'].includes(k))),
|
||||
null, 2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
{selectedEntry.body != null && (
|
||||
{selectedPull.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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user