feat: AMPP configuration in admin settings

- GET/PUT /api/ampp/config — store base URL + API key in db.json (no restart needed)
- POST /api/ampp/test — authenticate against AMPP token endpoint and report result
- Refactored AMPP_BASE/AMPP_API_KEY constants to dynamic getAmppBase()/getAmppApiKey()
  functions so live config changes take effect immediately
- Admin → AMPP tab: base URL field, masked API key field, Test Connection + Save buttons
- Cached token invalidated automatically on config save

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-04-05 20:22:51 -04:00
parent 6b6efc61ba
commit 155c821ef8
2 changed files with 119 additions and 10 deletions

View file

@ -519,6 +519,7 @@ body:not([data-theme="light"]) .header-wordmark{filter:invert(1)}
<div class="admin-tabs">
<div class="admin-tab active" onclick="switchAdminTab('s3')">S3 Storage</div>
<div class="admin-tab" onclick="switchAdminTab('relay')">UDP Relay</div>
<div class="admin-tab" onclick="switchAdminTab('ampp')">AMPP</div>
<div class="admin-tab" onclick="switchAdminTab('users')">Users</div>
<div class="admin-tab" onclick="switchAdminTab('folders')">Folders</div>
</div>
@ -557,6 +558,26 @@ body:not([data-theme="light"]) .header-wordmark{filter:invert(1)}
<div class="status-msg" id="relay-status"></div>
</div>
<!-- AMPP -->
<div class="admin-panel" id="admin-ampp">
<div class="section-title">AMPP Configuration</div>
<div class="form-group">
<label class="form-label">Base URL</label>
<input class="form-input" id="ampp-base-url" type="url" placeholder="https://us-east-1.gvampp.com"/>
<div class="form-hint">Your Grass Valley AMPP regional endpoint</div>
</div>
<div class="form-group">
<label class="form-label">API Key (Base64 credentials)</label>
<input class="form-input" id="ampp-api-key" type="password" placeholder="Leave blank to keep existing" autocomplete="new-password"/>
<div class="form-hint" id="ampp-key-hint"></div>
</div>
<div class="btn-row">
<button class="btn-secondary" onclick="testAmpp()">🔍 Test Connection</button>
<button class="btn-primary" onclick="saveAmpp()">💾 Save Configuration</button>
</div>
<div class="status-msg" id="ampp-status"></div>
</div>
<!-- Users -->
<div class="admin-panel" id="admin-users">
<div class="section-title">User Management</div>
@ -692,7 +713,7 @@ function showApp() {
document.getElementById('header-user').textContent = currentUser;
if (currentRole === 'admin') {
document.querySelectorAll('.admin-only').forEach(e => e.classList.remove('hidden'));
loadS3Config(); loadRelayConfig(); loadUsers();
loadS3Config(); loadRelayConfig(); loadAmppConfig(); loadUsers();
}
loadFolders();
loadAmppJobs();
@ -732,11 +753,12 @@ function switchPage(name) {
function switchAdminTab(name) {
document.querySelectorAll('.admin-tab').forEach((t,i) => {
t.classList.toggle('active', ['s3','relay','users','folders'][i] === name);
t.classList.toggle('active', ['s3','relay','ampp','users','folders'][i] === name);
});
document.querySelectorAll('.admin-panel').forEach(p => p.classList.toggle('active', p.id === `admin-${name}`));
if (name === 'users') loadUsers();
if (name === 'folders') loadAdminFolders();
if (name === 'ampp') loadAmppConfig();
}
// ============================================================
@ -1037,6 +1059,41 @@ async function saveRelay() {
}
function setRelayDot(c,t){document.getElementById('relay-dot').className=`relay-dot ${c}`;document.getElementById('relay-status-text').textContent=t;}
// ============================================================
// AMPP CONFIG ADMIN
// ============================================================
async function loadAmppConfig() {
try {
const d=await api('GET','/api/ampp/config'); if(!d.success)return;
document.getElementById('ampp-base-url').value=d.config.baseUrl||'';
const hint=document.getElementById('ampp-key-hint');
hint.textContent=d.config.apiKeyExists?'API key is saved (leave blank to keep)':'No API key saved yet';
} catch(_){}
}
async function testAmpp() {
const s=document.getElementById('ampp-status');
s.className='status-msg loading'; s.textContent='🔍 Testing AMPP connection…';
try {
const body={baseUrl:document.getElementById('ampp-base-url').value.trim()};
const k=document.getElementById('ampp-api-key').value.trim();
if(k) body.apiKey=k;
const d=await api('POST','/api/ampp/test',body);
s.className=`status-msg ${d.success?'success':'error'}`; s.textContent=d.success?`✅ ${d.message}`:`❌ ${d.error}`;
} catch(e){s.className='status-msg error';s.textContent=`❌ ${e.message}`;}
}
async function saveAmpp() {
const s=document.getElementById('ampp-status');
s.className='status-msg loading'; s.textContent='💾 Saving…';
try {
const body={baseUrl:document.getElementById('ampp-base-url').value.trim()};
const k=document.getElementById('ampp-api-key').value.trim();
if(k) body.apiKey=k;
const d=await api('PUT','/api/ampp/config',body);
s.className=`status-msg ${d.success?'success':'error'}`; s.textContent=d.success?'✅ AMPP configuration saved':`❌ ${d.error}`;
if(d.success){document.getElementById('ampp-api-key').value='';loadAmppConfig();}
} catch(e){s.className='status-msg error';s.textContent=`❌ ${e.message}`;}
}
// ============================================================
// USERS ADMIN
// ============================================================

View file

@ -533,16 +533,18 @@ app.get("/api/udp/relay/health", async (req, res) => {
});
// ==================== AMPP JOB MONITORING ====================
const AMPP_BASE = process.env.AMPP_BASE_URL || "https://us-east-1.gvampp.com";
const AMPP_API_KEY = process.env.AMPP_API_KEY || "";
const AMPP_BASE_DEFAULT = process.env.AMPP_BASE_URL || "https://us-east-1.gvampp.com";
function getAmppBase() { return (db.amppConfig?.baseUrl || AMPP_BASE_DEFAULT).trim(); }
function getAmppApiKey() { return db.amppConfig?.apiKey || process.env.AMPP_API_KEY || ""; }
let amppToken = null;
let amppTokenExpiry = 0;
async function getAmppToken() {
if (amppToken && Date.now() < amppTokenExpiry - 60000) return amppToken;
const r = await fetch(`${AMPP_BASE}/identity/connect/token`, {
const r = await fetch(`${getAmppBase()}/identity/connect/token`, {
method: "POST",
headers: { "Authorization": `Basic ${AMPP_API_KEY}`, "Content-Type": "application/x-www-form-urlencoded" },
headers: { "Authorization": `Basic ${getAmppApiKey()}`, "Content-Type": "application/x-www-form-urlencoded" },
body: "grant_type=client_credentials&scope=platform",
});
if (!r.ok) throw new Error(`AMPP auth failed (${r.status})`);
@ -552,13 +554,63 @@ async function getAmppToken() {
return amppToken;
}
// ---- AMPP Config admin endpoints ----
app.get("/api/ampp/config", requireAdmin, (req, res) => {
const cfg = db.amppConfig || {};
res.json({
success: true,
config: {
baseUrl: cfg.baseUrl || AMPP_BASE_DEFAULT,
apiKeyExists: !!(cfg.apiKey),
}
});
});
app.put("/api/ampp/config", requireAdmin, (req, res) => {
const { baseUrl, apiKey } = req.body;
if (!db.amppConfig) db.amppConfig = {};
if (baseUrl !== undefined) db.amppConfig.baseUrl = baseUrl.trim();
if (apiKey) db.amppConfig.apiKey = apiKey.trim();
amppToken = null; amppTokenExpiry = 0; // invalidate cached token
saveData(db);
res.json({ success: true, message: "AMPP configuration saved" });
});
app.post("/api/ampp/test", requireAdmin, async (req, res) => {
const { baseUrl, apiKey } = req.body;
const testBase = (baseUrl || getAmppBase()).trim();
const testApiKey = (apiKey || getAmppApiKey()).trim();
if (!testApiKey) return res.status(400).json({ success: false, error: "API key is required to test" });
try {
const r = await fetch(`${testBase}/identity/connect/token`, {
method: "POST",
headers: { "Authorization": `Basic ${testApiKey}`, "Content-Type": "application/x-www-form-urlencoded" },
body: "grant_type=client_credentials&scope=platform",
signal: AbortSignal.timeout(8000),
});
if (!r.ok) {
const text = await r.text().catch(() => "");
return res.status(400).json({ success: false, error: `Authentication failed (HTTP ${r.status})${text ? ': ' + text.slice(0, 120) : ''}` });
}
const data = await r.json();
if (!data.access_token) return res.status(400).json({ success: false, error: "No access token returned — check credentials" });
res.json({ success: true, message: "AMPP connection confirmed! Authentication successful." });
} catch (err) {
let msg = err.message;
if (err.name === "TimeoutError" || err.name === "AbortError") msg = "Request timed out — check the base URL";
else if (msg.includes("ECONNREFUSED")) msg = "Connection refused — check the base URL and network";
else if (msg.includes("ENOTFOUND")) msg = "Host not found — check the base URL";
res.status(400).json({ success: false, error: msg });
}
});
app.get("/api/ampp/jobs", requireAuth, async (req, res) => {
if (!AMPP_API_KEY) return res.status(503).json({ success: false, error: "AMPP API key not configured" });
if (!getAmppApiKey()) return res.status(503).json({ success: false, error: "AMPP API key not configured" });
try {
const token = await getAmppToken();
const limit = parseInt(req.query.limit) || 100;
const skip = parseInt(req.query.skip) || 0;
const url = `${AMPP_BASE}/api/v1/queue/job/jobs/querypage?skip=${skip}&limit=${limit}&sort=created:dateTime&asc=false`;
const url = `${getAmppBase()}/api/v1/queue/job/jobs/querypage?skip=${skip}&limit=${limit}&sort=created:dateTime&asc=false`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 15000);
const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, body: "{}", signal: controller.signal });
@ -579,10 +631,10 @@ app.get("/api/ampp/jobs", requireAuth, async (req, res) => {
});
app.get("/api/ampp/jobs/:jobId", requireAuth, async (req, res) => {
if (!AMPP_API_KEY) return res.status(503).json({ success: false, error: "AMPP API key not configured" });
if (!getAmppApiKey()) return res.status(503).json({ success: false, error: "AMPP API key not configured" });
try {
const token = await getAmppToken();
const r = await fetch(`${AMPP_BASE}/api/v1/queue/job/jobs/${encodeURIComponent(req.params.jobId)}`, { headers: { "Authorization": `Bearer ${token}` } });
const r = await fetch(`${getAmppBase()}/api/v1/queue/job/jobs/${encodeURIComponent(req.params.jobId)}`, { headers: { "Authorization": `Bearer ${token}` } });
if (!r.ok) return res.status(r.status).json({ success: false, error: `AMPP ${r.status}` });
res.json({ success: true, job: await r.json() });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }