feat: initial srt-server v1.0.0 — Node.js hub + React dashboard + Docker
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
# Server
|
||||||
|
PORT=3001
|
||||||
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
# JWT secret — change this to a long random string in production
|
||||||
|
JWT_SECRET=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# Admin credentials (set on first run, or configure here)
|
||||||
|
ADMIN_USER=admin
|
||||||
|
ADMIN_PASS=changeme
|
||||||
|
|
||||||
|
# Data directory (stores agents.json, users.json)
|
||||||
|
DATA_DIR=./data
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
client/node_modules/
|
||||||
|
client/dist/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ── Публичный сервер (SSL автоматически через Let's Encrypt) ──────────────────
|
||||||
|
srt.queo.ru {
|
||||||
|
reverse_proxy /api/* localhost:3001
|
||||||
|
reverse_proxy /ws localhost:3001
|
||||||
|
reverse_proxy localhost:3001
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Raspberry Pi / локальная сеть (без SSL) ───────────────────────────────────
|
||||||
|
# :3000 {
|
||||||
|
# reverse_proxy /api/* localhost:3001
|
||||||
|
# reverse_proxy /ws localhost:3001
|
||||||
|
# reverse_proxy localhost:3001
|
||||||
|
# }
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
FROM node:20-alpine AS client-builder
|
||||||
|
WORKDIR /app/client
|
||||||
|
COPY client/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY client/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY --from=client-builder /app/client/dist ./client/dist
|
||||||
|
|
||||||
|
ENV PORT=3001
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV DATA_DIR=/data
|
||||||
|
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SRT Server</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "srt-server-client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port 5174",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import Login from './pages/Login.jsx'
|
||||||
|
import Dashboard from './pages/Dashboard.jsx'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [token, setToken] = useState(() => localStorage.getItem('srt_token') || null)
|
||||||
|
|
||||||
|
function handleLogin(jwt) {
|
||||||
|
localStorage.setItem('srt_token', jwt)
|
||||||
|
setToken(jwt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
localStorage.removeItem('srt_token')
|
||||||
|
setToken(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) return <Login onLogin={handleLogin} />
|
||||||
|
return <Dashboard token={token} onLogout={handleLogout} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
export default function AddAgentModal({ onAdd, onClose }) {
|
||||||
|
const [machineName, setMachineName] = useState('')
|
||||||
|
const [note, setNote] = useState('')
|
||||||
|
const [result, setResult] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!machineName.trim()) return
|
||||||
|
setLoading(true)
|
||||||
|
const data = await onAdd(machineName.trim(), note.trim())
|
||||||
|
setResult(data)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToken() {
|
||||||
|
navigator.clipboard.writeText(result.token)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Register new agent</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!result ? (
|
||||||
|
<form onSubmit={handleSubmit} className="modal-body">
|
||||||
|
<label className="field-label">Machine name <span className="req">*</span></label>
|
||||||
|
<input
|
||||||
|
className="field-input"
|
||||||
|
type="text"
|
||||||
|
value={machineName}
|
||||||
|
onChange={e => setMachineName(e.target.value)}
|
||||||
|
placeholder="Studio, Office, Mobile unit…"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<label className="field-label" style={{ marginTop: 12 }}>Note (optional)</label>
|
||||||
|
<input
|
||||||
|
className="field-input"
|
||||||
|
type="text"
|
||||||
|
value={note}
|
||||||
|
onChange={e => setNote(e.target.value)}
|
||||||
|
placeholder="Location, camera setup…"
|
||||||
|
/>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn btn-ghost" type="button" onClick={onClose}>Cancel</button>
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={loading || !machineName.trim()}>
|
||||||
|
{loading ? 'Creating…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="token-result">
|
||||||
|
<div className="token-result-icon">✅</div>
|
||||||
|
<p>Agent <strong>{result.machineName}</strong> registered.</p>
|
||||||
|
<p className="token-hint">Copy this token and paste it into <strong>SRT Streamer → Remote → Token</strong>:</p>
|
||||||
|
<div className="token-display-row">
|
||||||
|
<code className="token-display">{result.token}</code>
|
||||||
|
<button className="btn btn-primary" onClick={copyToken}>
|
||||||
|
{copied ? '✓ Copied!' : '📋 Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="token-warn">⚠ Save the token — it won't be shown again.</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn btn-primary" onClick={onClose}>Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
const STATUS_COLOR = { running: '#22c55e', connecting: '#f59e0b', idle: '#475569', error: '#ef4444' }
|
||||||
|
|
||||||
|
export default function AgentCard({ agent, logs, onCommand, onDelete }) {
|
||||||
|
const [showLogs, setShowLogs] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const activeStreams = (agent.streams || []).filter(s => s.status === 'running' || s.status === 'connecting')
|
||||||
|
|
||||||
|
function copyToken() {
|
||||||
|
navigator.clipboard.writeText(agent.token)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`agent-card ${agent.online ? 'agent-card--online' : 'agent-card--offline'}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="agent-header">
|
||||||
|
<div className="agent-header-left">
|
||||||
|
<span className={`agent-dot ${agent.online ? 'dot--green' : 'dot--gray'}`} />
|
||||||
|
<span className="agent-name">{agent.machineName}</span>
|
||||||
|
{agent.version && <span className="agent-version">v{agent.version}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="agent-header-right">
|
||||||
|
{activeStreams.length > 0 && (
|
||||||
|
<span className="agent-live-badge">{activeStreams.length} LIVE</span>
|
||||||
|
)}
|
||||||
|
<button className="btn-icon" onClick={onDelete} title="Revoke agent">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token */}
|
||||||
|
<div className="agent-token-row">
|
||||||
|
<code className="agent-token">{agent.token}</code>
|
||||||
|
<button className="btn btn-xs" onClick={copyToken}>
|
||||||
|
{copied ? '✓' : '📋'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Streams */}
|
||||||
|
{agent.online && (agent.streams || []).length > 0 && (
|
||||||
|
<div className="agent-streams">
|
||||||
|
{agent.streams.map(s => (
|
||||||
|
<div key={s.id} className="stream-row">
|
||||||
|
<span className="stream-dot" style={{ background: STATUS_COLOR[s.status] || '#475569' }} />
|
||||||
|
<span className="stream-id">{s.id}</span>
|
||||||
|
<span className="stream-status">{s.status}</span>
|
||||||
|
{(s.status === 'running' || s.status === 'connecting') && (
|
||||||
|
<button
|
||||||
|
className="btn btn-xs btn-stop"
|
||||||
|
onClick={() => onCommand({ type: 'stop_stream', id: s.id })}
|
||||||
|
>
|
||||||
|
■ Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{agent.online && (
|
||||||
|
<div className="agent-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
onClick={() => onCommand({ type: 'stop_all' })}
|
||||||
|
disabled={activeStreams.length === 0}
|
||||||
|
>
|
||||||
|
■ Stop All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => onCommand({ type: 'get_devices' })}
|
||||||
|
>
|
||||||
|
↻ Devices
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${showLogs ? 'btn-active' : ''}`}
|
||||||
|
onClick={() => setShowLogs(v => !v)}
|
||||||
|
>
|
||||||
|
Logs {logs.length > 0 && `(${logs.length})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!agent.online && (
|
||||||
|
<div className="agent-offline-msg">
|
||||||
|
Offline{agent.lastSeen ? ` · last seen ${new Date(agent.lastSeen).toLocaleString()}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logs */}
|
||||||
|
{showLogs && (
|
||||||
|
<div className="agent-logs">
|
||||||
|
{logs.length === 0
|
||||||
|
? <div className="log-empty">No logs yet</div>
|
||||||
|
: logs.slice(-30).map((l, i) => (
|
||||||
|
<div key={i} className="log-line">{l.text}</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './styles/index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App />)
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import AgentCard from '../components/AgentCard.jsx'
|
||||||
|
import AddAgentModal from '../components/AddAgentModal.jsx'
|
||||||
|
|
||||||
|
export default function Dashboard({ token, onLogout }) {
|
||||||
|
const [agents, setAgents] = useState([])
|
||||||
|
const [wsStatus, setWsStatus] = useState('connecting')
|
||||||
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
|
const [logs, setLogs] = useState([]) // { agentToken, id, text, time }
|
||||||
|
const wsRef = useRef(null)
|
||||||
|
|
||||||
|
// ─── Connect WebSocket ──────────────────────────────────────────────────────
|
||||||
|
const connectWs = useCallback(() => {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
const ws = new WebSocket(`${proto}://${location.host}/ws`)
|
||||||
|
wsRef.current = ws
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', jwt: token }))
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data)
|
||||||
|
handleWsMessage(msg)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setWsStatus('disconnected')
|
||||||
|
setTimeout(connectWs, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => setWsStatus('error')
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connectWs()
|
||||||
|
return () => wsRef.current?.close()
|
||||||
|
}, [connectWs])
|
||||||
|
|
||||||
|
// Keepalive
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => {
|
||||||
|
if (wsRef.current?.readyState === 1) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'ping' }))
|
||||||
|
}
|
||||||
|
}, 25000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleWsMessage(msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'auth_ok':
|
||||||
|
setWsStatus('connected')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'auth_fail':
|
||||||
|
setWsStatus('error')
|
||||||
|
onLogout()
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'agent_list':
|
||||||
|
setAgents(msg.agents || [])
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'agent_online':
|
||||||
|
setAgents(prev => {
|
||||||
|
const exists = prev.find(a => a.token === msg.agent.token)
|
||||||
|
if (exists) return prev.map(a => a.token === msg.agent.token ? { ...a, ...msg.agent, online: true } : a)
|
||||||
|
return [...prev, msg.agent]
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'agent_offline':
|
||||||
|
setAgents(prev => prev.map(a => a.token === msg.token ? { ...a, online: false, streams: [] } : a))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'agent_devices':
|
||||||
|
setAgents(prev => prev.map(a => a.token === msg.token ? { ...a, devices: msg.devices } : a))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'stream_status':
|
||||||
|
setAgents(prev => prev.map(a => {
|
||||||
|
if (a.token !== msg.token) return a
|
||||||
|
const streams = (a.streams || []).map(s => s.id === msg.id ? { ...s, status: msg.status } : s)
|
||||||
|
if (!streams.find(s => s.id === msg.id)) streams.push({ id: msg.id, status: msg.status })
|
||||||
|
return { ...a, streams }
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'all_status':
|
||||||
|
setAgents(prev => prev.map(a => a.token === msg.token ? { ...a, streams: msg.streams || [] } : a))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'log':
|
||||||
|
setLogs(prev => [...prev.slice(-999), { agentToken: msg.token, id: msg.id, text: msg.text, time: Date.now() }])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCmd(agentToken, msg) {
|
||||||
|
if (wsRef.current?.readyState === 1) {
|
||||||
|
wsRef.current.send(JSON.stringify({ ...msg, agentToken }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddAgent(machineName, note) {
|
||||||
|
const res = await fetch('/api/agents', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ machineName, note })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
setAgents(prev => [...prev, { token: data.token, machineName: data.machineName, online: false, streams: [], devices: {} }])
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAgent(agentToken) {
|
||||||
|
if (!confirm('Отозвать токен агента?')) return
|
||||||
|
await fetch(`/api/agents/${agentToken}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
setAgents(prev => prev.filter(a => a.token !== agentToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlineCount = agents.filter(a => a.online).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="dash-header">
|
||||||
|
<div className="dash-header-left">
|
||||||
|
<span className="dash-logo">▶</span>
|
||||||
|
<span className="dash-title">SRT Server</span>
|
||||||
|
<span className={`ws-badge ${wsStatus}`}>
|
||||||
|
{wsStatus === 'connected' ? '● Connected' : wsStatus === 'connecting' ? '○ Connecting…' : '✕ Disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="dash-header-right">
|
||||||
|
<span className="agents-count">{onlineCount} / {agents.length} online</span>
|
||||||
|
<button className="btn btn-primary" onClick={() => setShowAdd(true)}>+ Add Agent</button>
|
||||||
|
<button className="btn btn-ghost" onClick={onLogout}>Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Agent grid */}
|
||||||
|
<main className="dash-content">
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-icon">📡</div>
|
||||||
|
<h2>No agents registered</h2>
|
||||||
|
<p>Click <strong>+ Add Agent</strong> to register a new SRT Streamer instance</p>
|
||||||
|
<button className="btn btn-primary" onClick={() => setShowAdd(true)}>+ Add Agent</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="agents-grid">
|
||||||
|
{agents.map(agent => (
|
||||||
|
<AgentCard
|
||||||
|
key={agent.token}
|
||||||
|
agent={agent}
|
||||||
|
logs={logs.filter(l => l.agentToken === agent.token).slice(-50)}
|
||||||
|
onCommand={(msg) => sendCmd(agent.token, msg)}
|
||||||
|
onDelete={() => handleDeleteAgent(agent.token)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{showAdd && (
|
||||||
|
<AddAgentModal
|
||||||
|
onAdd={handleAddAgent}
|
||||||
|
onClose={() => setShowAdd(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
export default function Login({ onLogin }) {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { setError(data.error || 'Login failed'); return }
|
||||||
|
onLogin(data.token)
|
||||||
|
} catch {
|
||||||
|
setError('Server unavailable')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-logo">▶</div>
|
||||||
|
<h1 className="login-title">SRT Server</h1>
|
||||||
|
<p className="login-subtitle">Remote stream management</p>
|
||||||
|
<form className="login-form" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
className="login-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="login-input"
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && <div className="login-error">{error}</div>}
|
||||||
|
<button className="login-btn" type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Logging in…' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0f0f1a;
|
||||||
|
--bg2: #16213e;
|
||||||
|
--bg3: #1a1a2e;
|
||||||
|
--border: #1e2a4a;
|
||||||
|
--accent: #4f8ef7;
|
||||||
|
--green: #22c55e;
|
||||||
|
--red: #ef4444;
|
||||||
|
--orange: #f59e0b;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--text2: #94a3b8;
|
||||||
|
--text3: #475569;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root { height: 100%; background: var(--bg); }
|
||||||
|
|
||||||
|
/* ── Login ── */
|
||||||
|
.login-page { display: flex; align-items: center; justify-content: center; height: 100vh; background: var(--bg); }
|
||||||
|
.login-card { background: var(--bg3); border: 1px solid var(--border); border-radius: 16px; padding: 40px 36px; width: 360px; text-align: center; }
|
||||||
|
.login-logo { font-size: 36px; color: var(--accent); margin-bottom: 12px; }
|
||||||
|
.login-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
|
||||||
|
.login-subtitle { color: var(--text2); font-size: 13px; margin-bottom: 28px; }
|
||||||
|
.login-form { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.login-input { background: #0d1117; border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 10px 14px; font-size: 13px; outline: none; }
|
||||||
|
.login-input:focus { border-color: var(--accent); }
|
||||||
|
.login-error { color: var(--red); font-size: 12px; }
|
||||||
|
.login-btn { background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: 10px; font-size: 13px; font-weight: 600; cursor: pointer; margin-top: 4px; }
|
||||||
|
.login-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
|
.login-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Dashboard ── */
|
||||||
|
.dashboard { display: flex; flex-direction: column; height: 100vh; }
|
||||||
|
|
||||||
|
.dash-header { display: flex; align-items: center; justify-content: space-between; padding: 0 20px; height: 52px; background: var(--bg2); border-bottom: 1px solid var(--border); flex-shrink: 0; gap: 12px; }
|
||||||
|
.dash-header-left, .dash-header-right { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.dash-logo { color: var(--accent); font-size: 18px; }
|
||||||
|
.dash-title { font-size: 15px; font-weight: 700; }
|
||||||
|
|
||||||
|
.ws-badge { font-size: 11px; padding: 2px 8px; border-radius: 8px; font-weight: 600; }
|
||||||
|
.ws-badge.connected { background: rgba(34,197,94,.15); color: var(--green); }
|
||||||
|
.ws-badge.connecting { background: rgba(245,158,11,.15); color: var(--orange); }
|
||||||
|
.ws-badge.disconnected, .ws-badge.error { background: rgba(239,68,68,.15); color: var(--red); }
|
||||||
|
|
||||||
|
.agents-count { font-size: 12px; color: var(--text2); }
|
||||||
|
|
||||||
|
.dash-content { flex: 1; overflow-y: auto; padding: 20px; }
|
||||||
|
|
||||||
|
/* ── Agent grid ── */
|
||||||
|
.agents-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; }
|
||||||
|
|
||||||
|
.agent-card { background: var(--bg3); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||||
|
.agent-card--online { border-color: rgba(34,197,94,.3); }
|
||||||
|
.agent-card--offline { opacity: 0.65; }
|
||||||
|
|
||||||
|
.agent-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; background: var(--bg2); border-bottom: 1px solid var(--border); }
|
||||||
|
.agent-header-left, .agent-header-right { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
.agent-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.dot--green { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||||
|
.dot--gray { background: var(--text3); }
|
||||||
|
|
||||||
|
.agent-name { font-weight: 600; font-size: 13.5px; }
|
||||||
|
.agent-version { font-size: 10px; color: var(--text3); padding: 1px 5px; border: 1px solid var(--border); border-radius: 4px; }
|
||||||
|
|
||||||
|
.agent-live-badge { font-size: 10px; font-weight: 700; background: var(--red); color: #fff; padding: 2px 6px; border-radius: 6px; }
|
||||||
|
|
||||||
|
.agent-token-row { display: flex; align-items: center; gap: 6px; padding: 8px 14px; border-bottom: 1px solid var(--border); }
|
||||||
|
.agent-token { font-family: monospace; font-size: 10.5px; color: var(--text2); flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.agent-streams { padding: 8px 14px; display: flex; flex-direction: column; gap: 4px; border-bottom: 1px solid var(--border); }
|
||||||
|
.stream-row { display: flex; align-items: center; gap: 6px; font-size: 12px; }
|
||||||
|
.stream-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.stream-id { flex: 1; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.stream-status { color: var(--text3); font-size: 11px; }
|
||||||
|
|
||||||
|
.agent-actions { display: flex; gap: 6px; padding: 10px 14px; flex-wrap: wrap; }
|
||||||
|
.agent-offline-msg { padding: 10px 14px; font-size: 11.5px; color: var(--text3); }
|
||||||
|
|
||||||
|
.agent-logs { max-height: 140px; overflow-y: auto; padding: 8px 14px; background: #0d1117; border-top: 1px solid var(--border); font-family: monospace; font-size: 10.5px; color: var(--text2); }
|
||||||
|
.agent-logs .log-line { padding: 1px 0; word-break: break-all; }
|
||||||
|
.agent-logs .log-empty { color: var(--text3); }
|
||||||
|
|
||||||
|
/* ── Buttons ── */
|
||||||
|
.btn { border: none; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 500; padding: 5px 12px; transition: filter .15s; }
|
||||||
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.btn:not(:disabled):hover { filter: brightness(1.1); }
|
||||||
|
.btn-primary { background: var(--accent); color: #fff; }
|
||||||
|
.btn-ghost { background: transparent; color: var(--text2); border: 1px solid var(--border); }
|
||||||
|
.btn-ghost:hover { color: var(--text); border-color: #2d3f6a; }
|
||||||
|
.btn-danger { background: var(--red); color: #fff; }
|
||||||
|
.btn-stop { background: rgba(239,68,68,.15); color: var(--red); border: 1px solid rgba(239,68,68,.3); }
|
||||||
|
.btn-sm { padding: 3px 10px; font-size: 11.5px; background: transparent; border: 1px solid var(--border); color: var(--text2); }
|
||||||
|
.btn-sm:not(:disabled):hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.btn-sm.btn-active { background: rgba(79,142,247,.15); border-color: var(--accent); color: var(--accent); }
|
||||||
|
.btn-xs { padding: 2px 7px; font-size: 11px; background: transparent; border: 1px solid var(--border); color: var(--text3); border-radius: 4px; cursor: pointer; }
|
||||||
|
.btn-xs:hover { color: var(--text); }
|
||||||
|
.btn-icon { background: transparent; border: none; color: var(--text3); cursor: pointer; font-size: 14px; padding: 2px 4px; border-radius: 4px; }
|
||||||
|
.btn-icon:hover { color: var(--red); }
|
||||||
|
|
||||||
|
/* ── Empty state ── */
|
||||||
|
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 80px 20px; text-align: center; color: var(--text2); }
|
||||||
|
.empty-icon { font-size: 48px; }
|
||||||
|
.empty-state h2 { font-size: 18px; color: var(--text); }
|
||||||
|
.empty-state p { color: var(--text2); }
|
||||||
|
|
||||||
|
/* ── Modal ── */
|
||||||
|
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||||
|
.modal { background: var(--bg3); border: 1px solid var(--border); border-radius: 14px; width: 440px; max-width: 95vw; }
|
||||||
|
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||||
|
.modal-header h2 { font-size: 15px; font-weight: 600; }
|
||||||
|
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||||
|
|
||||||
|
.field-label { font-size: 11.5px; color: var(--text2); display: block; margin-bottom: 4px; }
|
||||||
|
.field-input { width: 100%; background: #0d1117; border: 1px solid var(--border); color: var(--text); border-radius: 7px; padding: 8px 12px; font-size: 13px; outline: none; }
|
||||||
|
.field-input:focus { border-color: var(--accent); }
|
||||||
|
.req { color: var(--red); }
|
||||||
|
|
||||||
|
/* Token result */
|
||||||
|
.token-result { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.token-result-icon { font-size: 28px; }
|
||||||
|
.token-hint { font-size: 12px; color: var(--text2); }
|
||||||
|
.token-warn { font-size: 11.5px; color: var(--orange); }
|
||||||
|
.token-display-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.token-display { font-family: monospace; font-size: 11px; background: #0d1117; border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; flex: 1; word-break: break-all; color: #7dd3fc; }
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
* { scrollbar-width: thin; scrollbar-color: #1e2a4a transparent; }
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3001',
|
||||||
|
'/ws': { target: 'ws://localhost:3001', ws: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
srt-server:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- srt-data:/data
|
||||||
|
environment:
|
||||||
|
- PORT=3001
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||||
|
- ADMIN_USER=${ADMIN_USER:-admin}
|
||||||
|
- ADMIN_PASS=${ADMIN_PASS:-changeme}
|
||||||
|
- DATA_DIR=/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
srt-data:
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "srt-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Central management server for SRT Streamer agents",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "nodemon src/server.js",
|
||||||
|
"build:client": "cd client && npm install && npm run build",
|
||||||
|
"setup": "npm install && npm run build:client",
|
||||||
|
"docker:build": "docker build -t srt-server .",
|
||||||
|
"docker:run": "docker-compose up -d"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
'use strict'
|
||||||
|
const express = require('express')
|
||||||
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
const { login, requireAuth, changePassword } = require('./auth')
|
||||||
|
const { loadAgents, saveAgents } = require('./config')
|
||||||
|
const hub = require('./hub')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
router.post('/auth/login', (req, res) => {
|
||||||
|
const { username, password } = req.body
|
||||||
|
if (!username || !password) return res.status(400).json({ error: 'Missing credentials' })
|
||||||
|
const token = login(username, password)
|
||||||
|
if (!token) return res.status(401).json({ error: 'Invalid username or password' })
|
||||||
|
res.json({ token })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/auth/change-password', requireAuth, (req, res) => {
|
||||||
|
const { newPassword } = req.body
|
||||||
|
if (!newPassword || newPassword.length < 6)
|
||||||
|
return res.status(400).json({ error: 'Password must be at least 6 characters' })
|
||||||
|
changePassword(req.user.username, newPassword)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Agents ───────────────────────────────────────────────────────────────────
|
||||||
|
// List all registered agents (with online status)
|
||||||
|
router.get('/agents', requireAuth, (req, res) => {
|
||||||
|
const agents = loadAgents()
|
||||||
|
const list = Object.entries(agents).map(([token, meta]) => {
|
||||||
|
const live = hub.agents.get(token)
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
machineName: meta.machineName || token.slice(0, 8),
|
||||||
|
note: meta.note || '',
|
||||||
|
online: !!live,
|
||||||
|
version: meta.version || '',
|
||||||
|
connectedAt: meta.connectedAt,
|
||||||
|
lastSeen: meta.lastSeen,
|
||||||
|
streams: live?.streams || [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
res.json(list)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register new agent — generates a token to paste into SRT Streamer
|
||||||
|
router.post('/agents', requireAuth, (req, res) => {
|
||||||
|
const { machineName, note } = req.body
|
||||||
|
if (!machineName) return res.status(400).json({ error: 'machineName required' })
|
||||||
|
const token = uuidv4()
|
||||||
|
const agents = loadAgents()
|
||||||
|
agents[token] = {
|
||||||
|
machineName,
|
||||||
|
note: note || '',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
online: false
|
||||||
|
}
|
||||||
|
saveAgents(agents)
|
||||||
|
res.json({ token, machineName })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update agent meta (name, note)
|
||||||
|
router.patch('/agents/:token', requireAuth, (req, res) => {
|
||||||
|
const agents = loadAgents()
|
||||||
|
if (!agents[req.params.token]) return res.status(404).json({ error: 'Not found' })
|
||||||
|
const { machineName, note } = req.body
|
||||||
|
if (machineName) agents[req.params.token].machineName = machineName
|
||||||
|
if (note !== undefined) agents[req.params.token].note = note
|
||||||
|
saveAgents(agents)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete / revoke agent
|
||||||
|
router.delete('/agents/:token', requireAuth, (req, res) => {
|
||||||
|
const agents = loadAgents()
|
||||||
|
if (!agents[req.params.token]) return res.status(404).json({ error: 'Not found' })
|
||||||
|
delete agents[req.params.token]
|
||||||
|
saveAgents(agents)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get devices of a specific agent
|
||||||
|
router.get('/agents/:token/devices', requireAuth, (req, res) => {
|
||||||
|
const live = hub.agents.get(req.params.token)
|
||||||
|
if (!live) return res.status(404).json({ error: 'Agent not connected' })
|
||||||
|
res.json(live.devices || {})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Stream commands (proxied to agent via hub) ───────────────────────────────
|
||||||
|
router.post('/agents/:token/start', requireAuth, (req, res) => {
|
||||||
|
const live = hub.agents.get(req.params.token)
|
||||||
|
if (!live) return res.status(404).json({ error: 'Agent not connected' })
|
||||||
|
hub._sendToAgent(req.params.token, { type: 'start_stream', config: req.body })
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/agents/:token/stop', requireAuth, (req, res) => {
|
||||||
|
const { streamId } = req.body
|
||||||
|
hub._sendToAgent(req.params.token, { type: 'stop_stream', id: streamId })
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/agents/:token/stop-all', requireAuth, (req, res) => {
|
||||||
|
hub._sendToAgent(req.params.token, { type: 'stop_all' })
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
'use strict'
|
||||||
|
const jwt = require('jsonwebtoken')
|
||||||
|
const bcrypt = require('bcryptjs')
|
||||||
|
const { loadUsers, saveUsers } = require('./config')
|
||||||
|
|
||||||
|
const SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-prod'
|
||||||
|
|
||||||
|
// ─── Ensure default admin exists on first run ─────────────────────────────────
|
||||||
|
function ensureDefaultAdmin() {
|
||||||
|
const users = loadUsers()
|
||||||
|
if (Object.keys(users).length === 0) {
|
||||||
|
const adminUser = process.env.ADMIN_USER || 'admin'
|
||||||
|
const adminPass = process.env.ADMIN_PASS || 'changeme'
|
||||||
|
users[adminUser] = {
|
||||||
|
passwordHash: bcrypt.hashSync(adminPass, 10),
|
||||||
|
role: 'admin',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
saveUsers(users)
|
||||||
|
console.log(`[Auth] Created default admin: ${adminUser} / ${adminPass}`)
|
||||||
|
console.log('[Auth] ⚠️ Change the password after first login!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Verify login ─────────────────────────────────────────────────────────────
|
||||||
|
function login(username, password) {
|
||||||
|
const users = loadUsers()
|
||||||
|
const user = users[username]
|
||||||
|
if (!user) return null
|
||||||
|
if (!bcrypt.compareSync(password, user.passwordHash)) return null
|
||||||
|
const token = jwt.sign({ username, role: user.role }, SECRET, { expiresIn: '7d' })
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Change password ──────────────────────────────────────────────────────────
|
||||||
|
function changePassword(username, newPassword) {
|
||||||
|
const users = loadUsers()
|
||||||
|
if (!users[username]) return false
|
||||||
|
users[username].passwordHash = bcrypt.hashSync(newPassword, 10)
|
||||||
|
saveUsers(users)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Middleware: verify JWT from Authorization header ─────────────────────────
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
const header = req.headers.authorization || ''
|
||||||
|
const token = header.startsWith('Bearer ') ? header.slice(7) : null
|
||||||
|
if (!token) return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(token, SECRET)
|
||||||
|
next()
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: 'Invalid token' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Verify JWT for WebSocket connections ────────────────────────────────────
|
||||||
|
function verifyJwt(token) {
|
||||||
|
try { return jwt.verify(token, SECRET) }
|
||||||
|
catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ensureDefaultAdmin, login, changePassword, requireAuth, verifyJwt }
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use strict'
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data')
|
||||||
|
|
||||||
|
function ensureDir() {
|
||||||
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(file, defaultVal = {}) {
|
||||||
|
ensureDir()
|
||||||
|
const p = path.join(DATA_DIR, file)
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf8'))
|
||||||
|
} catch {}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(file, data) {
|
||||||
|
ensureDir()
|
||||||
|
fs.writeFileSync(path.join(DATA_DIR, file), JSON.stringify(data, null, 2), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Registered agents (tokens → meta) ───────────────────────────────────────
|
||||||
|
function loadAgents() { return load('agents.json', {}) }
|
||||||
|
function saveAgents(agents) { save('agents.json', agents) }
|
||||||
|
|
||||||
|
// ─── Admin users ──────────────────────────────────────────────────────────────
|
||||||
|
function loadUsers() { return load('users.json', {}) }
|
||||||
|
function saveUsers(users) { save('users.json', users) }
|
||||||
|
|
||||||
|
module.exports = { loadAgents, saveAgents, loadUsers, saveUsers }
|
||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
'use strict'
|
||||||
|
const { EventEmitter } = require('events')
|
||||||
|
const { loadAgents, saveAgents } = require('./config')
|
||||||
|
const { verifyJwt } = require('./auth')
|
||||||
|
|
||||||
|
// ─── Hub — центральная шина между агентами и браузерами ───────────────────────
|
||||||
|
class Hub extends EventEmitter {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
// Активные WS соединения
|
||||||
|
this.agents = new Map() // token → { ws, meta, devices, streams }
|
||||||
|
this.browsers = new Set() // ws connections от авторизованных браузеров
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Входящее WS соединение ──────────────────────────────────────────────
|
||||||
|
handleConnection(ws, req) {
|
||||||
|
let authenticated = false
|
||||||
|
let connType = null // 'agent' | 'browser'
|
||||||
|
let agentToken = null
|
||||||
|
|
||||||
|
const authTimeout = setTimeout(() => {
|
||||||
|
if (!authenticated) ws.terminate()
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let msg
|
||||||
|
try { msg = JSON.parse(raw) } catch { return }
|
||||||
|
|
||||||
|
// ── Первое сообщение — авторизация ──────────────────────────────────
|
||||||
|
if (!authenticated) {
|
||||||
|
if (msg.type === 'auth' && msg.token) {
|
||||||
|
// Агент подключается с токеном
|
||||||
|
const agents = loadAgents()
|
||||||
|
if (!agents[msg.token]) {
|
||||||
|
this._send(ws, { type: 'auth_fail', reason: 'Unknown token. Register agent first.' })
|
||||||
|
ws.terminate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearTimeout(authTimeout)
|
||||||
|
authenticated = true
|
||||||
|
connType = 'agent'
|
||||||
|
agentToken = msg.token
|
||||||
|
|
||||||
|
// Сохраняем соединение
|
||||||
|
const meta = agents[msg.token]
|
||||||
|
meta.machineName = msg.machineName || meta.machineName
|
||||||
|
meta.version = msg.version || ''
|
||||||
|
meta.connectedAt = new Date().toISOString()
|
||||||
|
meta.online = true
|
||||||
|
agents[msg.token] = meta
|
||||||
|
saveAgents(agents)
|
||||||
|
|
||||||
|
this.agents.set(msg.token, { ws, meta, devices: {}, streams: [] })
|
||||||
|
this._send(ws, { type: 'auth_ok', serverName: 'SRT Server' })
|
||||||
|
|
||||||
|
// Запрашиваем список устройств
|
||||||
|
this._send(ws, { type: 'get_devices' })
|
||||||
|
|
||||||
|
// Оповещаем браузеры
|
||||||
|
this._broadcastBrowsers({ type: 'agent_online', agent: this._agentInfo(msg.token) })
|
||||||
|
console.log(`[Hub] Agent connected: ${meta.machineName} (${msg.token.slice(0, 8)}…)`)
|
||||||
|
|
||||||
|
} else if (msg.type === 'auth' && msg.jwt) {
|
||||||
|
// Браузер подключается с JWT
|
||||||
|
const user = verifyJwt(msg.jwt)
|
||||||
|
if (!user) {
|
||||||
|
this._send(ws, { type: 'auth_fail', reason: 'Invalid session' })
|
||||||
|
ws.terminate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearTimeout(authTimeout)
|
||||||
|
authenticated = true
|
||||||
|
connType = 'browser'
|
||||||
|
|
||||||
|
this.browsers.add(ws)
|
||||||
|
this._send(ws, { type: 'auth_ok' })
|
||||||
|
|
||||||
|
// Отправляем текущий список агентов
|
||||||
|
this._send(ws, { type: 'agent_list', agents: this._allAgentInfos() })
|
||||||
|
console.log(`[Hub] Browser connected: ${user.username}`)
|
||||||
|
} else {
|
||||||
|
ws.terminate()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Авторизованные сообщения ─────────────────────────────────────────
|
||||||
|
if (connType === 'agent') {
|
||||||
|
this._handleAgentMessage(agentToken, msg)
|
||||||
|
} else if (connType === 'browser') {
|
||||||
|
this._handleBrowserMessage(ws, msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
clearTimeout(authTimeout)
|
||||||
|
if (connType === 'agent' && agentToken) {
|
||||||
|
this.agents.delete(agentToken)
|
||||||
|
// Помечаем offline в хранилище
|
||||||
|
const agents = loadAgents()
|
||||||
|
if (agents[agentToken]) {
|
||||||
|
agents[agentToken].online = false
|
||||||
|
agents[agentToken].lastSeen = new Date().toISOString()
|
||||||
|
saveAgents(agents)
|
||||||
|
}
|
||||||
|
this._broadcastBrowsers({ type: 'agent_offline', token: agentToken })
|
||||||
|
console.log(`[Hub] Agent disconnected: ${agentToken.slice(0, 8)}…`)
|
||||||
|
} else if (connType === 'browser') {
|
||||||
|
this.browsers.delete(ws)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('error', () => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Сообщения от агентов ────────────────────────────────────────────────
|
||||||
|
_handleAgentMessage(token, msg) {
|
||||||
|
const agent = this.agents.get(token)
|
||||||
|
if (!agent) return
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'devices':
|
||||||
|
agent.devices = msg.devices
|
||||||
|
this._broadcastBrowsers({ type: 'agent_devices', token, devices: msg.devices })
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'stream_status':
|
||||||
|
// Обновляем локальный список потоков
|
||||||
|
const stream = (agent.streams || []).find(s => s.id === msg.id)
|
||||||
|
if (stream) stream.status = msg.status
|
||||||
|
else agent.streams = [...(agent.streams || []), { id: msg.id, status: msg.status }]
|
||||||
|
this._broadcastBrowsers({ type: 'stream_status', token, id: msg.id, status: msg.status })
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'all_status':
|
||||||
|
agent.streams = msg.streams || []
|
||||||
|
this._broadcastBrowsers({ type: 'all_status', token, streams: msg.streams })
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'log':
|
||||||
|
// Пробрасываем логи в браузеры
|
||||||
|
this._broadcastBrowsers({ type: 'log', token, id: msg.id, text: msg.text })
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ping':
|
||||||
|
this._send(agent.ws, { type: 'pong' })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Команды от браузеров ────────────────────────────────────────────────
|
||||||
|
_handleBrowserMessage(browserWs, msg) {
|
||||||
|
const { agentToken } = msg
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'start_stream':
|
||||||
|
this._sendToAgent(agentToken, { type: 'start_stream', config: msg.config })
|
||||||
|
break
|
||||||
|
case 'stop_stream':
|
||||||
|
this._sendToAgent(agentToken, { type: 'stop_stream', id: msg.id })
|
||||||
|
break
|
||||||
|
case 'update_stream':
|
||||||
|
this._sendToAgent(agentToken, { type: 'update_stream', id: msg.id, changes: msg.changes })
|
||||||
|
break
|
||||||
|
case 'stop_all':
|
||||||
|
this._sendToAgent(agentToken, { type: 'stop_all' })
|
||||||
|
break
|
||||||
|
case 'get_devices':
|
||||||
|
this._sendToAgent(agentToken, { type: 'get_devices' })
|
||||||
|
break
|
||||||
|
case 'ping':
|
||||||
|
this._send(browserWs, { type: 'pong' })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
_send(ws, obj) {
|
||||||
|
try {
|
||||||
|
if (ws.readyState === 1) ws.send(JSON.stringify(obj))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendToAgent(token, msg) {
|
||||||
|
const agent = this.agents.get(token)
|
||||||
|
if (agent) this._send(agent.ws, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
_broadcastBrowsers(msg) {
|
||||||
|
for (const ws of this.browsers) this._send(ws, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
_agentInfo(token) {
|
||||||
|
const stored = loadAgents()[token] || {}
|
||||||
|
const live = this.agents.get(token)
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
machineName: stored.machineName || token.slice(0, 8),
|
||||||
|
version: stored.version || '',
|
||||||
|
online: !!live,
|
||||||
|
connectedAt: stored.connectedAt,
|
||||||
|
lastSeen: stored.lastSeen,
|
||||||
|
devices: live?.devices || {},
|
||||||
|
streams: live?.streams || [],
|
||||||
|
note: stored.note || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_allAgentInfos() {
|
||||||
|
const agents = loadAgents()
|
||||||
|
return Object.keys(agents).map(t => this._agentInfo(t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new Hub()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use strict'
|
||||||
|
require('dotenv').config()
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const http = require('http')
|
||||||
|
const path = require('path')
|
||||||
|
const { WebSocketServer } = require('ws')
|
||||||
|
|
||||||
|
const { ensureDefaultAdmin } = require('./auth')
|
||||||
|
const api = require('./api')
|
||||||
|
const hub = require('./hub')
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT) || 3001
|
||||||
|
const HOST = process.env.HOST || '0.0.0.0'
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
// ─── Middleware ────────────────────────────────────────────────────────────────
|
||||||
|
app.use(require('cors')())
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
// ─── API routes ───────────────────────────────────────────────────────────────
|
||||||
|
app.use('/api', api)
|
||||||
|
|
||||||
|
// ─── Serve React client (built) ───────────────────────────────────────────────
|
||||||
|
const clientDist = path.join(__dirname, '..', 'client', 'dist')
|
||||||
|
app.use(express.static(clientDist))
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(clientDist, 'index.html'), (err) => {
|
||||||
|
if (err) res.status(200).send('<h2>SRT Server — run <code>npm run build:client</code> to build the UI</h2>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── HTTP + WebSocket server ──────────────────────────────────────────────────
|
||||||
|
const server = http.createServer(app)
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ server, path: '/ws' })
|
||||||
|
wss.on('connection', (ws, req) => hub.handleConnection(ws, req))
|
||||||
|
|
||||||
|
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||||
|
ensureDefaultAdmin()
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
console.log(`╔═══════════════════════════════════════╗`)
|
||||||
|
console.log(`║ SRT Server v1.0.0 ║`)
|
||||||
|
console.log(`╠═══════════════════════════════════════╣`)
|
||||||
|
console.log(`║ HTTP → http://${HOST}:${PORT}`.padEnd(42) + `║`)
|
||||||
|
console.log(`║ WS → ws://${HOST}:${PORT}/ws`.padEnd(42) + `║`)
|
||||||
|
console.log(`╚═══════════════════════════════════════╝`)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user