327 lines
12 KiB
JavaScript
327 lines
12 KiB
JavaScript
import React, { useState, useCallback } from 'react';
|
|
import { RefreshCw, Settings, Save, AlertCircle, CheckCircle } from 'lucide-react';
|
|
|
|
export default function ElasticRecorderDashboard() {
|
|
const [apiKey, setApiKey] = useState('');
|
|
const [baseUrl, setBaseUrl] = useState('https://us-east-1.gvampp.com');
|
|
const [channels, setChannels] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [authenticated, setAuthenticated] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [success, setSuccess] = useState('');
|
|
const [editingId, setEditingId] = useState(null);
|
|
const [editForm, setEditForm] = useState({});
|
|
|
|
// Get auth token
|
|
const getToken = useCallback(async () => {
|
|
try {
|
|
const [username, password] = atob(apiKey).split(':');
|
|
|
|
const response = await fetch(`${baseUrl}/identity/connect/token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Basic ${apiKey}`,
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: 'client_credentials',
|
|
scope: 'platform'
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Authentication failed: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data.access_token;
|
|
} catch (err) {
|
|
throw new Error(`Token error: ${err.message}`);
|
|
}
|
|
}, [apiKey, baseUrl]);
|
|
|
|
// Fetch channels
|
|
const fetchChannels = useCallback(async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const token = await getToken();
|
|
|
|
const response = await fetch(`${baseUrl}/api/store/channel/v1/channels`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch channels: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
setChannels(data);
|
|
setAuthenticated(true);
|
|
setSuccess(`Loaded ${data.length} channels`);
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
setAuthenticated(false);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [getToken, baseUrl]);
|
|
|
|
// Handle login
|
|
const handleLogin = (e) => {
|
|
e.preventDefault();
|
|
if (apiKey && baseUrl) {
|
|
fetchChannels();
|
|
}
|
|
};
|
|
|
|
// Start editing a channel
|
|
const startEdit = (channel) => {
|
|
setEditingId(channel['channel:id']);
|
|
setEditForm({
|
|
'source:text': channel['source:text'] || '',
|
|
'destinationId:int': channel['destinationId:int'] || '',
|
|
'name:text': channel['name:text'] || ''
|
|
});
|
|
};
|
|
|
|
// Cancel edit
|
|
const cancelEdit = () => {
|
|
setEditingId(null);
|
|
setEditForm({});
|
|
};
|
|
|
|
// Save changes
|
|
const saveChanges = async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const token = await getToken();
|
|
|
|
const updateData = {
|
|
'source:text': editForm['source:text'],
|
|
'destinationId:int': editForm['destinationId:int'] ? parseInt(editForm['destinationId:int']) : null
|
|
};
|
|
|
|
const response = await fetch(`${baseUrl}/api/store/channel/v1/channels/${editingId}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(updateData)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to update channel: ${response.statusText}`);
|
|
}
|
|
|
|
setSuccess('Channel updated successfully');
|
|
setEditingId(null);
|
|
setTimeout(() => fetchChannels(), 500);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Login form
|
|
if (!authenticated) {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center p-4">
|
|
<div className="bg-slate-800 rounded-lg shadow-xl p-8 w-full max-w-md border border-slate-700">
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-bold text-white mb-2">Elastic Recorder Control</h1>
|
|
<p className="text-slate-400">Manage recording settings and folder assignments</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleLogin} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-300 mb-2">API Key</label>
|
|
<textarea
|
|
value={apiKey}
|
|
onChange={(e) => setApiKey(e.target.value)}
|
|
placeholder="Paste your base64-encoded API key"
|
|
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded text-white placeholder-slate-400 text-sm font-mono h-24"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-300 mb-2">Platform URL</label>
|
|
<input
|
|
type="text"
|
|
value={baseUrl}
|
|
onChange={(e) => setBaseUrl(e.target.value)}
|
|
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded text-white placeholder-slate-400"
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="flex gap-2 p-3 bg-red-900/20 border border-red-600 rounded text-red-400 text-sm">
|
|
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading || !apiKey}
|
|
className="w-full py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-600 text-white font-medium rounded transition"
|
|
>
|
|
{loading ? 'Connecting...' : 'Connect'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Main dashboard
|
|
return (
|
|
<div className="min-h-screen bg-slate-900 text-white p-6">
|
|
<div className="max-w-6xl mx-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 className="text-3xl font-bold mb-2">Elastic Recorder Control</h1>
|
|
<p className="text-slate-400">Manage {channels.length} recorders</p>
|
|
</div>
|
|
<button
|
|
onClick={fetchChannels}
|
|
disabled={loading}
|
|
className="flex items-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded transition"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
{/* Alerts */}
|
|
{error && (
|
|
<div className="mb-4 p-4 bg-red-900/20 border border-red-600 rounded flex gap-3">
|
|
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0" />
|
|
<div className="text-red-400">{error}</div>
|
|
</div>
|
|
)}
|
|
|
|
{success && (
|
|
<div className="mb-4 p-4 bg-green-900/20 border border-green-600 rounded flex gap-3">
|
|
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0" />
|
|
<div className="text-green-400">{success}</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Channels Grid */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{channels.map((channel) => {
|
|
const isEditing = editingId === channel['channel:id'];
|
|
|
|
return (
|
|
<div
|
|
key={channel['channel:id']}
|
|
className="bg-slate-800 border border-slate-700 rounded-lg p-6 hover:border-slate-600 transition"
|
|
>
|
|
{/* Channel Header */}
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-lg font-semibold mb-1">{channel['name:text']}</h2>
|
|
<p className="text-slate-400 text-sm font-mono">{channel['channel:id'].substring(0, 20)}...</p>
|
|
</div>
|
|
{channel['elasticRecorder'] && (
|
|
<div className="px-3 py-1 bg-blue-900/30 border border-blue-700 rounded text-xs text-blue-300 font-medium">
|
|
Elastic Recorder
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Display Mode */}
|
|
{!isEditing ? (
|
|
<>
|
|
<div className="space-y-3 mb-4">
|
|
<div>
|
|
<label className="text-xs font-medium text-slate-400 uppercase">Recording Store</label>
|
|
<p className="text-white mt-1">{channel['source:text'] || '—'}</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-slate-400 uppercase">Framelight Folder</label>
|
|
<p className="text-white mt-1">{channel['destinationId:int'] || '—'}</p>
|
|
</div>
|
|
{channel['elasticRecorder'] && (
|
|
<div>
|
|
<label className="text-xs font-medium text-slate-400 uppercase">Workload ID</label>
|
|
<p className="text-white mt-1 font-mono text-sm">{channel['elasticRecorder']['workloadId:text']?.substring(0, 16)}...</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => startEdit(channel)}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded transition"
|
|
>
|
|
<Settings className="w-4 h-4" />
|
|
Edit Settings
|
|
</button>
|
|
</>
|
|
) : (
|
|
/* Edit Mode */
|
|
<>
|
|
<div className="space-y-3 mb-4">
|
|
<div>
|
|
<label className="text-xs font-medium text-slate-400 uppercase mb-1 block">Recording Store</label>
|
|
<input
|
|
type="text"
|
|
value={editForm['source:text']}
|
|
onChange={(e) => setEditForm({...editForm, 'source:text': e.target.value})}
|
|
placeholder="e.g., /archive/store1"
|
|
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded text-white placeholder-slate-400 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-slate-400 uppercase mb-1 block">Framelight Folder ID</label>
|
|
<input
|
|
type="text"
|
|
value={editForm['destinationId:int']}
|
|
onChange={(e) => setEditForm({...editForm, 'destinationId:int': e.target.value})}
|
|
placeholder="Folder ID or path"
|
|
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded text-white placeholder-slate-400 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={saveChanges}
|
|
disabled={loading}
|
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-slate-600 text-white rounded transition"
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
Save
|
|
</button>
|
|
<button
|
|
onClick={cancelEdit}
|
|
disabled={loading}
|
|
className="flex-1 px-4 py-2 bg-slate-700 hover:bg-slate-600 disabled:bg-slate-600 text-white rounded transition"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{channels.length === 0 && (
|
|
<div className="text-center py-12 text-slate-400">
|
|
<p>No channels found. Try refreshing or check your connection.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|