Add elastic-recorder-dashboard.jsx
This commit is contained in:
parent
62528fcaf1
commit
525aa97adb
1 changed files with 327 additions and 0 deletions
327
elastic-recorder-dashboard.jsx
Normal file
327
elastic-recorder-dashboard.jsx
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue