Initial Sheet Relay application
Publish container image / publish (push) Failing after 26s

This commit is contained in:
lzstealth committed 2026-09-17 18:54:22 +01:00
commit 854da073d4
15 files changed
+2885

No files matched your search

+163
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[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 = '';
});
}
+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sheet Relay</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main>
<header><div class="header-row"><div><p class="eyebrow">GOOGLE SHEETS TO API</p><h1>Sheet Relay</h1></div><label class="theme-toggle" title="Toggle dark mode"><input id="dark-mode" type="checkbox" aria-label="Toggle dark mode"><span class="theme-icon sun" aria-hidden="true"><i data-lucide="sun"></i></span><span class="theme-icon moon" aria-hidden="true"><i data-lucide="moon"></i></span></label></div><p>Choose exactly which spreadsheet tabs become authenticated JSON endpoints.</p></header>
<section id="access-panel" class="panel">
<h2>Admin access</h2><div class="inline"><input id="admin-token" type="password" placeholder="Admin token from server startup"><button id="connect" title="Connect to admin console">Connect</button></div><p id="status" class="status">Enter the token printed when the server first starts.</p>
</section>
<div id="console" hidden>
<nav class="tabs" aria-label="Configuration pages"><button class="tab active" data-page="credentials">Credentials</button><button class="tab" data-page="documents">Documents</button><button class="tab" data-page="endpoints">Endpoints</button><button class="tab" data-page="operations">Operations</button></nav>
<section class="page panel" id="credentials"><h2>Credential profiles</h2>
<div class="two-up"><label>Profile name<input id="credential-name" placeholder="Production service account"></label><label>Requests per minute<input id="rpm" type="number" min="1" max="10000" value="45"></label></div>
<div class="mode"><label><input type="radio" name="mode" value="apiKey" checked> API key</label><label><input type="radio" name="mode" value="serviceAccount"> Service account</label></div>
<label id="api-key-field">Google API key<input id="api-key" type="password" autocomplete="off"></label>
<label id="service-account-field">Service account JSON<textarea id="service-account" rows="5" spellcheck="false"></textarea></label>
<label id="service-account-upload" class="upload">Upload service-account JSON<input id="service-account-file" type="file" accept="application/json,.json"></label>
<button id="save-credential">Add credential profile</button><button id="cancel-credential" class="secondary" hidden>Cancel edit</button><p class="hint">45 RPM leaves room below a 50 RPM quota. API keys require publicly readable sheets. Share private sheets with the service account's client email.</p><div id="credentials-list" class="records"></div>
</section>
<section class="page panel" id="documents" hidden><h2>Google documents</h2>
<div class="two-up"><label>Document name<input id="document-name" placeholder="Warehouse inventory"></label><label>Credential profile<select id="document-credential"></select></label></div>
<label>Google Sheets URL<input id="document-url" type="url" placeholder="https://docs.google.com/spreadsheets/d/.../"></label>
<button id="discover">Load sheet tabs</button><div id="sheet-picker" class="sheet-picker"></div><button id="save-document">Add document</button><button id="cancel-document" class="secondary" hidden>Cancel edit</button><p class="hint">Paste the spreadsheet URL. Select tabs by name after they are loaded.</p><div id="documents-list" class="records"></div>
</section>
<section class="page panel" id="endpoints" hidden><h2>Authenticated endpoints</h2>
<div class="two-up"><label>Endpoint name<input id="publication-name" placeholder="Inventory feed"></label><label>Document<select id="publication-document"></select></label></div>
<label>Include sheet tabs<div id="endpoint-sheet-picker" class="sheet-picker"></div></label>
<div class="two-up"><label>Output format<select id="data-format"><option value="rows">Rows</option><option value="objects">Objects using header row</option></select></label><label>A1 range (optional)<input id="sheet-range" placeholder="A1:H500"></label></div>
<label class="check-option"><input id="omit-blank-rows" type="checkbox"> Omit blank rows</label>
<div class="two-up"><label>Cache seconds<input id="cache" type="number" min="0" max="86400" value="30"></label><label>Maximum rows per sheet<input id="max-rows" type="number" min="1" max="100000" value="10000"></label></div>
<label>Consumer requests per minute<input id="consumer-rpm" type="number" min="1" max="100000" value="120"></label>
<label class="check-option"><input id="scheduled-refresh" type="checkbox"> Background cache refresh</label>
<button id="publish">Publish endpoint</button><button id="cancel-publication" class="secondary" hidden>Cancel edit</button><p class="hint">A bearer token is generated automatically. Copy it or rotate it from the endpoint controls after publishing.</p><details class="response-format"><summary>Response format</summary><pre>{
"publication": "Inventory feed",
"document": "Warehouse inventory",
"spreadsheetId": "...",
"updatedAt": "2026-09-17T12:00:00.000Z",
"sheets": [{ "sheet": "OUTPUT", "values": [["Header"], ["Value"]] }],
"cached": false
}</pre></details><div id="publications" class="records"></div>
</section>
<section class="page panel" id="operations" hidden><h2>Operations</h2>
<label>Allowed browser origins<textarea id="cors-origins" rows="4" spellcheck="false" placeholder="*&#10;https://app.example.com"></textarea></label>
<label>Trusted reverse proxy hops<input id="trusted-proxy-hops" type="number" min="0" max="10" value="0"></label>
<button id="save-settings">Save CORS settings</button><p class="hint">Browser origins are denied by default. Use * for all origins, or enter one exact origin per line to allow selected browser clients. Server-to-server callers without an Origin header are unaffected.</p>
<p class="hint">Set proxy hops to 1 when one trusted reverse proxy is in front of this service. This makes request logs use the original client address from forwarded headers.</p>
<h3>Administration</h3><div class="actions operations-actions"><button id="rotate-admin-token" class="danger">Rotate admin token</button><button id="export-config" class="secondary">Export redacted config</button><button id="import-config" class="secondary">Import config</button><input id="config-file" type="file" accept="application/json,.json" hidden></div>
<p class="hint">Imports replace all existing configuration. Credentials are imported without their secrets, endpoints receive new tokens, and remain disabled until you explicitly enable them.</p>
<h3>Metrics</h3><div class="status-card"><code id="metrics-url"></code></div>
<h3>Service status</h3><div id="service-status" class="status-card">Loading status when this page opens.</div>
<h3>Endpoint health</h3><div id="endpoint-health" class="request-log"></div>
<h3>Recent API requests</h3><div class="request-filters"><select id="request-endpoint"><option value="">All endpoints</option></select><select id="request-status"><option value="">All statuses</option><option value="200">200</option><option value="401">401</option><option value="429">429</option><option value="502">502</option><option value="503">503</option></select><select id="request-result"><option value="">All results</option><option value="cached">Cached</option><option value="refresh">Refreshed</option><option value="stale">Stale</option></select><button id="clear-request-log" class="danger">Clear log</button></div><div id="request-log" class="request-log"></div>
</section>
</div>
</main>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/app.js"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Fraunces:opsz,wght@9..144,600;9..144,700&display=swap');
:root { --ink: #15231f; --muted: #60706a; --paper: #f6f3ea; --rule: #e8e5da; --panel: #fffdf7; --field: #fffefa; --line: #d8ddd2; --field-line: #abb8b0; --accent: #d8562b; --dark: #173c35; --button-ink: #fff; --shadow: #dfe5d8; --soft: #eaf0e7; --secondary: #b7c7bd; --secondary-hover: #91a99a; }
:root[data-theme="dark"] { --ink: #e9eee7; --muted: #b4c1b9; --paper: #17201d; --rule: #29332f; --panel: #202a26; --field: #18221e; --line: #405149; --field-line: #61766b; --accent: #ef855d; --dark: #9ec9b4; --button-ink: #15231f; --shadow: #0d1311; --soft: #293a33; --secondary: #596f64; --secondary-hover: #708a7b; }
* { box-sizing: border-box; }
body { margin: 0; color: var(--ink); background: repeating-linear-gradient(90deg, transparent 0 39px, var(--rule) 40px), var(--paper); font-family: "DM Mono", monospace; font-size: 14px; }
main { width: min(880px, calc(100% - 32px)); margin: 0 auto; padding: 64px 0; }
header { border-left: 8px solid var(--accent); padding-left: 20px; margin-bottom: 36px; }.header-row { display: flex; align-items: start; justify-content: space-between; gap: 16px; }
.eyebrow, .hint { color: var(--muted); font-size: 12px; line-height: 1.6; }
h1, h2 { font-family: Fraunces, serif; letter-spacing: 0; } h1 { font-size: 48px; margin: 3px 0 10px; } h2 { margin: 0 0 20px; font-size: 25px; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 28px; margin: 18px 0; box-shadow: 5px 5px 0 var(--shadow); }
label { display: grid; gap: 8px; margin: 14px 0; } [hidden] { display: none !important; } input, select, textarea { width: 100%; min-height: 42px; border: 1px solid var(--field-line); border-radius: 3px; background: var(--field); padding: 10px 11px; font: inherit; color: var(--ink); } select { appearance: auto; } textarea { resize: vertical; }
button { border: 0; border-radius: 3px; background: var(--dark); color: var(--button-ink); font: 500 13px "DM Mono", monospace; padding: 11px 16px; cursor: pointer; } button:hover { background: var(--accent); color: #fff; } button.danger { background: #a83129; color: #fff; } button.secondary { background: var(--secondary); color: var(--ink); } button.secondary:hover { background: var(--secondary-hover); }.secret-input { display: flex; gap: 8px; }.secret-input input { min-width: 0; }.secret-input button { flex: 0 0 auto; }.icon-button { padding: 8px 10px; }
.panel > button + button { margin-left: 8px; }
.tabs { display: flex; gap: 1px; margin: 28px 0 -18px; }.tab { border-radius: 4px 4px 0 0; background: var(--secondary); color: var(--ink); }.tab.active { background: var(--dark); color: var(--button-ink); }#console .page { border-radius: 0 6px 6px 6px; }
.inline, .two-up, .mode { display: flex; gap: 14px; align-items: end; } .two-up > label { flex: 1; } .mode { align-items: center; } .mode label { display: flex; margin: 0; align-items: center; } .mode input { width: auto; }
.status { min-height: 20px; color: var(--accent); } .sheet-picker { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: 8px; margin: 18px 0; }.sheet-picker label { display: flex; align-items: center; border: 1px solid var(--line); padding: 10px; margin: 0; gap: 8px; }.sheet-picker input { width: auto; }
.check-option { display: flex; align-items: center; gap: 8px; }.check-option input { width: auto; min-height: auto; }
input[type="checkbox"] { appearance: none; display: grid; place-content: center; width: 18px; min-width: 18px; height: 18px; min-height: 18px; margin: 0; padding: 0; border: 1px solid var(--field-line); border-radius: 3px; background: var(--field); cursor: pointer; } input[type="checkbox"]::after { content: ""; width: 7px; height: 4px; border: solid var(--button-ink); border-width: 0 0 2px 2px; transform: rotate(-45deg) scale(0); transform-origin: center; } input[type="checkbox"]:checked { border-color: var(--dark); background: var(--dark); } input[type="checkbox"]:checked::after { transform: rotate(-45deg) scale(1); }
.records { margin-top: 24px; }.record, .endpoint { border-top: 1px solid var(--line); padding: 16px 0; display: flex; justify-content: space-between; gap: 14px; align-items: start; }.record:first-child, .endpoint:first-child { border-top: 0; padding-top: 0; }.endpoint { flex-wrap: wrap; }.endpoint code { display: inline-block; max-width: 100%; color: var(--muted); overflow-wrap: anywhere; font-size: 11px; margin: 5px 0; }.endpoint pre { width: 100%; max-width: 620px; overflow: auto; white-space: pre-wrap; background: var(--soft); border-left: 3px solid var(--accent); padding: 12px; font-size: 11px; line-height: 1.5; }.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }.actions button { padding: 8px 10px; }
.response-format { margin: 18px 0; color: var(--muted); }.response-format summary { cursor: pointer; }.response-format pre { max-width: 620px; overflow: auto; background: var(--soft); border-left: 3px solid var(--accent); padding: 12px; color: var(--ink); font-size: 11px; line-height: 1.5; }
.status-card { border-left: 3px solid var(--dark); background: var(--soft); padding: 12px; line-height: 1.5; }.request-log { margin-top: 12px; }.log-entry { display: grid; grid-template-columns: 52px minmax(0, 1fr) 90px 64px 170px; gap: 10px; align-items: center; border-top: 1px solid var(--line); padding: 10px 0; font-size: 11px; }.log-entry strong { color: var(--accent); }.log-entry span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.log-entry time { color: var(--muted); }.theme-toggle { display: grid; position: relative; width: 32px; height: 32px; place-items: center; margin: 4px 0; border: 1px solid var(--field-line); border-radius: 50%; background: var(--field); cursor: pointer; }.theme-toggle input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }.theme-icon { display: grid; place-items: center; color: var(--accent); }.theme-icon svg { width: 18px; height: 18px; stroke-width: 2; }.theme-icon.moon { display: none; }:root[data-theme="dark"] .theme-icon.sun { display: none; }:root[data-theme="dark"] .theme-icon.moon { display: grid; }
.health-entry { display: grid; grid-template-columns: minmax(120px, 1fr) 68px minmax(170px, 1.5fr); gap: 10px; align-items: center; border-top: 1px solid var(--line); padding: 10px 0; font-size: 11px; }.health-entry span { color: var(--muted); }.health-entry .health-error { color: #a83129; grid-column: 1 / -1; overflow-wrap: anywhere; }
.request-filters, .operations-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; margin: 12px 0; }.request-filters select { width: auto; min-width: 130px; min-height: 36px; padding: 6px 8px; }.request-filters button { padding: 9px 12px; }.operations-actions { justify-content: flex-start; }
@media (max-width: 600px) { .log-entry { grid-template-columns: 45px minmax(0, 1fr); }.log-entry time { grid-column: 1 / -1; } }
@media (max-width: 600px) { main { padding: 34px 0; } h1 { font-size: 38px; }.panel { padding: 20px; }.two-up, .inline { display: grid; }.inline button { width: 100%; } }