feat: NetUP via agent — proxy HTTP requests through Windows workstation
- Migration 044: add connection_type + agent_workstation_id to netup_settings - Backend: netupReqViaCfg routes requests via agent WebSocket when connection_type=agent - Agent: add netup_request command handler (proxies HTTP to local NetUP server) - TvWelcomePage: add connection mode toggle (direct / via agent) + workstation selector - Hint changes: URL placeholder shows local IP when agent mode selected Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
4
backend/migrations/044_netup_agent.sql
Normal file
4
backend/migrations/044_netup_agent.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- NetUP connection via agent
|
||||||
|
ALTER TABLE netup_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS connection_type VARCHAR(10) NOT NULL DEFAULT 'direct',
|
||||||
|
ADD COLUMN IF NOT EXISTS agent_workstation_id UUID REFERENCES workstations(id) ON DELETE SET NULL;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify'
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
|
import { sendCommandAndWait } from '../agent-ws'
|
||||||
|
|
||||||
type SlugParam = { Params: { slug: string } }
|
type SlugParam = { Params: { slug: string } }
|
||||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||||
@@ -53,8 +54,8 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
|||||||
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
||||||
role === 'super_admin' || userSlug === slug
|
role === 'super_admin' || userSlug === slug
|
||||||
|
|
||||||
// ── Вспомогательная функция: HTTP запрос к NetUP MW ──────────────────────
|
// ── HTTP запрос к NetUP MW напрямую ──────────────────────────────────────
|
||||||
async function netupReq(
|
async function netupReqDirect(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
@@ -68,20 +69,53 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||||||
'Authorization': `Basic ${cred}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
signal: AbortSignal.timeout(8000),
|
signal: AbortSignal.timeout(8000),
|
||||||
})
|
})
|
||||||
|
|
||||||
let data: unknown = null
|
let data: unknown = null
|
||||||
try { data = await res.json() } catch { /* empty response */ }
|
try { data = await res.json() } catch { /* empty response */ }
|
||||||
|
|
||||||
return { ok: res.ok, status: res.status, data }
|
return { ok: res.ok, status: res.status, data }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── HTTP запрос через агент ───────────────────────────────────────────────
|
||||||
|
async function netupReqViaAgent(
|
||||||
|
workstationId: string,
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||||||
|
const base = serverUrl.replace(/\/$/, '')
|
||||||
|
const url = `${base}/mw/api${path}`
|
||||||
|
const cred = Buffer.from(`${username}:${password}`).toString('base64')
|
||||||
|
|
||||||
|
const result = await sendCommandAndWait(workstationId, {
|
||||||
|
type: 'netup_request',
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||||||
|
body: body ?? null,
|
||||||
|
}, 10000) as { ok: boolean; status: number; data: unknown }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Роутинг: прямой или через агент ──────────────────────────────────────
|
||||||
|
async function netupReq(
|
||||||
|
cfg: { server_url: string; username: string; password: string; connection_type?: string; agent_workstation_id?: string | null },
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||||||
|
if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) {
|
||||||
|
return netupReqViaAgent(cfg.agent_workstation_id, cfg.server_url, cfg.username, cfg.password, method, path, body)
|
||||||
|
}
|
||||||
|
return netupReqDirect(cfg.server_url, cfg.username, cfg.password, method, path, body)
|
||||||
|
}
|
||||||
|
|
||||||
// ── GET /api/hotels/:slug/netup/settings ────────────────────────────────
|
// ── GET /api/hotels/:slug/netup/settings ────────────────────────────────
|
||||||
fastify.get<SlugParam>(
|
fastify.get<SlugParam>(
|
||||||
'/api/hotels/:slug/netup/settings',
|
'/api/hotels/:slug/netup/settings',
|
||||||
@@ -95,15 +129,15 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
'SELECT server_url, username, password, default_language, enabled, tl_token FROM netup_settings WHERE hotel_id = $1',
|
'SELECT server_url, username, password, default_language, enabled, tl_token, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1',
|
||||||
[hotelId],
|
[hotelId],
|
||||||
)
|
)
|
||||||
return rows[0] ?? { server_url: '', username: 'admin', password: '', default_language: 'ru_RU', enabled: false, tl_token: '' }
|
return rows[0] ?? { server_url: '', username: 'admin', password: '', default_language: 'ru_RU', enabled: false, tl_token: '', connection_type: 'direct', agent_workstation_id: null }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── PATCH /api/hotels/:slug/netup/settings ───────────────────────────────
|
// ── PATCH /api/hotels/:slug/netup/settings ───────────────────────────────
|
||||||
fastify.patch<SlugParam & { Body: { server_url?: string; username?: string; password?: string; default_language?: string; enabled?: boolean; tl_token?: string } }>(
|
fastify.patch<SlugParam & { Body: { server_url?: string; username?: string; password?: string; default_language?: string; enabled?: boolean; tl_token?: string; connection_type?: string; agent_workstation_id?: string | null } }>(
|
||||||
'/api/hotels/:slug/netup/settings',
|
'/api/hotels/:slug/netup/settings',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
@@ -115,20 +149,23 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { server_url, username, password, default_language, enabled, tl_token } = request.body
|
const { server_url, username, password, default_language, enabled, tl_token, connection_type, agent_workstation_id } = request.body
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO netup_settings (hotel_id, server_url, username, password, default_language, enabled, tl_token, updated_at)
|
`INSERT INTO netup_settings (hotel_id, server_url, username, password, default_language, enabled, tl_token, connection_type, agent_workstation_id, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
|
||||||
ON CONFLICT (hotel_id) DO UPDATE SET
|
ON CONFLICT (hotel_id) DO UPDATE SET
|
||||||
server_url = EXCLUDED.server_url,
|
server_url = EXCLUDED.server_url,
|
||||||
username = EXCLUDED.username,
|
username = EXCLUDED.username,
|
||||||
password = CASE WHEN EXCLUDED.password = '' THEN netup_settings.password ELSE EXCLUDED.password END,
|
password = CASE WHEN EXCLUDED.password = '' THEN netup_settings.password ELSE EXCLUDED.password END,
|
||||||
default_language = EXCLUDED.default_language,
|
default_language = EXCLUDED.default_language,
|
||||||
enabled = EXCLUDED.enabled,
|
enabled = EXCLUDED.enabled,
|
||||||
tl_token = EXCLUDED.tl_token,
|
tl_token = EXCLUDED.tl_token,
|
||||||
updated_at = NOW()`,
|
connection_type = EXCLUDED.connection_type,
|
||||||
[hotelId, server_url ?? '', username ?? 'admin', password ?? '', default_language ?? 'ru_RU', enabled ?? true, tl_token ?? ''],
|
agent_workstation_id = EXCLUDED.agent_workstation_id,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[hotelId, server_url ?? '', username ?? 'admin', password ?? '', default_language ?? 'ru_RU', enabled ?? true, tl_token ?? '',
|
||||||
|
connection_type ?? 'direct', agent_workstation_id ?? null],
|
||||||
)
|
)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
@@ -147,14 +184,14 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1',
|
'SELECT server_url, username, password, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1',
|
||||||
[hotelId],
|
[hotelId],
|
||||||
)
|
)
|
||||||
const cfg = rows[0]
|
const cfg = rows[0]
|
||||||
if (!cfg?.server_url) return reply.code(400).send({ error: 'Настройки не заполнены' })
|
if (!cfg?.server_url) return reply.code(400).send({ error: 'Настройки не заполнены' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await netupReq(cfg.server_url, cfg.username, cfg.password, 'GET', '/hotel-room')
|
const result = await netupReq(cfg, 'GET', '/hotel-room')
|
||||||
if (result.ok) return { ok: true, message: 'Подключение успешно' }
|
if (result.ok) return { ok: true, message: 'Подключение успешно' }
|
||||||
return reply.code(502).send({ error: `NetUP вернул статус ${result.status}` })
|
return reply.code(502).send({ error: `NetUP вернул статус ${result.status}` })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -240,7 +277,7 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// Get NetUP settings
|
// Get NetUP settings
|
||||||
const { rows: [cfg] } = await db.query(
|
const { rows: [cfg] } = await db.query(
|
||||||
'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
|
'SELECT server_url, username, password, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
|
||||||
[hotelId],
|
[hotelId],
|
||||||
)
|
)
|
||||||
if (!cfg?.server_url) return reply.code(400).send({ error: 'NetUP не настроен' })
|
if (!cfg?.server_url) return reply.code(400).send({ error: 'NetUP не настроен' })
|
||||||
@@ -257,7 +294,7 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await netupReq(
|
const result = await netupReq(
|
||||||
cfg.server_url, cfg.username, cfg.password,
|
cfg,
|
||||||
'POST', '/system-message',
|
'POST', '/system-message',
|
||||||
{
|
{
|
||||||
name: guest_name ? `Сообщение для ${guest_name}` : `Сообщение в номер ${mapping.room_number}`,
|
name: guest_name ? `Сообщение для ${guest_name}` : `Сообщение в номер ${mapping.room_number}`,
|
||||||
@@ -358,13 +395,45 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function netupReqViaCfg(
|
||||||
|
cfg: { server_url: string; username: string; password: string; connection_type: string; agent_workstation_id: string | null },
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||||||
|
if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) {
|
||||||
|
const base = cfg.server_url.replace(/\/$/, '')
|
||||||
|
const url = `${base}/mw/api${path}`
|
||||||
|
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||||
|
return sendCommandAndWait(cfg.agent_workstation_id, {
|
||||||
|
type: 'netup_request',
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||||||
|
body: body ?? null,
|
||||||
|
}, 10000) as Promise<{ ok: boolean; status: number; data: unknown }>
|
||||||
|
}
|
||||||
|
const base = cfg.server_url.replace(/\/$/, '')
|
||||||
|
const url = `${base}/mw/api${path}`
|
||||||
|
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
})
|
||||||
|
let data: unknown = null
|
||||||
|
try { data = await res.json() } catch { /* empty */ }
|
||||||
|
return { ok: res.ok, status: res.status, data }
|
||||||
|
}
|
||||||
|
|
||||||
async function getNetupCfg(hotelId: string, needEnabled = true) {
|
async function getNetupCfg(hotelId: string, needEnabled = true) {
|
||||||
const cond = needEnabled ? 'AND enabled = true' : ''
|
const cond = needEnabled ? 'AND enabled = true' : ''
|
||||||
const { rows: [cfg] } = await db.query(
|
const { rows: [cfg] } = await db.query(
|
||||||
`SELECT server_url, username, password, default_language FROM netup_settings WHERE hotel_id = $1 ${cond}`,
|
`SELECT server_url, username, password, default_language, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1 ${cond}`,
|
||||||
[hotelId],
|
[hotelId],
|
||||||
)
|
)
|
||||||
return cfg as { server_url: string; username: string; password: string; default_language: string } | undefined
|
return cfg as { server_url: string; username: string; password: string; default_language: string; connection_type: string; agent_workstation_id: string | null } | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMapping(hotelId: string, roomId: string) {
|
async function getMapping(hotelId: string, roomId: string) {
|
||||||
@@ -396,31 +465,41 @@ async function notifyNetupCheckinInternal(
|
|||||||
|
|
||||||
const base = cfg.server_url.replace(/\/$/, '')
|
const base = cfg.server_url.replace(/\/$/, '')
|
||||||
const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`
|
const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`
|
||||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
|
||||||
|
|
||||||
// NetUP expects reservation_id as a numeric string (parses with strconv.Atoi)
|
// NetUP expects reservation_id as a numeric string (parses with strconv.Atoi)
|
||||||
// Convert UUID to a stable numeric string by taking first 8 hex chars → decimal
|
|
||||||
const resIdStr = String(parseInt(reservationId.replace(/-/g, '').slice(0, 8), 16) % 100000000)
|
const resIdStr = String(parseInt(reservationId.replace(/-/g, '').slice(0, 8), 16) % 100000000)
|
||||||
const requestBody = { reservation_id: resIdStr, name: guestName, language: language ?? cfg.default_language }
|
const requestBody = { reservation_id: resIdStr, name: guestName, language: language ?? cfg.default_language }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
let ok: boolean, status: number
|
||||||
method: 'POST',
|
let responseText = ''
|
||||||
headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(requestBody),
|
if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) {
|
||||||
signal: AbortSignal.timeout(6000),
|
const result = await netupReqViaCfg(cfg, 'POST', `/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`, requestBody)
|
||||||
})
|
ok = result.ok; status = result.status
|
||||||
const responseText = await res.text().catch(() => '')
|
responseText = result.data ? JSON.stringify(result.data) : ''
|
||||||
|
} else {
|
||||||
|
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: AbortSignal.timeout(6000),
|
||||||
|
})
|
||||||
|
ok = res.ok; status = res.status
|
||||||
|
responseText = await res.text().catch(() => '')
|
||||||
|
}
|
||||||
|
|
||||||
const event: PushEvent = {
|
const event: PushEvent = {
|
||||||
ts: new Date().toISOString(), action: 'check-in', hotelId,
|
ts: new Date().toISOString(), action: 'check-in', hotelId,
|
||||||
roomNumber, netupRoom: mapping.netup_room_number, url,
|
roomNumber, netupRoom: mapping.netup_room_number, url,
|
||||||
requestBody,
|
requestBody,
|
||||||
status: res.ok ? 'ok' : 'error', httpStatus: res.status,
|
status: ok ? 'ok' : 'error', httpStatus: status,
|
||||||
responseBody: responseText || undefined,
|
responseBody: responseText || undefined,
|
||||||
}
|
}
|
||||||
if (!res.ok) event.error = `HTTP ${res.status}`
|
if (!ok) event.error = `HTTP ${status}`
|
||||||
recordPush(event)
|
recordPush(event)
|
||||||
return res.ok ? { ok: true, netupRoom: mapping.netup_room_number } : { ok: false, error: `HTTP ${res.status}: ${responseText}` }
|
return ok ? { ok: true, netupRoom: mapping.netup_room_number } : { ok: false, error: `HTTP ${status}: ${responseText}` }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err.message : 'Ошибка соединения'
|
const error = err instanceof Error ? err.message : 'Ошибка соединения'
|
||||||
recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, requestBody, status: 'error', error })
|
recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, requestBody, status: 'error', error })
|
||||||
@@ -451,21 +530,31 @@ export async function notifyNetupCheckout(hotelId: string, roomId: string) {
|
|||||||
|
|
||||||
const base = cfg.server_url.replace(/\/$/, '')
|
const base = cfg.server_url.replace(/\/$/, '')
|
||||||
const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`
|
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 {
|
try {
|
||||||
const res = await fetch(url, {
|
let ok: boolean, status: number, responseText = ''
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Basic ${cred}` },
|
if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) {
|
||||||
signal: AbortSignal.timeout(6000),
|
const result = await netupReqViaCfg(cfg, 'POST', `/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`)
|
||||||
})
|
ok = result.ok; status = result.status
|
||||||
const responseText = await res.text().catch(() => '')
|
responseText = result.data ? JSON.stringify(result.data) : ''
|
||||||
|
} else {
|
||||||
|
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Basic ${cred}` },
|
||||||
|
signal: AbortSignal.timeout(6000),
|
||||||
|
})
|
||||||
|
ok = res.ok; status = res.status
|
||||||
|
responseText = await res.text().catch(() => '')
|
||||||
|
}
|
||||||
|
|
||||||
recordPush({
|
recordPush({
|
||||||
ts: new Date().toISOString(), action: 'check-out', hotelId,
|
ts: new Date().toISOString(), action: 'check-out', hotelId,
|
||||||
roomNumber, netupRoom: mapping.netup_room_number, url,
|
roomNumber, netupRoom: mapping.netup_room_number, url,
|
||||||
status: res.ok ? 'ok' : 'error', httpStatus: res.status,
|
status: ok ? 'ok' : 'error', httpStatus: status,
|
||||||
responseBody: responseText || undefined,
|
responseBody: responseText || undefined,
|
||||||
error: res.ok ? undefined : `HTTP ${res.status}`,
|
error: ok ? undefined : `HTTP ${status}`,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err.message : 'Ошибка соединения'
|
const error = err instanceof Error ? err.message : 'Ошибка соединения'
|
||||||
|
|||||||
@@ -546,10 +546,10 @@ export const api = {
|
|||||||
// ── NetUP IPTV ────────────────────────────────────────────────────────────
|
// ── NetUP IPTV ────────────────────────────────────────────────────────────
|
||||||
netup: {
|
netup: {
|
||||||
getSettings: (slug: string) =>
|
getSettings: (slug: string) =>
|
||||||
req<{ serverUrl: string; username: string; password: string; defaultLanguage: string; enabled: boolean; tlToken: string }>(
|
req<{ serverUrl: string; username: string; password: string; defaultLanguage: string; enabled: boolean; tlToken: string; connectionType: string; agentWorkstationId: string | null }>(
|
||||||
'GET', `/api/hotels/${slug}/netup/settings`),
|
'GET', `/api/hotels/${slug}/netup/settings`),
|
||||||
|
|
||||||
saveSettings: (slug: string, data: { server_url: string; username: string; password?: string; default_language: string; enabled: boolean; tl_token: string }) =>
|
saveSettings: (slug: string, data: { server_url: string; username: string; password?: string; default_language: string; enabled: boolean; tl_token: string; connection_type: string; agent_workstation_id: string | null }) =>
|
||||||
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/netup/settings`, data),
|
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/netup/settings`, data),
|
||||||
|
|
||||||
testConnection: (slug: string) =>
|
testConnection: (slug: string) =>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Tv2, Plug, Map, Activity, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, RefreshCw, Trash2 } from 'lucide-react'
|
import { Tv2, Plug, Map, Activity, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, RefreshCw, Trash2, Wifi, Globe } 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'
|
||||||
|
import type { Workstation } from '../lib/api'
|
||||||
|
|
||||||
type Tab = 'connection' | 'rooms' | 'log'
|
type Tab = 'connection' | 'rooms' | 'log'
|
||||||
|
|
||||||
@@ -38,11 +39,14 @@ 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 [connectionType, setConnectionType] = useState<'direct' | 'agent'>('direct')
|
||||||
|
const [agentWorkstationId, setAgentWorkstationId] = useState<string | null>(null)
|
||||||
|
const [workstations, setWorkstations] = useState<Workstation[]>([])
|
||||||
const [settingsLoaded, setSettingsLoaded] = useState(false)
|
const [settingsLoaded, setSettingsLoaded] = useState(false)
|
||||||
|
|
||||||
// Room mappings
|
// Room mappings
|
||||||
@@ -57,7 +61,7 @@ export function TvWelcomePage() {
|
|||||||
const [testingCheckin, setTestingCheckin] = useState(false)
|
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 + workstations
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
api.netup.getSettings(slug)
|
api.netup.getSettings(slug)
|
||||||
@@ -66,9 +70,12 @@ export function TvWelcomePage() {
|
|||||||
setUsername(s.username)
|
setUsername(s.username)
|
||||||
setLanguage(s.defaultLanguage)
|
setLanguage(s.defaultLanguage)
|
||||||
setEnabled(s.enabled)
|
setEnabled(s.enabled)
|
||||||
|
setConnectionType((s.connectionType as 'direct' | 'agent') ?? 'direct')
|
||||||
|
setAgentWorkstationId(s.agentWorkstationId ?? null)
|
||||||
setSettingsLoaded(true)
|
setSettingsLoaded(true)
|
||||||
})
|
})
|
||||||
.catch(() => setSettingsLoaded(true))
|
.catch(() => setSettingsLoaded(true))
|
||||||
|
api.workstations.list(slug).then(setWorkstations).catch(() => {})
|
||||||
}, [slug])
|
}, [slug])
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -102,6 +109,8 @@ export function TvWelcomePage() {
|
|||||||
password: password || undefined,
|
password: password || undefined,
|
||||||
default_language: language, enabled,
|
default_language: language, enabled,
|
||||||
tl_token: '',
|
tl_token: '',
|
||||||
|
connection_type: connectionType,
|
||||||
|
agent_workstation_id: connectionType === 'agent' ? agentWorkstationId : null,
|
||||||
})
|
})
|
||||||
setPassword('')
|
setPassword('')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -118,6 +127,8 @@ export function TvWelcomePage() {
|
|||||||
password: password || undefined,
|
password: password || undefined,
|
||||||
default_language: language, enabled,
|
default_language: language, enabled,
|
||||||
tl_token: '',
|
tl_token: '',
|
||||||
|
connection_type: connectionType,
|
||||||
|
agent_workstation_id: connectionType === 'agent' ? agentWorkstationId : null,
|
||||||
})
|
})
|
||||||
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 })
|
||||||
@@ -226,16 +237,71 @@ export function TvWelcomePage() {
|
|||||||
|
|
||||||
<hr className="border-slate-100 dark:border-slate-700" />
|
<hr className="border-slate-100 dark:border-slate-700" />
|
||||||
|
|
||||||
|
{/* Connection type */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Режим подключения</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{([
|
||||||
|
{ id: 'direct', label: 'Прямое подключение', icon: Globe, hint: 'Сервер делает запросы напрямую к NetUP' },
|
||||||
|
{ id: 'agent', label: 'Через агент', icon: Wifi, hint: 'Запросы идут через Windows-агент в локальной сети' },
|
||||||
|
] as const).map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConnectionType(opt.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 p-3 rounded-xl border text-left transition-colors',
|
||||||
|
connectionType === opt.id
|
||||||
|
? 'border-violet-500 bg-violet-50 dark:bg-violet-900/20 text-violet-700 dark:text-violet-300'
|
||||||
|
: 'border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<opt.icon size={16} className="shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{opt.label}</p>
|
||||||
|
<p className="text-xs opacity-70 mt-0.5">{opt.hint}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent workstation selector */}
|
||||||
|
{connectionType === 'agent' && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Рабочее место с NetUP устройством</label>
|
||||||
|
<select
|
||||||
|
value={agentWorkstationId ?? ''}
|
||||||
|
onChange={e => setAgentWorkstationId(e.target.value || null)}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="">— выберите рабочее место —</option>
|
||||||
|
{workstations.map(ws => (
|
||||||
|
<option key={ws.id} value={ws.id}>
|
||||||
|
{ws.name}{ws.isOnline ? ' ✓ онлайн' : ' · офлайн'}{ws.agentVersion ? ` v${ws.agentVersion}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
На этом рабочем месте должно быть добавлено устройство типа «NetUp IPTV» в разделе Оборудование
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<hr className="border-slate-100 dark:border-slate-700" />
|
||||||
|
|
||||||
{/* 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">Адрес сервера NetUP</label>
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Адрес сервера NetUP</label>
|
||||||
<input
|
<input
|
||||||
type="text" value={serverUrl} onChange={e => setServerUrl(e.target.value)}
|
type="text" value={serverUrl} onChange={e => setServerUrl(e.target.value)}
|
||||||
placeholder="http://192.168.1.100:8880"
|
placeholder={connectionType === 'agent' ? 'http://172.16.0.12:8880' : 'http://192.168.1.100:8880'}
|
||||||
className="input w-full font-mono text-sm"
|
className="input w-full font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||||
Внешний адрес и порт, на который пробрасывается 172.16.0.12:80
|
{connectionType === 'agent'
|
||||||
|
? 'Локальный адрес NetUP в сети отеля (агент обращается к нему напрямую)'
|
||||||
|
: 'Внешний адрес и порт, на который пробрасывается 172.16.0.12:80'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user