Remove pull integration; persist push log to DB (last 50 per hotel)

- Remove TravelLine/netup-pms pull route (travelline.ts unused)
- Remove pull integration section from TvWelcomePage (token field, API URL, etc.)
- Migration 008: netup_push_log table (hotel_id, action, status, request/response body)
- recordPush() now writes to DB in addition to in-memory buffer
- getLog reads from DB so logs survive server restarts
- clearLog deletes from DB
- Limit enforced: 50 rows per hotel (oldest auto-deleted)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:55:39 +03:00
parent 32115399ee
commit ed397dbcd9
4 changed files with 200 additions and 363 deletions

View File

@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS netup_push_log (
id BIGSERIAL PRIMARY KEY,
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
action TEXT NOT NULL, -- 'check-in' | 'check-out' | 'message'
room_number TEXT NOT NULL DEFAULT '',
netup_room TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
request_body JSONB,
status TEXT NOT NULL, -- 'ok' | 'error' | 'skipped'
http_status INT,
response_body TEXT,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_netup_push_log_hotel_ts ON netup_push_log(hotel_id, ts DESC);

View File

@@ -16,7 +16,6 @@ import housekeepingRoutes from './routes/housekeeping'
import channelsRoutes from './routes/channels' import channelsRoutes from './routes/channels'
import usersRoutes from './routes/users' import usersRoutes from './routes/users'
import netupRoutes from './routes/netup' import netupRoutes from './routes/netup'
import travellineRoutes from './routes/travelline'
export async function buildApp() { export async function buildApp() {
const fastify = Fastify({ const fastify = Fastify({
@@ -74,7 +73,6 @@ export async function buildApp() {
await fastify.register(channelsRoutes) await fastify.register(channelsRoutes)
await fastify.register(usersRoutes) await fastify.register(usersRoutes)
await fastify.register(netupRoutes) await fastify.register(netupRoutes)
await fastify.register(travellineRoutes)
return fastify return fastify
} }

View File

@@ -1,30 +1,47 @@
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 } }
// ── Push event log (check-in / check-out calls to NetUP) ───────────────────── // ── Push event log (check-in / check-out calls to NetUP) ─────────────────────
export type PushEvent = { export type PushEvent = {
ts: string ts: string
action: 'check-in' | 'check-out' | 'message' action: 'check-in' | 'check-out' | 'message'
hotelId: string hotelId: string
roomNumber: string // PMS room number roomNumber: string
netupRoom: string // NetUP room number netupRoom: string
url: string url: string
requestBody?: unknown // what we sent requestBody?: unknown
status: 'ok' | 'error' | 'skipped' status: 'ok' | 'error' | 'skipped'
httpStatus?: number httpStatus?: number
responseBody?: string // what NetUP replied responseBody?: string
error?: string error?: string
} }
const MAX_PUSH = 100 // In-memory fallback (used before DB write completes; also keeps last 50)
export const pushLog: PushEvent[] = [] export const pushLog: PushEvent[] = []
function recordPush(e: PushEvent) { function recordPush(e: PushEvent) {
pushLog.push(e) pushLog.push(e)
if (pushLog.length > MAX_PUSH) pushLog.shift() if (pushLog.length > 50) pushLog.shift()
// Persist to DB (fire-and-forget); also enforce 50-row limit per hotel
db.query(
`INSERT INTO netup_push_log
(hotel_id, ts, action, room_number, netup_room, url, request_body, status, http_status, response_body, error)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[e.hotelId, e.ts, e.action, e.roomNumber, e.netupRoom, e.url,
e.requestBody ? JSON.stringify(e.requestBody) : null,
e.status, e.httpStatus ?? null, e.responseBody ?? null, e.error ?? null],
).then(() =>
// Keep only last 50 rows per hotel
db.query(
`DELETE FROM netup_push_log WHERE hotel_id = $1 AND id NOT IN (
SELECT id FROM netup_push_log WHERE hotel_id = $1 ORDER BY ts DESC LIMIT 50
)`,
[e.hotelId],
)
).catch(() => { /* best-effort */ })
} }
const netup: FastifyPluginAsync = async (fastify) => { const netup: FastifyPluginAsync = async (fastify) => {
@@ -270,13 +287,25 @@ const netup: FastifyPluginAsync = async (fastify) => {
return reply.code(403).send({ error: 'Forbidden' }) return reply.code(403).send({ error: 'Forbidden' })
} }
const hotelId = await getHotelId(slug) const hotelId = await getHotelId(slug)
// Push events filtered to this hotel const { rows } = await db.query(
const pushEvents = [...pushLog] `SELECT ts, action, room_number, netup_room, url, request_body,
.filter(e => e.hotelId === hotelId) status, http_status, response_body, error
.reverse() FROM netup_push_log WHERE hotel_id = $1 ORDER BY ts DESC LIMIT 50`,
// Pull requests (from NetUP TravelLine polls) — all, no hotel filter possible [hotelId],
const pullRequests = [...captured].reverse() )
return { pushEvents, pullRequests } const pushEvents = rows.map((r: Record<string, unknown>) => ({
ts: (r.ts as Date).toISOString(),
action: r.action,
roomNumber: r.room_number,
netupRoom: r.netup_room,
url: r.url,
requestBody: r.request_body,
status: r.status,
httpStatus: r.http_status,
responseBody: r.response_body,
error: r.error,
}))
return { pushEvents, pullRequests: [] }
}, },
) )
@@ -289,7 +318,8 @@ const netup: FastifyPluginAsync = async (fastify) => {
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' }) return reply.code(403).send({ error: 'Forbidden' })
} }
captured.length = 0 const hotelId2 = await getHotelId(slug)
if (hotelId2) await db.query('DELETE FROM netup_push_log WHERE hotel_id = $1', [hotelId2])
pushLog.length = 0 pushLog.length = 0
return { ok: true } return { ok: true }
}, },

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Tv2, Plug, Map, Activity, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, Copy, Link2, RefreshCw, Trash2, ArrowUpRight, ArrowDownLeft } from 'lucide-react' import { Tv2, Plug, Map, Activity, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, 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'
@@ -14,17 +14,6 @@ const LANGUAGES = [
{ value: 'ar_AE', label: 'العربية' }, { value: 'ar_AE', label: 'العربية' },
] ]
const PMS_API_URL = 'https://api.hotelsync.ru/netup-pms'
type PullEntry = {
ts: string
method: string
url: string
headers: Record<string, unknown>
query: Record<string, unknown>
body: unknown
}
type PushEvent = { type PushEvent = {
ts: string ts: string
action: string action: string
@@ -49,28 +38,23 @@ export function TvWelcomePage() {
const [showPass, setShowPass] = useState(false) const [showPass, setShowPass] = useState(false)
// Connection settings // Connection settings
const [serverUrl, setServerUrl] = useState('') const [serverUrl, setServerUrl] = useState('')
const [username, setUsername] = useState('admin') const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [language, setLanguage] = useState('ru_RU') const [language, setLanguage] = useState('ru_RU')
const [enabled, setEnabled] = useState(true) const [enabled, setEnabled] = useState(true)
const [tlToken, setTlToken] = useState('')
const [settingsLoaded, setSettingsLoaded] = useState(false) const [settingsLoaded, setSettingsLoaded] = useState(false)
const [copied, setCopied] = useState(false)
// Room mappings // Room mappings
const [rooms, setRooms] = useState<{ id: string; number: string; type: string; netupRoomNumber: string }[]>([]) const [rooms, setRooms] = useState<{ id: string; number: string; type: string; netupRoomNumber: string }[]>([])
const [roomsSaving, setRoomsSaving] = useState(false) const [roomsSaving, setRoomsSaving] = useState(false)
const [roomsSaved, setRoomsSaved] = useState(false) const [roomsSaved, setRoomsSaved] = useState(false)
// Log // Diagnostics
const [pushEvents, setPushEvents] = useState<PushEvent[]>([]) const [pushEvents, setPushEvents] = useState<PushEvent[]>([])
const [pullEntries, setPullEntries] = useState<PullEntry[]>([]) const [logLoading, setLogLoading] = useState(false)
const [logLoading, setLogLoading] = useState(false) const [selectedPush, setSelectedPush] = useState<PushEvent | null>(null)
const [selectedPull, setSelectedPull] = useState<PullEntry | null>(null) const [testingCheckin, setTestingCheckin] = useState(false)
const [selectedPush, setSelectedPush] = useState<PushEvent | null>(null)
// Test check-in
const [testingCheckin, setTestingCheckin] = useState(false)
const [testCheckinResult, setTestCheckinResult] = useState<{ ok: boolean; msg: string } | null>(null) const [testCheckinResult, setTestCheckinResult] = useState<{ ok: boolean; msg: string } | null>(null)
// Load settings // Load settings
@@ -82,7 +66,6 @@ export function TvWelcomePage() {
setUsername(s.username) setUsername(s.username)
setLanguage(s.defaultLanguage) setLanguage(s.defaultLanguage)
setEnabled(s.enabled) setEnabled(s.enabled)
setTlToken(s.tlToken ?? '')
setSettingsLoaded(true) setSettingsLoaded(true)
}) })
.catch(() => setSettingsLoaded(true)) .catch(() => setSettingsLoaded(true))
@@ -91,21 +74,16 @@ export function TvWelcomePage() {
// Load room mappings when tab switches (also needed on 'log' for test button) // Load room mappings when tab switches (also needed on 'log' for test button)
useEffect(() => { useEffect(() => {
if ((tab !== 'rooms' && tab !== 'log') || !slug) return if ((tab !== 'rooms' && tab !== 'log') || !slug) return
if (rooms.length > 0) return // already loaded if (rooms.length > 0) return
api.netup.getRoomMappings(slug) api.netup.getRoomMappings(slug).then(setRooms).catch(() => {})
.then(setRooms)
.catch(() => {})
}, [tab, slug, rooms.length]) }, [tab, slug, rooms.length])
// Load log when tab switches // Load push log when diagnostics tab opens
const loadLog = useCallback(() => { const loadLog = useCallback(() => {
if (!slug) return if (!slug) return
setLogLoading(true) setLogLoading(true)
api.netup.getLog(slug) api.netup.getLog(slug)
.then(r => { .then(r => setPushEvents(r.pushEvents))
setPushEvents(r.pushEvents)
setPullEntries(r.pullRequests)
})
.catch(() => {}) .catch(() => {})
.finally(() => setLogLoading(false)) .finally(() => setLogLoading(false))
}, [slug]) }, [slug])
@@ -120,12 +98,10 @@ export function TvWelcomePage() {
setTestResult(null) setTestResult(null)
try { try {
await api.netup.saveSettings(slug, { await api.netup.saveSettings(slug, {
server_url: serverUrl, server_url: serverUrl, username,
username,
password: password || undefined, password: password || undefined,
default_language: language, default_language: language, enabled,
enabled, tl_token: '',
tl_token: tlToken,
}) })
setPassword('') setPassword('')
} finally { } finally {
@@ -138,18 +114,15 @@ export function TvWelcomePage() {
setTestResult(null) setTestResult(null)
try { try {
await api.netup.saveSettings(slug, { await api.netup.saveSettings(slug, {
server_url: serverUrl, server_url: serverUrl, username,
username,
password: password || undefined, password: password || undefined,
default_language: language, default_language: language, enabled,
enabled, tl_token: '',
tl_token: tlToken,
}) })
const res = await api.netup.testConnection(slug) const res = await api.netup.testConnection(slug)
setTestResult({ ok: true, msg: res.message }) setTestResult({ ok: true, msg: res.message })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка подключения' setTestResult({ ok: false, msg: err instanceof Error ? err.message : 'Ошибка подключения' })
setTestResult({ ok: false, msg })
} finally { } finally {
setTesting(false) setTesting(false)
} }
@@ -160,8 +133,7 @@ export function TvWelcomePage() {
setRoomsSaved(false) setRoomsSaved(false)
try { try {
await api.netup.saveRoomMappings(slug, rooms.map(r => ({ await api.netup.saveRoomMappings(slug, rooms.map(r => ({
pms_room_id: r.id, pms_room_id: r.id, netup_room_number: r.netupRoomNumber,
netup_room_number: r.netupRoomNumber,
}))) })))
setRoomsSaved(true) setRoomsSaved(true)
setTimeout(() => setRoomsSaved(false), 2500) setTimeout(() => setRoomsSaved(false), 2500)
@@ -173,13 +145,11 @@ export function TvWelcomePage() {
const handleClearLog = async () => { const handleClearLog = async () => {
await api.netup.clearLog(slug).catch(() => {}) await api.netup.clearLog(slug).catch(() => {})
setPushEvents([]) setPushEvents([])
setPullEntries([]) setSelectedPush(null)
setSelectedPull(null)
} }
const handleTestCheckin = async () => { const handleTestCheckin = async () => {
if (!rooms.length) return if (!rooms.length) return
// pick first room that has a netup mapping
const room = rooms.find(r => r.netupRoomNumber) ?? rooms[0] const room = rooms.find(r => r.netupRoomNumber) ?? rooms[0]
setTestingCheckin(true) setTestingCheckin(true)
setTestCheckinResult(null) setTestCheckinResult(null)
@@ -187,11 +157,9 @@ export function TvWelcomePage() {
const res = await api.netup.testCheckin(slug, room.id) const res = await api.netup.testCheckin(slug, room.id)
setTestCheckinResult({ ok: true, msg: res.message }) setTestCheckinResult({ ok: true, msg: res.message })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка' setTestCheckinResult({ ok: false, msg: err instanceof Error ? err.message : 'Ошибка' })
setTestCheckinResult({ ok: false, msg })
} finally { } finally {
setTestingCheckin(false) setTestingCheckin(false)
// Refresh log after test
setTimeout(loadLog, 500) setTimeout(loadLog, 500)
} }
} }
@@ -250,15 +218,9 @@ export function TvWelcomePage() {
</div> </div>
<button <button
onClick={() => setEnabled(v => !v)} onClick={() => setEnabled(v => !v)}
className={cn( className={cn('relative w-12 h-6 rounded-full transition-colors', enabled ? 'bg-violet-500' : 'bg-slate-300 dark:bg-slate-600')}
'relative w-12 h-6 rounded-full transition-colors',
enabled ? 'bg-violet-500' : 'bg-slate-300 dark:bg-slate-600',
)}
> >
<span className={cn( <span className={cn('absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform', enabled ? 'translate-x-6' : 'translate-x-0.5')} />
'absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform',
enabled ? 'translate-x-6' : 'translate-x-0.5',
)} />
</button> </button>
</div> </div>
@@ -266,13 +228,9 @@ export function TvWelcomePage() {
{/* Server URL */} {/* Server URL */}
<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"> <label className="text-sm font-medium text-slate-700 dark:text-slate-300">Адрес сервера NetUP</label>
Адрес сервера NetUP
</label>
<input <input
type="text" type="text" value={serverUrl} onChange={e => setServerUrl(e.target.value)}
value={serverUrl}
onChange={e => setServerUrl(e.target.value)}
placeholder="http://192.168.1.100:8880" placeholder="http://192.168.1.100:8880"
className="input w-full font-mono text-sm" className="input w-full font-mono text-sm"
/> />
@@ -285,26 +243,18 @@ export function TvWelcomePage() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<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">Логин</label> <label className="text-sm font-medium text-slate-700 dark:text-slate-300">Логин</label>
<input <input type="text" value={username} onChange={e => setUsername(e.target.value)} className="input w-full" />
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="input w-full"
/>
</div> </div>
<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">Пароль</label> <label className="text-sm font-medium text-slate-700 dark:text-slate-300">Пароль</label>
<div className="relative"> <div className="relative">
<input <input
type={showPass ? 'text' : 'password'} type={showPass ? 'text' : 'password'} value={password}
value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
placeholder="Не изменять" placeholder="Не изменять" className="input w-full pr-9"
className="input w-full pr-9"
/> />
<button <button
type="button" type="button" onClick={() => setShowPass(v => !v)}
onClick={() => setShowPass(v => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600" className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
> >
{showPass ? <EyeOff size={15} /> : <Eye size={15} />} {showPass ? <EyeOff size={15} /> : <Eye size={15} />}
@@ -316,79 +266,15 @@ export function TvWelcomePage() {
{/* Default language */} {/* Default language */}
<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"> <label className="text-sm font-medium text-slate-700 dark:text-slate-300">Язык по умолчанию</label>
Язык по умолчанию
</label>
<select value={language} onChange={e => setLanguage(e.target.value)} className="input w-full"> <select value={language} onChange={e => setLanguage(e.target.value)} className="input w-full">
{LANGUAGES.map(l => ( {LANGUAGES.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select> </select>
<p className="text-xs text-slate-400 dark:text-slate-500"> <p className="text-xs text-slate-400 dark:text-slate-500">
Язык меню на TV, если для гостя не указан язык Язык меню на TV, если для гостя не указан язык
</p> </p>
</div> </div>
<hr className="border-slate-100 dark:border-slate-700" />
{/* Pull integration */}
<div className="space-y-3">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">
Pull-интеграция (NetUP опрашивает наш сервер)
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Настройте в NetUP тип интеграции «TravelLine» укажите адрес ниже и токен.
NetUP будет сам забирать данные о бронях каждые 3060 секунд.
</p>
</div>
{/* API URL (read-only) */}
<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)
</label>
<div className="flex gap-2">
<input
type="text"
readOnly
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(PMS_API_URL)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
className="btn-secondary px-3 shrink-0 flex items-center gap-1.5"
>
{copied ? <CheckCircle2 size={13} className="text-emerald-500" /> : <Copy size={13} />}
{copied ? 'Скопировано' : 'Копировать'}
</button>
</div>
</div>
{/* Token */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
Токен авторизации
</label>
<input
type="text"
value={tlToken}
onChange={e => setTlToken(e.target.value)}
placeholder="Придумайте любой секретный токен"
className="input w-full font-mono text-sm"
/>
<p className="text-xs text-slate-400 dark:text-slate-500">
Введите этот же токен в поле «Токен» настроек TravelLine в NetUP
</p>
</div>
</div>
{/* Test result */} {/* Test result */}
{testResult && ( {testResult && (
<div className={cn( <div className={cn(
@@ -397,28 +283,18 @@ export function TvWelcomePage() {
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800' ? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800'
: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800', : 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800',
)}> )}>
{testResult.ok {testResult.ok ? <CheckCircle2 size={15} className="shrink-0" /> : <XCircle size={15} className="shrink-0" />}
? <CheckCircle2 size={15} className="shrink-0" />
: <XCircle size={15} className="shrink-0" />}
{testResult.msg} {testResult.msg}
</div> </div>
)} )}
{/* Actions */} {/* Actions */}
<div className="flex gap-3 pt-1"> <div className="flex gap-3 pt-1">
<button <button onClick={handleTest} disabled={testing || !serverUrl} className="btn-secondary flex items-center gap-2">
onClick={handleTest}
disabled={testing || !serverUrl}
className="btn-secondary flex items-center gap-2"
>
{testing ? <Loader2 size={14} className="animate-spin" /> : <Plug size={14} />} {testing ? <Loader2 size={14} className="animate-spin" /> : <Plug size={14} />}
Проверить подключение Проверить подключение
</button> </button>
<button <button onClick={handleSaveConnection} disabled={saving || !settingsLoaded} className="btn-primary flex items-center gap-2">
onClick={handleSaveConnection}
disabled={saving || !settingsLoaded}
className="btn-primary flex items-center gap-2"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} {saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Сохранить Сохранить
</button> </button>
@@ -451,22 +327,14 @@ export function TvWelcomePage() {
</thead> </thead>
<tbody> <tbody>
{rooms.map((room, i) => ( {rooms.map((room, i) => (
<tr <tr key={room.id} className={cn('border-b border-slate-50 dark:border-slate-700/50 last:border-0', i % 2 !== 0 && 'bg-slate-50/50 dark:bg-slate-700/20')}>
key={room.id}
className={cn(
'border-b border-slate-50 dark:border-slate-700/50 last:border-0',
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 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 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" value={room.netupRoomNumber}
value={room.netupRoomNumber}
onChange={e => updateRoomNetup(room.id, e.target.value)} onChange={e => updateRoomNetup(room.id, e.target.value)}
placeholder="Не задан" placeholder="Не задан" className="input py-1.5 w-36 text-sm"
className="input py-1.5 w-36 text-sm"
/> />
</td> </td>
</tr> </tr>
@@ -476,11 +344,7 @@ export function TvWelcomePage() {
)} )}
<div className="px-5 py-4 border-t border-slate-100 dark:border-slate-700 flex items-center gap-3"> <div className="px-5 py-4 border-t border-slate-100 dark:border-slate-700 flex items-center gap-3">
<button <button onClick={handleSaveRooms} disabled={roomsSaving || rooms.length === 0} className="btn-primary flex items-center gap-2">
onClick={handleSaveRooms}
disabled={roomsSaving || rooms.length === 0}
className="btn-primary flex items-center gap-2"
>
{roomsSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} {roomsSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Сохранить сопоставление Сохранить сопоставление
</button> </button>
@@ -493,179 +357,108 @@ export function TvWelcomePage() {
</div> </div>
)} )}
{/* ── Tab: Diagnostics / Log ───────────────────────────────────────────── */} {/* ── Tab: Diagnostics ─────────────────────────────────────────────────── */}
{tab === '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-4">
{/* ── Push events (наш сервер → NetUP) ── */} <div>
<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">Вызовы в NetUP</p>
<div className="flex items-center justify-between mb-3"> <p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
<div> Заселения и выезды, отправленные в NetUP. Нажмите на строку для деталей.
<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
</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>
{(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>
)}
</div>
</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> </p>
) : ( </div>
<div className="divide-y divide-slate-100 dark:divide-slate-700/50"> <div className="flex gap-2">
{pushEvents.map((e, i) => ( <button onClick={loadLog} disabled={logLoading} className="btn-secondary flex items-center gap-1.5 text-sm">
<div key={i}> {logLoading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
<button Обновить
onClick={() => setSelectedPush(selectedPush === e ? null : e)} </button>
className={cn( {pushEvents.length > 0 && (
'w-full flex items-center gap-3 py-2.5 px-1 text-sm rounded-lg transition-colors', <button onClick={handleClearLog} className="btn-secondary flex items-center gap-1.5 text-sm text-red-500 dark:text-red-400">
selectedPush === e ? 'bg-slate-50 dark:bg-slate-700/40' : 'hover:bg-slate-50/60 dark:hover:bg-slate-700/20', <Trash2 size={13} />
)} Очистить
> </button>
<span className={cn( )}
'w-2 h-2 rounded-full shrink-0', </div>
e.status === 'ok' ? 'bg-emerald-500' : e.status === 'skipped' ? 'bg-amber-400' : 'bg-red-500', </div>
)} />
<span className="font-medium text-slate-700 dark:text-slate-200 w-20 shrink-0"> {/* Test check-in */}
{e.action === 'check-in' ? 'Заселение' : 'Выезд'} <div className="mb-4 pb-4 border-b border-slate-100 dark:border-slate-700 flex items-center gap-3 flex-wrap">
</span> <button
<span className="text-slate-500 dark:text-slate-400"> onClick={handleTestCheckin}
Номер <span className="font-medium text-slate-700 dark:text-slate-200">{e.roomNumber}</span> disabled={testingCheckin || rooms.length === 0}
{e.netupRoom && <> NetUP <span className="font-mono text-xs">{e.netupRoom}</span></>} className="btn-secondary flex items-center gap-2 text-sm"
</span> >
{e.error && <span className="text-red-500 dark:text-red-400 text-xs flex-1 truncate text-left">{e.error}</span>} {testingCheckin ? <Loader2 size={13} className="animate-spin" /> : <Plug size={13} />}
{e.httpStatus && e.status === 'ok' && <span className="text-xs text-emerald-600 dark:text-emerald-400">HTTP {e.httpStatus}</span>} Тест: отправить заселение в NetUP
<span className="ml-auto text-xs text-slate-400 shrink-0"> </button>
{new Date(e.ts).toLocaleTimeString('ru-RU')} {rooms.length === 0 && <p className="text-xs text-slate-400">Сначала настройте сопоставление номеров</p>}
</span> {testCheckinResult && (
</button> <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')}>
{selectedPush === e && ( {testCheckinResult.ok ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
<div className="mx-1 mb-2 bg-slate-900 dark:bg-slate-950 rounded-xl p-4 space-y-3 text-xs font-mono"> {testCheckinResult.msg}
<div> </span>
<p className="text-slate-400 font-sans font-semibold mb-1">URL запроса</p>
<p className="text-violet-400 break-all">{e.url}</p>
</div>
{e.requestBody != null && (
<div>
<p className="text-slate-400 font-sans font-semibold mb-1">Тело запроса (отправили в NetUP)</p>
<pre className="text-emerald-300 whitespace-pre-wrap break-all">
{JSON.stringify(e.requestBody, null, 2)}
</pre>
</div>
)}
{e.responseBody && (
<div>
<p className="text-slate-400 font-sans font-semibold mb-1">Ответ NetUP</p>
<pre className="text-red-300 whitespace-pre-wrap break-all">{e.responseBody}</pre>
</div>
)}
</div>
)}
</div>
))}
</div>
)} )}
</div> </div>
{/* ── Pull requests (NetUP → наш сервер) ── */} {/* Events list */}
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-5"> {pushEvents.length === 0 ? (
<p className="font-medium text-slate-900 dark:text-slate-100 flex items-center gap-2 mb-1"> <div className="py-8 text-center">
<ArrowDownLeft size={15} className="text-blue-500" /> <Activity size={32} className="mx-auto mb-3 text-slate-300 dark:text-slate-600" />
Входящие запросы от NetUP (pull) <p className="text-sm text-slate-500 dark:text-slate-400">Вызовов ещё не было</p>
</p> <p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
<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> </p>
) : ( </div>
<div className="divide-y divide-slate-100 dark:divide-slate-700/50"> ) : (
{pullEntries.map((entry, i) => ( <div className="divide-y divide-slate-100 dark:divide-slate-700/50">
{pushEvents.map((e, i) => (
<div key={i}>
<button <button
key={i} onClick={() => setSelectedPush(selectedPush === e ? null : e)}
onClick={() => setSelectedPull(selectedPull === entry ? null : entry)}
className={cn( className={cn(
'w-full text-left px-2 py-2.5 rounded-lg transition-colors text-sm', 'w-full flex items-center gap-3 py-2.5 px-1 text-sm rounded-lg transition-colors',
selectedPull === entry ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-slate-50 dark:hover:bg-slate-700/40', selectedPush === e ? 'bg-slate-50 dark:bg-slate-700/40' : 'hover:bg-slate-50/60 dark:hover:bg-slate-700/20',
)} )}
> >
<div className="flex items-center gap-3"> <span className={cn('w-2 h-2 rounded-full shrink-0',
<span className="font-mono text-xs font-bold text-blue-600 dark:text-blue-400 w-10 shrink-0">{entry.method}</span> e.status === 'ok' ? 'bg-emerald-500' : e.status === 'skipped' ? 'bg-amber-400' : 'bg-red-500'
<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> <span className="font-medium text-slate-700 dark:text-slate-200 w-20 shrink-0">
</div> {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 text-left">{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>
</button> </button>
))}
</div>
)}
{selectedPull && ( {selectedPush === e && (
<div className="mt-3 bg-slate-900 dark:bg-slate-950 rounded-xl p-4 space-y-3 text-xs font-mono"> <div className="mx-1 mb-2 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> <div>
{Object.keys(selectedPull.query ?? {}).length > 0 && ( <p className="text-slate-400 font-sans font-semibold mb-1">URL запроса</p>
<div> <p className="text-violet-400 break-all">{e.url}</p>
<p className="text-slate-400 font-sans text-xs font-semibold mb-1">Query</p> </div>
<pre className="text-slate-200 whitespace-pre-wrap break-all">{JSON.stringify(selectedPull.query, null, 2)}</pre> {e.requestBody != null && (
</div> <div>
)} <p className="text-slate-400 font-sans font-semibold mb-1">Тело запроса</p>
<div> <pre className="text-emerald-300 whitespace-pre-wrap break-all">{JSON.stringify(e.requestBody, null, 2)}</pre>
<p className="text-slate-400 font-sans text-xs font-semibold mb-1">Заголовки</p> </div>
<pre className="text-slate-300 whitespace-pre-wrap break-all"> )}
{JSON.stringify( {e.responseBody && (
Object.fromEntries(Object.entries(selectedPull.headers).filter(([k]) => !['host','connection','accept-encoding'].includes(k))), <div>
null, 2 <p className="text-slate-400 font-sans font-semibold mb-1">Ответ NetUP</p>
)} <pre className="text-red-300 whitespace-pre-wrap break-all">{e.responseBody}</pre>
</pre> </div>
)}
</div>
)}
</div> </div>
{selectedPull.body != null && ( ))}
<div> </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>
)}
</div>
</div> </div>
)} )}
</div> </div>