feat: initial srt-server v1.0.0 — Node.js hub + React dashboard + Docker
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user