diff --git a/smolrouter/static/vendor/api-key-copy.js b/smolrouter/static/vendor/api-key-copy.js new file mode 100644 index 0000000..4143053 --- /dev/null +++ b/smolrouter/static/vendor/api-key-copy.js @@ -0,0 +1,137 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) root.smolrouterApiKeyCopy = api; +})(typeof window !== 'undefined' ? window : globalThis, function () { + 'use strict'; + + const COPY_LABEL = 'Copy API key'; + const COPIED_LABEL = 'API key copied'; + + function legacyCopy(text, doc) { + const previousFocus = doc.activeElement; + const temporary = doc.createElement('textarea'); + temporary.setAttribute('readonly', ''); + temporary.setAttribute('aria-label', 'Temporary API key copy field'); + temporary.setAttribute('tabindex', '-1'); + temporary.style.position = 'fixed'; + temporary.style.left = '-9999px'; + temporary.style.opacity = '0'; + temporary.value = text; + doc.body.appendChild(temporary); + try { + temporary.focus(); + temporary.select(); + temporary.setSelectionRange(0, temporary.value.length); + if (!doc.execCommand('copy')) throw new Error('Copy command was rejected'); + } finally { + temporary.value = ''; + temporary.remove(); + if (previousFocus && previousFocus.isConnected !== false && typeof previousFocus.focus === 'function') { + previousFocus.focus(); + } + } + } + + async function copyText(text, options) { + const win = options && options.window ? options.window : window; + const doc = options && options.document ? options.document : document; + const clipboard = win.navigator && win.navigator.clipboard; + if (win.isSecureContext && clipboard && typeof clipboard.writeText === 'function') { + try { + await clipboard.writeText(text); + return; + } catch (_) { + // Firefox and hardened browsers can reject Clipboard API calls even when exposed. + } + } + legacyCopy(text, doc); + } + + function createController(options) { + const win = options.window; + const input = options.input; + const panel = options.panel; + const copyButton = options.copyButton; + const copyIcon = options.copyIcon; + const dismissButton = options.dismissButton; + const status = options.status; + const performCopy = options.copyText || ((text) => copyText(text, {window: win, document: options.document})); + let secretCopied = false; + let copyResetTimer = null; + + function clearResetTimer() { + if (copyResetTimer !== null) { + win.clearTimeout(copyResetTimer); + copyResetTimer = null; + } + } + + function resetCopyFeedback() { + clearResetTimer(); + copyIcon.textContent = 'content_copy'; + copyButton.setAttribute('aria-label', COPY_LABEL); + copyButton.setAttribute('title', COPY_LABEL); + status.textContent = ''; + status.classList.remove('success', 'warning'); + } + + function markCopied() { + secretCopied = true; + clearResetTimer(); + copyIcon.textContent = 'check'; + copyButton.setAttribute('aria-label', COPIED_LABEL); + copyButton.setAttribute('title', COPIED_LABEL); + status.textContent = 'API key copied.'; + status.classList.remove('warning'); + status.classList.add('success'); + copyResetTimer = win.setTimeout(resetCopyFeedback, 2000); + } + + function clearSecret() { + clearResetTimer(); + input.value = ''; + secretCopied = false; + resetCopyFeedback(); + } + + function showSecret(secret) { + clearSecret(); + input.value = secret; + panel.hidden = false; + input.focus(); + input.select(); + } + + async function handleCopy() { + try { + await performCopy(input.value); + markCopied(); + } catch (_) { + resetCopyFeedback(); + status.textContent = 'Automatic copy is unavailable. The key is selected; press Ctrl+C or Command+C to copy it.'; + status.classList.add('warning'); + input.focus(); + input.select(); + } + } + + function handleDismiss() { + if (input.value && !secretCopied && !win.confirm('This key will not be shown again. Dismiss without copying?')) return; + clearSecret(); + panel.hidden = true; + win.location.reload(); + } + + copyButton.addEventListener('click', handleCopy); + input.addEventListener('copy', () => { + if (input.value && input.selectionStart === 0 && input.selectionEnd === input.value.length) markCopied(); + }); + dismissButton.addEventListener('click', handleDismiss); + win.addEventListener('pagehide', clearSecret); + + return {showSecret: showSecret, clearSecret: clearSecret}; + } + + return {copyText: copyText, createController: createController}; +}); diff --git a/smolrouter/templates/client_dashboard.html b/smolrouter/templates/client_dashboard.html index 3b14bfb..bb8126b 100644 --- a/smolrouter/templates/client_dashboard.html +++ b/smolrouter/templates/client_dashboard.html @@ -58,6 +58,9 @@ text-decoration: underline; } + .project-cell { display:flex; flex-direction:column; gap:.2rem; } + .project-cell-secondary, .identity-cue { color:#666; font-size:.8rem; } + @media (max-width: 768px) { .stats { grid-template-columns: repeat(2, 1fr); @@ -148,7 +151,7 @@

Recent Requests for {{ client_ip }}

Time Service - Project Identity + Project Path Model Size @@ -230,13 +233,15 @@

Recent Requests for {{ client_ip }}

const canonical = `${identityKind}:${identitySubject}`; const displayName = log.identity_display_name; - const label = displayName ? `${canonical} (${displayName})` : canonical; - if (identityKind === 'facade_key') { - return `${escapeHtml(label)}`; + const label = displayName || identitySubject; + const secondaryId = displayName && displayName !== identitySubject + ? `${escapeHtml(identitySubject)}` + : ''; + return `
${escapeHtml(label)}${secondaryId}
`; } - return escapeHtml(label); + return `
${escapeHtml(canonical)}Identity
`; } // JSON formatting functions (same as main dashboard) @@ -401,7 +406,7 @@

Recent Requests for {{ client_ip }}

${log.service_type || 'unknown'} - ${renderProjectCell(log)} + ${renderProjectCell(log)} ${log.path || '-'} ${formatModel(log.original_model, log.mapped_model, log.model_observation_status)} ${formatSize(log.request_size)} @@ -549,7 +554,7 @@

Recent Requests for {{ client_ip }}

${request.service_type || 'unknown'} - ${renderProjectCell(request)} + ${renderProjectCell(request)} ${request.path || '-'} ${formatModel(request.original_model, request.mapped_model, request.model_observation_status)} ${formatSize(request.request_size)} @@ -583,7 +588,7 @@

Recent Requests for {{ client_ip }}

${request.service_type || 'unknown'} - ${renderProjectCell(request)} + ${renderProjectCell(request)} ${request.path || '-'} ${formatModel(request.original_model, request.mapped_model, request.model_observation_status)} ${formatSize(request.request_size)} diff --git a/smolrouter/templates/index.html b/smolrouter/templates/index.html index f107518..6283233 100644 --- a/smolrouter/templates/index.html +++ b/smolrouter/templates/index.html @@ -94,6 +94,12 @@ color: #666; } + .project-primary { + display: inline-flex; + align-items: center; + gap: 0.4rem; + } + .token-button { padding: 0; border: none; @@ -125,6 +131,17 @@ color: #007bff; } + .icon-link:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; + border-radius: 3px; + } + + .identity-cue { + color: #6c757d; + font-size: 0.72rem; + } + .provider-primary { font-weight: 500; } @@ -242,7 +259,7 @@

Recent Requests

class="filter-input" id="dashboard-query" type="search" - placeholder="host:192.168.1.14 provider:google-gen-ai model:gemma3-12b project:project-a identity:facade_key:project-a" + placeholder="host:192.168.1.14 provider:google-gen-ai model:gemma3-12b project:project-a" autocomplete="off" > @@ -251,7 +268,7 @@

Recent Requests

- Space-separated clauses are ANDed. Supported fields: host, provider, model, project, and identity. Bare words also search across the visible request fields. + Space-separated clauses are ANDed. Supported fields: host, provider, model, and project. Bare words also search across the visible request fields.
@@ -263,7 +280,7 @@

Recent Requests

Time Source - Project Identity + Project Provider Path Model @@ -367,6 +384,10 @@

Recent Requests

return div.innerHTML; } + function escapeAttribute(text) { + return escapeHtml(text).replaceAll('"', '"').replaceAll("'", '''); + } + function escapeClassName(text) { return String(text || 'unknown').toLowerCase().replaceAll(/[^a-z0-9_-]+/g, '-'); } @@ -430,19 +451,35 @@

Recent Requests

function renderProjectCell(log) { const identityKind = log.identity_kind; const identitySubject = log.identity_subject_id; - const identityDisplayName = log.identity_display_name; - if (!identityKind || !identitySubject) { return '-'; } const canonical = `${identityKind}:${identitySubject}`; - const label = log.identity_display_name ? `${canonical} (${identityDisplayName})` : canonical; + if (identityKind !== 'facade_key') { + return ` +
+ ${escapeHtml(canonical)} + Identity +
+ `; + } + + const displayName = log.identity_display_name; + const label = displayName || identitySubject; + const secondaryId = displayName && displayName !== identitySubject + ? `
${renderFilterButton('project', identitySubject, identitySubject, 'secondary')}
` + : ''; return `
-
${renderFilterButton('project', identitySubject, canonical)}
-
${renderFilterButton('identity', canonical, label, 'secondary')}
+
+ ${renderFilterButton('project', identitySubject, label)} + + + +
+ ${secondaryId}
`; } @@ -527,7 +564,7 @@

Recent Requests

${formatTimestamp(log.timestamp)} ${renderSourceCell(log)} - ${renderProjectCell(log)} + ${renderProjectCell(log)} ${renderProviderCell(log)} ${escapeHtml(log.path || '-')} ${formatModel(log.original_model, log.mapped_model, log.model_observation_status)} @@ -730,8 +767,8 @@

Recent Requests

return; } - const clientLink = event.target.closest('.client-dashboard-link'); - if (clientLink) { + const navigationLink = event.target.closest('.client-dashboard-link, .project-detail-link'); + if (navigationLink) { event.stopPropagation(); } } diff --git a/smolrouter/templates/project_detail.html b/smolrouter/templates/project_detail.html index 42f6cf3..12e32d7 100644 --- a/smolrouter/templates/project_detail.html +++ b/smolrouter/templates/project_detail.html @@ -105,8 +105,19 @@ .management-panel { background:#fff; border:1px solid #e5e7eb; border-radius:10px; padding:1rem 1.5rem; margin-bottom:1rem; } .management-row { display:flex; gap:.6rem; align-items:center; flex-wrap:wrap; } .management-message { color:#b91c1c; min-height:1.2rem; margin-top:.5rem; } - .secret-panel { display:none; background:#fffbeb; border:1px solid #f59e0b; border-radius:8px; padding:1rem; margin-top:.75rem; } + .secret-panel { position:relative; background:#fffbeb; border:1px solid #f59e0b; border-radius:8px; padding:1rem; margin-top:.75rem; } + .secret-panel[hidden] { display:none; } + .secret-title { display:block; padding-right:2.5rem; margin-bottom:.5rem; } + .secret-row { display:flex; gap:.4rem; align-items:stretch; } + .secret-value { flex:1; min-width:0; border:1px solid #d1d5db; border-radius:6px; background:#fff; padding:.55rem .65rem; } .secret-value, .fingerprint { font-family:"Monaco","Menlo",monospace; overflow-wrap:anywhere; } + .icon-button { display:inline-flex; align-items:center; justify-content:center; min-width:2.6rem; padding:.45rem; } + .dismiss-secret { position:absolute; right:.55rem; top:.55rem; border:0; background:transparent; color:#475569; border-radius:5px; cursor:pointer; } + .dismiss-secret:hover { background:#fef3c7; color:#0f172a; } + .icon-button:focus-visible, .dismiss-secret:focus-visible, .secret-value:focus-visible { outline:3px solid #2563eb; outline-offset:2px; } + .copy-status { display:block; min-height:1.2rem; margin-top:.4rem; color:#475569; font-size:.9rem; } + .copy-status.success { color:#166534; } + .copy-status.warning { color:#b45309; } .danger { background:#b91c1c !important; color:#fff !important; } {% include 'responsive_shell.html' %} @@ -120,7 +131,9 @@

{{ project.display_name or project.id }}

-
facade_key:{{ project.id }}
+ {% if project.display_name and project.display_name != project.id %} +
Project ID: {{ project.id }}
+ {% endif %}
{% if project.configured and project.enabled %} Enabled @@ -141,11 +154,14 @@

{{ project.display_name or project.id }}

API keys

-
- Copy this key now. It will not be shown again. -
- - + @@ -195,14 +211,14 @@

Delete project

-

Recent Facade-Key Requests

+

Recent Project Requests

- This page shows recent requests attributed to facade_key:{{ project.id }} within the retained request-log window. The displayed {{ project.request_rate_limit.source | default("historical", true) | replace('_', ' ') }} request policy is effective configuration, not a live Redis counter. + This page shows recent requests for this project within the retained request-log window. The displayed {{ project.request_rate_limit.source | default("historical", true) | replace('_', ' ') }} request policy is effective configuration, not a live Redis counter.
@@ -243,27 +259,28 @@

Recent Facade-Key Requests

{% include 'project_mutation.html' %} {% if project.configured %} +