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:
2026-03-27 13:53:55 +03:00
parent c410ef8ba4
commit 2caf717131
4 changed files with 218 additions and 59 deletions

View File

@@ -546,10 +546,10 @@ export const api = {
// ── NetUP IPTV ────────────────────────────────────────────────────────────
netup: {
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`),
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),
testConnection: (slug: string) =>

View File

@@ -1,8 +1,9 @@
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 { api } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
import type { Workstation } from '../lib/api'
type Tab = 'connection' | 'rooms' | 'log'
@@ -38,11 +39,14 @@ export function TvWelcomePage() {
const [showPass, setShowPass] = useState(false)
// Connection settings
const [serverUrl, setServerUrl] = useState('')
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('')
const [language, setLanguage] = useState('ru_RU')
const [enabled, setEnabled] = useState(true)
const [serverUrl, setServerUrl] = useState('')
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('')
const [language, setLanguage] = useState('ru_RU')
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)
// Room mappings
@@ -57,7 +61,7 @@ export function TvWelcomePage() {
const [testingCheckin, setTestingCheckin] = useState(false)
const [testCheckinResult, setTestCheckinResult] = useState<{ ok: boolean; msg: string } | null>(null)
// Load settings
// Load settings + workstations
useEffect(() => {
if (!slug) return
api.netup.getSettings(slug)
@@ -66,9 +70,12 @@ export function TvWelcomePage() {
setUsername(s.username)
setLanguage(s.defaultLanguage)
setEnabled(s.enabled)
setConnectionType((s.connectionType as 'direct' | 'agent') ?? 'direct')
setAgentWorkstationId(s.agentWorkstationId ?? null)
setSettingsLoaded(true)
})
.catch(() => setSettingsLoaded(true))
api.workstations.list(slug).then(setWorkstations).catch(() => {})
}, [slug])
// Load room mappings when tab switches (also needed on 'log' for test button)
@@ -102,6 +109,8 @@ export function TvWelcomePage() {
password: password || undefined,
default_language: language, enabled,
tl_token: '',
connection_type: connectionType,
agent_workstation_id: connectionType === 'agent' ? agentWorkstationId : null,
})
setPassword('')
} finally {
@@ -118,6 +127,8 @@ export function TvWelcomePage() {
password: password || undefined,
default_language: language, enabled,
tl_token: '',
connection_type: connectionType,
agent_workstation_id: connectionType === 'agent' ? agentWorkstationId : null,
})
const res = await api.netup.testConnection(slug)
setTestResult({ ok: true, msg: res.message })
@@ -226,16 +237,71 @@ export function TvWelcomePage() {
<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 */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Адрес сервера NetUP</label>
<input
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"
/>
<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>
</div>