299 lines
12 KiB
HTML
299 lines
12 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Elastic Recorder Control</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0f172a; color: #fff; }
|
|
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
|
h1 { font-size: 32px; font-weight: bold; margin-bottom: 8px; }
|
|
.subtitle { color: #94a3b8; margin-bottom: 32px; }
|
|
|
|
.login-card { max-width: 500px; background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 32px; margin-bottom: 32px; }
|
|
.form-group { margin-bottom: 16px; }
|
|
label { display: block; font-size: 14px; font-weight: 600; color: #cbd5e1; margin-bottom: 8px; text-transform: uppercase; }
|
|
input, textarea { width: 100%; padding: 10px; background: #0f172a; border: 1px solid #475569; border-radius: 4px; color: #fff; font-family: inherit; }
|
|
input:focus, textarea:focus { outline: none; border-color: #3b82f6; }
|
|
textarea { height: 100px; resize: vertical; font-family: monospace; font-size: 12px; }
|
|
button { padding: 10px 16px; background: #3b82f6; color: #fff; font-weight: 600; border: none; border-radius: 4px; cursor: pointer; transition: background 0.2s; }
|
|
button:hover { background: #2563eb; }
|
|
button:disabled { background: #475569; cursor: not-allowed; opacity: 0.6; }
|
|
|
|
.alert { padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; display: flex; gap: 12px; font-size: 14px; }
|
|
.alert-error { background: rgba(220, 38, 38, 0.1); border: 1px solid #dc2626; color: #fca5a5; }
|
|
.alert-success { background: rgba(34, 197, 94, 0.1); border: 1px solid #16a34a; color: #86efac; }
|
|
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
|
.card { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 20px; }
|
|
.card h2 { font-size: 18px; margin-bottom: 12px; }
|
|
.card-id { font-size: 12px; color: #64748b; font-family: monospace; margin-bottom: 16px; }
|
|
.field { margin-bottom: 12px; }
|
|
.field-label { font-size: 12px; color: #94a3b8; text-transform: uppercase; font-weight: 600; }
|
|
.field-value { color: #e2e8f0; margin-top: 4px; word-break: break-all; }
|
|
.field-input { width: 100%; padding: 8px; background: #0f172a; border: 1px solid #475569; border-radius: 4px; color: #fff; font-size: 14px; }
|
|
|
|
.card-actions { display: flex; gap: 8px; margin-top: 16px; }
|
|
.card-actions button { flex: 1; padding: 8px 12px; font-size: 14px; }
|
|
.btn-save { background: #16a34a; }
|
|
.btn-save:hover { background: #15803d; }
|
|
.btn-cancel { background: #475569; }
|
|
.btn-cancel:hover { background: #64748b; }
|
|
|
|
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px; }
|
|
.header-buttons { display: flex; gap: 8px; }
|
|
.hidden { display: none; }
|
|
.loading { color: #94a3b8; text-align: center; padding: 48px 0; }
|
|
.empty { color: #94a3b8; text-align: center; padding: 48px 0; }
|
|
.spinner { animation: spin 1s linear infinite; display: inline-block; }
|
|
@keyframes spin { 0% { transform: rotate(0); } 100% { transform: rotate(360deg); } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<!-- Login View -->
|
|
<div id="loginView">
|
|
<div>
|
|
<h1>Elastic Recorder Control</h1>
|
|
<p class="subtitle">Manage recording settings and folder assignments</p>
|
|
</div>
|
|
|
|
<div class="login-card">
|
|
<form id="loginForm">
|
|
<div class="form-group">
|
|
<label>API Key (Base64)</label>
|
|
<textarea id="apiKey" placeholder="Paste your base64-encoded API key"></textarea>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Platform URL</label>
|
|
<input type="text" id="baseUrl" value="https://us-east-1.gvampp.com">
|
|
</div>
|
|
|
|
<div id="loginAlert" class="alert alert-error hidden"></div>
|
|
|
|
<button type="submit">Connect</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dashboard View -->
|
|
<div id="dashboardView" class="hidden">
|
|
<div class="header">
|
|
<div>
|
|
<h1>Elastic Recorder Control</h1>
|
|
<p class="subtitle" id="channelCount">Loading...</p>
|
|
</div>
|
|
<div class="header-buttons">
|
|
<button onclick="refresh()">↻ Refresh</button>
|
|
<button onclick="logout()">Logout</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="dashAlert" class="alert hidden"></div>
|
|
|
|
<div id="channelsGrid" class="grid">
|
|
<div class="loading">Loading channels...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let state = {
|
|
token: null,
|
|
baseUrl: null,
|
|
apiKey: null,
|
|
channels: [],
|
|
editing: null
|
|
};
|
|
|
|
async function login(e) {
|
|
e.preventDefault();
|
|
const apiKey = document.getElementById('apiKey').value.trim();
|
|
const baseUrl = document.getElementById('baseUrl').value.trim();
|
|
|
|
if (!apiKey) {
|
|
showAlert('loginAlert', 'Please enter API key', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ apiKey, baseUrl })
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || 'Authentication failed');
|
|
}
|
|
|
|
const data = await res.json();
|
|
state.token = data.access_token;
|
|
state.apiKey = apiKey;
|
|
state.baseUrl = baseUrl;
|
|
|
|
document.getElementById('loginView').classList.add('hidden');
|
|
document.getElementById('dashboardView').classList.remove('hidden');
|
|
|
|
await loadChannels();
|
|
} catch (err) {
|
|
showAlert('loginAlert', err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadChannels() {
|
|
try {
|
|
const res = await fetch(`/api/channels?token=${encodeURIComponent(state.token)}&baseUrl=${encodeURIComponent(state.baseUrl)}`);
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || 'Failed to load channels');
|
|
}
|
|
|
|
state.channels = await res.json();
|
|
renderChannels();
|
|
} catch (err) {
|
|
showAlert('dashAlert', err.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderChannels() {
|
|
const grid = document.getElementById('channelsGrid');
|
|
|
|
// Show all running recorders (already filtered server-side)
|
|
const recorders = state.channels;
|
|
|
|
// Update the count display
|
|
document.getElementById('channelCount').textContent = `${recorders.length} Active Recorder${recorders.length !== 1 ? 's' : ''}`;
|
|
|
|
if (recorders.length === 0) {
|
|
grid.innerHTML = '<div class="empty">No active recorders found. Check that your recorders are running in AMPP.</div>';
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = recorders.map(ch => {
|
|
const isEditing = ch['channel:id'] && state.editing === ch['channel:id'];
|
|
|
|
const healthStatus = ch._health || 'Unknown';
|
|
const statusText = ch._status || 'Unknown';
|
|
const healthColor = healthStatus === 'OK' ? '#22c55e' : (healthStatus === 'Unknown' ? '#94a3b8' : '#f59e0b');
|
|
const canEdit = !!ch['channel:id'];
|
|
|
|
if (isEditing) {
|
|
return `
|
|
<div class="card">
|
|
<div style="display:flex; justify-content:space-between; align-items:start; margin-bottom:8px;">
|
|
<h2>${ch['name:text']}</h2>
|
|
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${healthColor};margin-top:6px;flex-shrink:0;"></span>
|
|
</div>
|
|
<div style="font-size:12px;color:#94a3b8;margin-bottom:16px;">Status: ${statusText} · Health: ${healthStatus}</div>
|
|
|
|
<div class="form-group">
|
|
<label>Recording Store</label>
|
|
<input type="text" id="source-${ch['channel:id']}" value="${ch['source:text'] || ''}" placeholder="/archive/store1" class="field-input">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Framelight Folder ID</label>
|
|
<input type="text" id="dest-${ch['channel:id']}" value="${ch['destinationId:int'] || ''}" placeholder="Folder ID" class="field-input">
|
|
</div>
|
|
|
|
<div class="card-actions">
|
|
<button class="btn-save" onclick="save('${ch['channel:id']}')">Save</button>
|
|
<button class="btn-cancel" onclick="cancelEdit()">Cancel</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="card">
|
|
<div style="display:flex; justify-content:space-between; align-items:start; margin-bottom:8px;">
|
|
<h2>${ch['name:text']}</h2>
|
|
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${healthColor};flex-shrink:0;"></span>
|
|
</div>
|
|
<div style="font-size:12px;color:#94a3b8;margin-bottom:16px;">Status: ${statusText} · Health: ${healthStatus}</div>
|
|
|
|
<div class="field">
|
|
<div class="field-label">Recording Store</div>
|
|
<div class="field-value">${ch['source:text'] || '—'}</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<div class="field-label">Framelight Folder</div>
|
|
<div class="field-value">${ch['destinationId:int'] || '—'}</div>
|
|
</div>
|
|
|
|
<div class="card-actions">
|
|
${canEdit ? `<button onclick="edit('${ch['channel:id']}')">Edit</button>` : `<button disabled title="No channel linked">Edit</button>`}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
async function save(id) {
|
|
try {
|
|
const source = document.getElementById(`source-${id}`).value;
|
|
const dest = document.getElementById(`dest-${id}`).value;
|
|
|
|
const res = await fetch(`/api/channels/${id}?token=${encodeURIComponent(state.token)}&baseUrl=${encodeURIComponent(state.baseUrl)}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
'source:text': source,
|
|
'destinationId:int': dest ? parseInt(dest) : null
|
|
})
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || 'Update failed');
|
|
}
|
|
|
|
state.editing = null;
|
|
showAlert('dashAlert', 'Channel updated successfully', 'success');
|
|
await loadChannels();
|
|
} catch (err) {
|
|
showAlert('dashAlert', err.message, 'error');
|
|
}
|
|
}
|
|
|
|
function edit(id) {
|
|
state.editing = id;
|
|
renderChannels();
|
|
}
|
|
|
|
function cancelEdit() {
|
|
state.editing = null;
|
|
renderChannels();
|
|
}
|
|
|
|
async function refresh() {
|
|
await loadChannels();
|
|
}
|
|
|
|
function logout() {
|
|
state = { token: null, baseUrl: null, apiKey: null, channels: [], editing: null };
|
|
document.getElementById('loginView').classList.remove('hidden');
|
|
document.getElementById('dashboardView').classList.add('hidden');
|
|
document.getElementById('apiKey').value = '';
|
|
}
|
|
|
|
function showAlert(id, message, type) {
|
|
const el = document.getElementById(id);
|
|
el.className = `alert alert-${type}`;
|
|
el.textContent = (type === 'error' ? '✕ ' : '✓ ') + message;
|
|
el.classList.remove('hidden');
|
|
if (type === 'success') {
|
|
setTimeout(() => el.classList.add('hidden'), 3000);
|
|
}
|
|
}
|
|
|
|
document.getElementById('loginForm').addEventListener('submit', login);
|
|
</script>
|
|
</body>
|
|
</html>
|