feat: initial srt-server v1.0.0 — Node.js hub + React dashboard + Docker

This commit is contained in:
admin
2026-04-23 19:16:36 +03:00
commit be88a4b467
21 changed files with 1197 additions and 0 deletions
+12
View File
@@ -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>
+18
View File
@@ -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"
}
}
+20
View File
@@ -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} />
}
+81
View File
@@ -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>
)
}
+107
View File
@@ -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>
)
}
+6
View File
@@ -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 />)
+182
View File
@@ -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>
)
}
+59
View File
@@ -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>
)
}
+133
View File
@@ -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; }
+12
View File
@@ -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 }
}
}
})