This commit is contained in:
commit
854da073d4
15 files changed
+2885
No files matched your search
+163
@@ -0,0 +1,163 @@
|
||||
const state = { token: sessionStorage.getItem('adminToken') || '', config: null, discoveredSheets: [], credentialId: '', documentId: '', publicationId: '' };
|
||||
const secretMask = '............';
|
||||
const element = (selector) => document.querySelector(selector);
|
||||
const status = (message, isError = false) => { element('#status').textContent = message; element('#status').style.color = isError ? '#a83129' : '#d8562b'; };
|
||||
|
||||
function applyTheme(isDark) {
|
||||
document.documentElement.dataset.theme = isDark ? 'dark' : '';
|
||||
element('#dark-mode').checked = isDark;
|
||||
}
|
||||
|
||||
function applyCredentialMode() {
|
||||
const isApiKey = element('input[name="mode"]:checked').value === 'apiKey';
|
||||
element('#api-key-field').hidden = !isApiKey;
|
||||
element('#service-account-field').hidden = isApiKey;
|
||||
element('#service-account-upload').hidden = isApiKey;
|
||||
}
|
||||
|
||||
window.lucide?.createIcons();
|
||||
|
||||
async function adminFetch(path, options = {}) {
|
||||
const response = await fetch(path, { ...options, headers: { 'content-type': 'application/json', 'x-admin-token': state.token, ...options.headers } });
|
||||
if (!response.ok) throw new Error((await response.json()).error || 'Request failed.');
|
||||
return response.status === 204 ? null : response.json();
|
||||
}
|
||||
|
||||
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]); }
|
||||
function selectedTitles(selector) { return [...document.querySelectorAll(`${selector} input:checked`)].map((input) => input.value); }
|
||||
function renderPicker(selector, sheets, selected = sheets.map((sheet) => sheet.title)) { element(selector).innerHTML = sheets.map((sheet) => `<label><input type="checkbox" value="${escapeHtml(sheet.title)}" ${selected.includes(sheet.title) ? 'checked' : ''}>${escapeHtml(sheet.title)}</label>`).join(''); }
|
||||
function recordButtons(type, id) { return `<div class="actions"><button class="secondary" data-edit-${type}="${id}">Edit</button><button class="danger" data-delete-${type}="${id}">Delete</button></div>`; }
|
||||
|
||||
function renderCredentials() {
|
||||
const credentials = state.config.credentials;
|
||||
element('#credentials-list').innerHTML = credentials.length ? credentials.map((credential) => `<article class="record"><div><strong>${escapeHtml(credential.name)}</strong><br><small>${credential.mode === 'apiKey' ? 'API key' : 'Service account'} | ${credential.requestsPerMinute} RPM</small></div>${recordButtons('credential', credential.id)}</article>`).join('') : '<p class="hint">No credentials added yet.</p>';
|
||||
element('#document-credential').innerHTML = credentials.length
|
||||
? '<option value="" selected disabled>Select a credential profile</option>' + credentials.map((credential) => `<option value="${credential.id}">${escapeHtml(credential.name)}</option>`).join('')
|
||||
: '<option value="" selected disabled>Add a credential first</option>';
|
||||
}
|
||||
|
||||
function renderDocuments() {
|
||||
const documents = state.config.documents;
|
||||
element('#documents-list').innerHTML = documents.length ? documents.map((item) => {
|
||||
const credential = state.config.credentials.find((entry) => entry.id === item.credentialId);
|
||||
return `<article class="record"><div><strong>${escapeHtml(item.name)}</strong><br><small>${escapeHtml(credential?.name || 'Missing credential')} | ${item.sheets.map((sheet) => escapeHtml(sheet.title)).join(', ')}</small></div>${recordButtons('document', item.id)}</article>`;
|
||||
}).join('') : '<p class="hint">No documents added yet.</p>';
|
||||
element('#publication-document').innerHTML = documents.length
|
||||
? '<option value="" selected disabled>Select a document</option>' + documents.map((item) => `<option value="${item.id}">${escapeHtml(item.name)}</option>`).join('')
|
||||
: '<option value="" selected disabled>Add a document first</option>';
|
||||
renderEndpointPicker();
|
||||
}
|
||||
|
||||
function renderEndpointPicker(selected = []) {
|
||||
const selectedDocument = state.config?.documents.find((item) => item.id === element('#publication-document').value);
|
||||
element('#endpoint-sheet-picker').innerHTML = selectedDocument ? (() => { renderPicker('#endpoint-sheet-picker', selectedDocument.sheets, selected.length ? selected : selectedDocument.sheets.map((sheet) => sheet.title)); return element('#endpoint-sheet-picker').innerHTML; })() : '<p class="hint">Select a document to choose its tabs.</p>';
|
||||
}
|
||||
|
||||
function renderPublications() {
|
||||
const publications = state.config.publications;
|
||||
element('#publications').innerHTML = publications.length ? publications.map((publication) => {
|
||||
const url = `${location.origin}/api/v1/${publication.id}`;
|
||||
return `<article class="endpoint"><div><strong>${escapeHtml(publication.name)}</strong><br><code>${escapeHtml(url)}</code><br><small>${publication.sheets.map((sheet) => escapeHtml(sheet.title)).join(', ')} | ${publication.consumerRequestsPerMinute} consumer RPM${publication.scheduledRefresh ? ' | background refresh' : ''}${publication.enabled ? '' : ' | disabled'}</small></div><div class="actions"><button class="secondary" data-refresh-publication="${publication.id}">Refresh</button><button class="secondary" data-toggle-publication="${publication.id}">${publication.enabled ? 'Disable' : 'Enable'}</button><button class="secondary" data-rotate-token="${publication.id}">Rotate token</button><button class="secondary" data-copy-token="${publication.id}" title="Copy bearer token">Copy token</button><button class="secondary" data-edit-publication="${publication.id}">Edit</button><button class="danger" data-delete-publication="${publication.id}">Delete</button></div></article>`;
|
||||
}).join('') : '<p class="hint">No endpoints published yet.</p>';
|
||||
}
|
||||
|
||||
function renderRequestFilters() {
|
||||
const current = element('#request-endpoint').value;
|
||||
element('#request-endpoint').innerHTML = '<option value="">All endpoints</option>' + state.config.publications.map((publication) => `<option value="${publication.id}">${escapeHtml(publication.name)}</option>`).join('');
|
||||
element('#request-endpoint').value = current;
|
||||
}
|
||||
function renderRequestLog(entries) { element('#request-log').innerHTML = entries.length ? entries.map((entry) => `<article class="log-entry"><strong>${entry.status}</strong><span>${escapeHtml(entry.publicationId)}</span><span>${entry.cached ? 'cache' : 'refresh'}${entry.stale ? ', stale' : ''}</span><span>${entry.durationMs} ms</span><time>${new Date(entry.at).toLocaleString()}</time></article>`).join('') : '<p class="hint">No matching API requests.</p>'; }
|
||||
async function loadConfig() { state.config = await adminFetch('/admin/config'); element('#cors-origins').value = state.config.settings.corsOrigins.join('\n'); element('#trusted-proxy-hops').value = state.config.settings.trustedProxyHops; element('#metrics-url').textContent = `${location.origin}/metrics`; renderCredentials(); renderDocuments(); renderPublications(); renderRequestFilters(); bindRecordActions(); }
|
||||
async function loadRequests() { const query = new URLSearchParams(); if (element('#request-endpoint').value) query.set('publicationId', element('#request-endpoint').value); if (element('#request-status').value) query.set('status', element('#request-status').value); if (element('#request-result').value) query.set('result', element('#request-result').value); renderRequestLog(await adminFetch(`/admin/requests?${query}`)); }
|
||||
async function loadStatus() {
|
||||
const service = await adminFetch('/admin/status');
|
||||
element('#service-status').textContent = `Healthy | ${service.uptimeSeconds}s uptime | ${service.caches.endpointEntries} endpoint cache entries | ${service.caches.tabEntries} tab cache entries`;
|
||||
element('#endpoint-health').innerHTML = service.endpoints.length ? service.endpoints.map((endpoint) => `<article class="health-entry"><strong>${escapeHtml(endpoint.name)}</strong><span>${endpoint.cacheState}</span><span>${endpoint.lastSuccessAt ? `Last refresh: ${new Date(endpoint.lastSuccessAt).toLocaleString()}` : 'Not refreshed yet'}</span>${endpoint.lastError ? `<span class="health-error">${escapeHtml(endpoint.lastError)}</span>` : ''}</article>`).join('') : '<p class="hint">No endpoints published yet.</p>';
|
||||
await loadRequests();
|
||||
}
|
||||
function showPage(page) { document.querySelectorAll('.page').forEach((section) => { section.hidden = section.id !== page; }); document.querySelectorAll('.tab').forEach((button) => button.classList.toggle('active', button.dataset.page === page)); if (page === 'operations') loadStatus().catch((error) => { element('#service-status').textContent = error.message; }); }
|
||||
function resetCredential() { state.credentialId = ''; element('#credential-name').value = ''; element('#api-key').value = ''; element('#service-account').value = ''; element('#api-key').placeholder = ''; element('#service-account').placeholder = ''; element('#rpm').value = 45; element('#save-credential').textContent = 'Add credential profile'; element('#cancel-credential').hidden = true; }
|
||||
function resetDocument() { state.documentId = ''; state.discoveredSheets = []; element('#document-name').value = ''; element('#document-url').value = ''; element('#sheet-picker').innerHTML = ''; element('#save-document').textContent = 'Add document'; element('#cancel-document').hidden = true; }
|
||||
function resetPublication() { state.publicationId = ''; element('#publication-name').value = ''; element('#cache').value = 30; element('#max-rows').value = 10000; element('#consumer-rpm').value = 120; element('#scheduled-refresh').checked = false; element('#data-format').value = 'rows'; element('#sheet-range').value = ''; element('#omit-blank-rows').checked = false; element('#publish').textContent = 'Publish endpoint'; element('#cancel-publication').hidden = true; renderEndpointPicker(); }
|
||||
|
||||
function bindRecordActions() {
|
||||
document.querySelectorAll('[data-edit-credential]').forEach((button) => button.addEventListener('click', () => {
|
||||
const credential = state.config.credentials.find((item) => item.id === button.dataset.editCredential); state.credentialId = credential.id; element('#credential-name').value = credential.name; element('#rpm').value = credential.requestsPerMinute; element(`input[name="mode"][value="${credential.mode}"]`).checked = true; applyCredentialMode(); element('#api-key').value = credential.mode === 'apiKey' ? secretMask : ''; element('#service-account').value = credential.mode === 'serviceAccount' ? secretMask : ''; element('#save-credential').textContent = 'Save credential changes'; element('#cancel-credential').hidden = false; showPage('credentials'); status('Credential loaded. Replace the dots only to change its saved secret.');
|
||||
}));
|
||||
document.querySelectorAll('[data-edit-document]').forEach((button) => button.addEventListener('click', () => {
|
||||
const item = state.config.documents.find((document) => document.id === button.dataset.editDocument); state.documentId = item.id; state.discoveredSheets = item.sheets; element('#document-name').value = item.name; element('#document-url').value = `https://docs.google.com/spreadsheets/d/${item.spreadsheetId}/edit`; element('#document-credential').value = item.credentialId; renderPicker('#sheet-picker', item.sheets); element('#save-document').textContent = 'Save document changes'; element('#cancel-document').hidden = false; showPage('documents'); status('Document loaded. Load sheet tabs again to refresh the available names.');
|
||||
}));
|
||||
document.querySelectorAll('[data-edit-publication]').forEach((button) => button.addEventListener('click', () => {
|
||||
const item = state.config.publications.find((publication) => publication.id === button.dataset.editPublication); state.publicationId = item.id; element('#publication-name').value = item.name; element('#publication-document').value = item.documentId; element('#cache').value = item.cacheSeconds; element('#max-rows').value = item.maxRows; element('#consumer-rpm').value = item.consumerRequestsPerMinute; element('#scheduled-refresh').checked = Boolean(item.scheduledRefresh); element('#data-format').value = item.dataFormat || 'rows'; element('#sheet-range').value = item.range || ''; element('#omit-blank-rows').checked = Boolean(item.omitBlankRows); renderEndpointPicker(item.sheets.map((sheet) => sheet.title)); element('#publish').textContent = 'Save endpoint changes'; element('#cancel-publication').hidden = false; showPage('endpoints'); status('Endpoint loaded.');
|
||||
}));
|
||||
document.querySelectorAll('[data-copy-token]').forEach((button) => button.addEventListener('click', async () => {
|
||||
try {
|
||||
const secret = await adminFetch(`/admin/publications/${button.dataset.copyToken}/token`);
|
||||
await navigator.clipboard.writeText(secret.token);
|
||||
button.textContent = 'Copied';
|
||||
setTimeout(() => { button.textContent = 'Copy token'; }, 1_500);
|
||||
} catch (error) { status(error.message, true); }
|
||||
}));
|
||||
document.querySelectorAll('[data-rotate-token]').forEach((button) => button.addEventListener('click', async () => {
|
||||
if (!confirm('Rotate this token? Existing clients will stop working immediately.')) return;
|
||||
try {
|
||||
const secret = await adminFetch(`/admin/publications/${button.dataset.rotateToken}/rotate-token`, { method: 'POST' });
|
||||
await navigator.clipboard.writeText(secret.token);
|
||||
button.textContent = 'New token copied';
|
||||
setTimeout(() => { button.textContent = 'Rotate token'; }, 1_500);
|
||||
status('Token rotated and copied.');
|
||||
} catch (error) { status(error.message, true); }
|
||||
}));
|
||||
document.querySelectorAll('[data-refresh-publication]').forEach((button) => button.addEventListener('click', async () => {
|
||||
try { await adminFetch(`/admin/publications/${button.dataset.refreshPublication}/refresh`, { method: 'POST' }); button.textContent = 'Refreshed'; setTimeout(() => { button.textContent = 'Refresh'; }, 1_500); } catch (error) { status(error.message, true); }
|
||||
}));
|
||||
document.querySelectorAll('[data-toggle-publication]').forEach((button) => button.addEventListener('click', async () => {
|
||||
try { const publication = state.config.publications.find((item) => item.id === button.dataset.togglePublication); await adminFetch(`/admin/publications/${publication.id}/enabled`, { method: 'POST', body: JSON.stringify({ enabled: !publication.enabled }) }); await loadConfig(); } catch (error) { status(error.message, true); }
|
||||
}));
|
||||
['credential', 'document', 'publication'].forEach((type) => document.querySelectorAll(`[data-delete-${type}]`).forEach((button) => button.addEventListener('click', async () => { if (!confirm(`Delete this ${type}? This cannot be undone.`)) return; try { await adminFetch(`/admin/${type === 'publication' ? 'publications' : `${type}s`}/${button.dataset[`delete${type[0].toUpperCase()}${type.slice(1)}`]}`, { method: 'DELETE' }); await loadConfig(); status(`${type[0].toUpperCase()}${type.slice(1)} removed.`); } catch (error) { status(error.message, true); } })));
|
||||
}
|
||||
|
||||
element('#connect').addEventListener('click', async () => { state.token = element('#admin-token').value.trim(); try { await loadConfig(); sessionStorage.setItem('adminToken', state.token); element('#access-panel').hidden = true; element('#console').hidden = false; status('Connected.'); } catch (error) { status(error.message, true); } });
|
||||
document.querySelectorAll('.tab').forEach((button) => button.addEventListener('click', () => showPage(button.dataset.page)));
|
||||
applyTheme(localStorage.getItem('theme') === 'dark');
|
||||
applyCredentialMode();
|
||||
document.querySelectorAll('input[name="mode"]').forEach((input) => input.addEventListener('change', applyCredentialMode));
|
||||
element('#dark-mode').addEventListener('change', (event) => {
|
||||
const isDark = event.target.checked;
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
applyTheme(isDark);
|
||||
});
|
||||
element('#save-settings').addEventListener('click', async () => {
|
||||
try {
|
||||
const corsOrigins = element('#cors-origins').value.split(/\r?\n/);
|
||||
await adminFetch('/admin/settings', { method: 'PUT', body: JSON.stringify({ corsOrigins, trustedProxyHops: element('#trusted-proxy-hops').value }) });
|
||||
await loadConfig();
|
||||
status('CORS settings saved.');
|
||||
} catch (error) { status(error.message, true); }
|
||||
});
|
||||
element('#service-account-file').addEventListener('change', async (event) => { const [file] = event.target.files; if (file) element('#service-account').value = await file.text(); });
|
||||
element('#save-credential').addEventListener('click', async () => { try { await adminFetch('/admin/credentials', { method: 'POST', body: JSON.stringify({ id: state.credentialId || undefined, name: element('#credential-name').value, mode: element('input[name="mode"]:checked').value, apiKey: element('#api-key').value === secretMask ? '' : element('#api-key').value, serviceAccountJson: element('#service-account').value === secretMask ? '' : element('#service-account').value, requestsPerMinute: element('#rpm').value }) }); await loadConfig(); resetCredential(); applyCredentialMode(); status('Credential saved.'); } catch (error) { status(error.message, true); } });
|
||||
element('#cancel-credential').addEventListener('click', resetCredential);
|
||||
element('#discover').addEventListener('click', async () => { try { const result = await adminFetch('/admin/documents/discover', { method: 'POST', body: JSON.stringify({ credentialId: element('#document-credential').value, url: element('#document-url').value }) }); state.discoveredSheets = result.sheets; renderPicker('#sheet-picker', result.sheets); status(`Loaded ${result.sheets.length} tab(s) from ${result.title}.`); } catch (error) { status(error.message, true); } });
|
||||
element('#save-document').addEventListener('click', async () => { try { const sheets = state.discoveredSheets.filter((sheet) => selectedTitles('#sheet-picker').includes(sheet.title)); await adminFetch('/admin/documents', { method: 'POST', body: JSON.stringify({ id: state.documentId || undefined, name: element('#document-name').value, url: element('#document-url').value, credentialId: element('#document-credential').value, sheets }) }); await loadConfig(); resetDocument(); status('Document saved.'); } catch (error) { status(error.message, true); } });
|
||||
element('#cancel-document').addEventListener('click', resetDocument);
|
||||
element('#publication-document').addEventListener('change', () => renderEndpointPicker());
|
||||
element('#publish').addEventListener('click', async () => { try { const selectedDocument = state.config.documents.find((item) => item.id === element('#publication-document').value); if (!selectedDocument) throw new Error('Select a document first.'); const titles = selectedTitles('#endpoint-sheet-picker'); const sheets = selectedDocument.sheets.filter((sheet) => titles.includes(sheet.title)); await adminFetch('/admin/publications', { method: 'POST', body: JSON.stringify({ id: state.publicationId || undefined, name: element('#publication-name').value, documentId: selectedDocument.id, sheets, cacheSeconds: element('#cache').value, maxRows: element('#max-rows').value, consumerRequestsPerMinute: element('#consumer-rpm').value, scheduledRefresh: element('#scheduled-refresh').checked, dataFormat: element('#data-format').value, range: element('#sheet-range').value, omitBlankRows: element('#omit-blank-rows').checked }) }); await loadConfig(); resetPublication(); status('Endpoint saved.'); } catch (error) { status(error.message, true); } });
|
||||
['#request-endpoint', '#request-status', '#request-result'].forEach((selector) => element(selector).addEventListener('change', () => loadRequests().catch((error) => { element('#request-log').textContent = error.message; })));
|
||||
element('#clear-request-log').addEventListener('click', async () => { if (!confirm('Clear all retained API request logs?')) return; try { await adminFetch('/admin/requests', { method: 'DELETE' }); await loadRequests(); } catch (error) { status(error.message, true); } });
|
||||
element('#rotate-admin-token').addEventListener('click', async () => { if (!confirm('Rotate the admin token? Other admin sessions will stop working.')) return; try { const result = await adminFetch('/admin/token/rotate', { method: 'POST' }); state.token = result.token; sessionStorage.setItem('adminToken', state.token); await navigator.clipboard.writeText(result.token); status('Admin token rotated and copied.'); } catch (error) { status(error.message, true); } });
|
||||
element('#export-config').addEventListener('click', async () => { try { const exported = await adminFetch('/admin/config/export'); const url = URL.createObjectURL(new Blob([JSON.stringify(exported, null, 2)], { type: 'application/json' })); const link = document.createElement('a'); link.href = url; link.download = 'sheet-relay-config.json'; link.click(); URL.revokeObjectURL(url); } catch (error) { status(error.message, true); } });
|
||||
element('#import-config').addEventListener('click', () => element('#config-file').click());
|
||||
element('#config-file').addEventListener('change', async (event) => { const [file] = event.target.files; if (!file) return; try { const imported = JSON.parse(await file.text()); if (!confirm('Replace all existing configuration with this import?')) return; await adminFetch('/admin/config/import', { method: 'POST', body: JSON.stringify(imported) }); await loadConfig(); await loadStatus(); status('Configuration imported. Add credential secrets, then enable endpoints.'); } catch (error) { status(error.message, true); } finally { event.target.value = ''; } });
|
||||
element('#cancel-publication').addEventListener('click', resetPublication);
|
||||
|
||||
if (state.token) {
|
||||
loadConfig().then(() => {
|
||||
element('#admin-token').value = state.token;
|
||||
element('#access-panel').hidden = true;
|
||||
element('#console').hidden = false;
|
||||
}).catch(() => {
|
||||
sessionStorage.removeItem('adminToken');
|
||||
state.token = '';
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user