Add public/index.html

This commit is contained in:
Zac Gaetano 2026-03-31 15:29:52 -04:00
parent ccde43ce37
commit 6c3ac122df

299
public/index.html Normal file
View file

@ -0,0 +1,299 @@
<!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>