diff --git a/crates/miroir-proxy/admin-ui/dist/app.js b/crates/miroir-proxy/admin-ui/dist/app.js index 5581a75..7839c2a 100644 --- a/crates/miroir-proxy/admin-ui/dist/app.js +++ b/crates/miroir-proxy/admin-ui/dist/app.js @@ -1,6 +1,6 @@ /** * Miroir Admin UI - Plan ยง13.19 - * Overview and Topology sections implementation + * Overview, Topology, Indexes, and Aliases sections implementation */ (function() { @@ -16,7 +16,11 @@ shards: null, rebalanceStatus: null, refreshInterval: null, - isConnected: true + isConnected: true, + indexes: null, + aliases: null, + currentSettingsIndex: null, + currentAliasForHistory: null }; // ============================================================================ @@ -124,6 +128,55 @@ } } + async function fetchIndexes() { + try { + const data = await fetchAPI('/indexes'); + state.indexes = data.results || []; + return state.indexes; + } catch (error) { + console.error('Failed to fetch indexes:', error); + throw error; + } + } + + async function fetchIndexStats(indexUid) { + try { + return await fetchAPI(`/indexes/${indexUid}/stats`); + } catch (error) { + console.error(`Failed to fetch stats for ${indexUid}:`, error); + return null; + } + } + + async function fetchIndexSettings(indexUid) { + try { + return await fetchAPI(`/indexes/${indexUid}/settings`); + } catch (error) { + console.error(`Failed to fetch settings for ${indexUid}:`, error); + return null; + } + } + + async function fetchAliases() { + try { + const data = await fetchAPI('/aliases'); + state.aliases = data.results || []; + return state.aliases; + } catch (error) { + console.error('Failed to fetch aliases:', error); + throw error; + } + } + + async function fetchAliasDetails(aliasName) { + try { + return await fetchAPI(`/aliases/${aliasName}`); + } catch (error) { + console.error(`Failed to fetch alias ${aliasName}:`, error); + return null; + } + } + async function refreshData() { // Always fetch topology (used by overview and topology sections) await fetchTopology(); @@ -138,6 +191,12 @@ } else if (state.currentSection === 'overview') { await fetchRebalanceStatus(); renderOverview(); + } else if (state.currentSection === 'indexes') { + await fetchIndexes(); + renderIndexes(); + } else if (state.currentSection === 'aliases') { + await fetchAliases(); + renderAliases(); } } @@ -352,6 +411,125 @@ `; } + // ============================================================================ + // Rendering - Indexes Section + // ============================================================================ + + async function renderIndexes() { + const tbody = document.getElementById('indexesTableBody'); + if (!state.indexes) { + tbody.innerHTML = 'Loading...'; + return; + } + + if (state.indexes.length === 0) { + tbody.innerHTML = 'No indexes found. Create your first index to get started.'; + return; + } + + // Fetch stats for all indexes in parallel + const statsPromises = state.indexes.map(idx => fetchIndexStats(idx.uid)); + const statsResults = await Promise.allSettled(statsPromises); + + // Fetch settings version for all indexes + const settingsPromises = state.indexes.map(idx => fetchIndexSettings(idx.uid)); + const settingsResults = await Promise.allSettled(settingsPromises); + + tbody.innerHTML = state.indexes.map((idx, i) => { + const stats = statsResults[i].status === 'fulfilled' ? statsResults[i].value : null; + const settings = settingsResults[i].status === 'fulfilled' ? settingsResults[i].value : null; + + const docCount = stats?.numberOfDocuments || 0; + const primaryKey = idx.primaryKey || '-'; + const settingsVersion = settings?._miroirSettingsVersion || '-'; + const fingerprint = settings?._miroirFingerprint || '-'; + + return ` + + ${escapeHtml(idx.uid)} + ${escapeHtml(primaryKey)} + ${docCount.toLocaleString()} + ${settingsVersion} + ${escapeHtml(fingerprint.substring(0, 12))}${fingerprint.length > 12 ? '...' : ''} + +
+ + +
+ + + `; + }).join(''); + } + + // ============================================================================ + // Rendering - Aliases Section + // ============================================================================ + + async function renderAliases() { + const tbody = document.getElementById('aliasesTableBody'); + if (!state.aliases) { + tbody.innerHTML = 'Loading...'; + return; + } + + if (state.aliases.length === 0) { + tbody.innerHTML = 'No aliases found. Create an alias to get started.'; + return; + } + + tbody.innerHTML = state.aliases.map(alias => { + const kindLabel = alias.kind === 'single' ? 'Single Target' : 'Multi Target'; + const target = alias.kind === 'single' + ? (alias.currentUid || '-') + : (alias.targetUids?.join(', ') || '-'); + + return ` + + ${escapeHtml(alias.name)} + ${escapeHtml(kindLabel)} + ${escapeHtml(String(target))} + ${alias.version || 0} + ${alias.createdAt ? new Date(alias.createdAt * 1000).toLocaleString() : '-'} + +
+ ${alias.kind === 'single' ? `` : ''} + + +
+ + + `; + }).join(''); + } + + async function showAliasHistory(aliasName) { + state.currentAliasForHistory = aliasName; + const details = await fetchAliasDetails(aliasName); + if (!details) return; + + document.getElementById('aliasHistoryName').textContent = aliasName; + document.getElementById('aliasHistoryCard').style.display = 'block'; + + const timeline = document.getElementById('aliasHistoryTimeline'); + const history = details.history || []; + + if (history.length === 0) { + timeline.innerHTML = '

No history available

'; + return; + } + + timeline.innerHTML = history.map((entry, i) => ` +
+
${new Date(entry.flippedAt * 1000).toLocaleString()}
+
+ ${escapeHtml(entry.uid)} + ${i === 0 ? 'Current target' : 'Previous target'} +
+
+ `).join(''); + } + // ============================================================================ // Utilities // ============================================================================ @@ -440,10 +618,328 @@ // Initialization // ============================================================================ + // ============================================================================ + // Modal Handlers - Indexes + // ============================================================================ + + function openSettingsModal(indexUid) { + state.currentSettingsIndex = indexUid; + + // Fetch current settings + fetchIndexSettings(indexUid).then(settings => { + const currentJson = JSON.stringify(settings || {}, null, 2); + document.getElementById('currentSettingsJson').textContent = currentJson; + document.getElementById('settingsEditor').value = currentJson; + document.getElementById('settingsModalTitle').textContent = `Settings: ${indexUid}`; + document.getElementById('settingsDiff').style.display = 'none'; + document.getElementById('settingsApplyBtn').style.display = 'none'; + document.getElementById('settingsPreviewBtn').style.display = 'inline-flex'; + showModal('settingsModal'); + }).catch(err => { + console.error('Failed to fetch settings:', err); + alert('Failed to load settings. Please try again.'); + }); + } + + function previewSettingsChanges() { + const editor = document.getElementById('settingsEditor'); + const newSettings = JSON.parse(editor.value); + const currentJson = document.getElementById('currentSettingsJson').textContent; + const currentSettings = JSON.parse(currentJson || '{}'); + + // Compute fingerprint of new settings + const newFingerprint = computeFingerprint(newSettings); + document.getElementById('newFingerprint').textContent = newFingerprint; + + // Compute diff + const diffSummary = document.getElementById('diffSummary'); + const diff = computeDiff(currentSettings, newSettings); + + if (diff.length === 0) { + diffSummary.innerHTML = '

No changes detected.

'; + } else { + diffSummary.innerHTML = diff.map(line => + `
${escapeHtml(line.text)}
` + ).join(''); + } + + document.getElementById('settingsDiff').style.display = 'block'; + document.getElementById('settingsPreviewBtn').style.display = 'none'; + document.getElementById('settingsApplyBtn').style.display = 'inline-flex'; + } + + async function applySettingsChanges() { + const indexUid = state.currentSettingsIndex; + const editor = document.getElementById('settingsEditor'); + const newSettings = JSON.parse(editor.value); + + try { + await fetchAPI(`/indexes/${indexUid}/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newSettings) + }); + + hideModal('settingsModal'); + refreshData(); + } catch (error) { + console.error('Failed to apply settings:', error); + alert('Failed to apply settings. Please try again.'); + } + } + + function confirmDeleteIndex(indexUid) { + document.getElementById('deleteIndexName').textContent = indexUid; + document.getElementById('deleteIndexConfirm').value = ''; + document.getElementById('deleteIndexConfirmBtn').disabled = true; + showModal('deleteIndexModal'); + } + + async function deleteIndex() { + const indexUid = document.getElementById('deleteIndexName').textContent; + const confirmInput = document.getElementById('deleteIndexConfirm').value; + + if (confirmInput !== indexUid) { + alert('Index name does not match.'); + return; + } + + try { + await fetchAPI(`/indexes/${indexUid}`, { method: 'DELETE' }); + hideModal('deleteIndexModal'); + refreshData(); + } catch (error) { + console.error('Failed to delete index:', error); + alert('Failed to delete index. Please try again.'); + } + } + + // ============================================================================ + // Modal Handlers - Aliases + // ============================================================================ + + function openFlipAliasModal(aliasName, currentTarget) { + document.getElementById('flipAliasName').textContent = aliasName; + document.getElementById('flipAliasCurrent').value = currentTarget; + document.getElementById('flipAliasNew').value = ''; + showModal('flipAliasModal'); + } + + async function flipAlias() { + const aliasName = document.getElementById('flipAliasName').textContent; + const newTarget = document.getElementById('flipAliasNew').value.trim(); + + if (!newTarget) { + alert('Please enter a new target.'); + return; + } + + try { + await fetchAPI(`/_miroir/aliases/${aliasName}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: newTarget }) + }); + + hideModal('flipAliasModal'); + refreshData(); + } catch (error) { + console.error('Failed to flip alias:', error); + alert('Failed to flip alias. Please try again.'); + } + } + + function confirmDeleteAlias(aliasName) { + document.getElementById('deleteAliasName').textContent = aliasName; + document.getElementById('deleteAliasConfirm').value = ''; + document.getElementById('deleteAliasConfirmBtn').disabled = true; + showModal('deleteAliasModal'); + } + + async function deleteAlias() { + const aliasName = document.getElementById('deleteAliasName').textContent; + const confirmInput = document.getElementById('deleteAliasConfirm').value; + + if (confirmInput !== aliasName) { + alert('Alias name does not match.'); + return; + } + + try { + await fetchAPI(`/_miroir/aliases/${aliasName}`, { method: 'DELETE' }); + hideModal('deleteAliasModal'); + refreshData(); + } catch (error) { + console.error('Failed to delete alias:', error); + alert('Failed to delete alias. Please try again.'); + } + } + + async function createAlias() { + const name = document.getElementById('aliasName').value.trim(); + const type = document.getElementById('aliasType').value; + const target = document.getElementById('aliasTarget').value.trim(); + const targets = document.getElementById('aliasTargets').value.trim().split(',').map(s => s.trim()).filter(s => s); + + if (!name) { + alert('Please enter an alias name.'); + return; + } + + const body = type === 'single' + ? { target } + : { targets }; + + try { + await fetchAPI(`/_miroir/aliases/${name}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + hideModal('createAliasModal'); + refreshData(); + } catch (error) { + console.error('Failed to create alias:', error); + alert('Failed to create alias. Please try again.'); + } + } + + // ============================================================================ + // Modal Helpers + // ============================================================================ + + function showModal(modalId) { + document.getElementById(modalId).classList.add('active'); + } + + function hideModal(modalId) { + document.getElementById(modalId).classList.remove('active'); + } + + function computeFingerprint(settings) { + const canonical = JSON.stringify(settings, Object.keys(settings).sort()); + let hash = 0; + for (let i = 0; i < canonical.length; i++) { + const char = canonical.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return Math.abs(hash).toString(16).padStart(8, '0'); + } + + function computeDiff(current, newSettings) { + const diff = []; + + // Find removed keys + for (const key in current) { + if (!(key in newSettings)) { + diff.push({ type: 'removed', text: `- ${key}: ${JSON.stringify(current[key])}` }); + } + } + + // Find added and changed keys + for (const key in newSettings) { + const currentVal = current[key]; + const newVal = newSettings[key]; + + if (!(key in current)) { + diff.push({ type: 'added', text: `+ ${key}: ${JSON.stringify(newVal)}` }); + } else if (JSON.stringify(currentVal) !== JSON.stringify(newVal)) { + diff.push({ type: 'removed', text: `- ${key}: ${JSON.stringify(currentVal)}` }); + diff.push({ type: 'added', text: `+ ${key}: ${JSON.stringify(newVal)}` }); + } + } + + return diff; + } + + // ============================================================================ + // Initialization + // ============================================================================ + + function initModals() { + // Settings modal + document.getElementById('settingsModalClose').addEventListener('click', () => hideModal('settingsModal')); + document.getElementById('settingsCancelBtn').addEventListener('click', () => hideModal('settingsModal')); + document.getElementById('settingsPreviewBtn').addEventListener('click', previewSettingsChanges); + document.getElementById('settingsApplyBtn').addEventListener('click', applySettingsChanges); + + // Create index modal + document.getElementById('createIndexBtn').addEventListener('click', () => showModal('createIndexModal')); + document.getElementById('createIndexModalClose').addEventListener('click', () => hideModal('createIndexModal')); + document.getElementById('createIndexCancelBtn').addEventListener('click', () => hideModal('createIndexModal')); + document.getElementById('createIndexConfirmBtn').addEventListener('click', async () => { + const uid = document.getElementById('indexUid').value.trim(); + const primaryKey = document.getElementById('indexPrimaryKey').value.trim() || null; + + if (!uid) { + alert('Please enter an index UID.'); + return; + } + + try { + const body = primaryKey ? { uid, primaryKey } : { uid }; + await fetchAPI('/indexes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + hideModal('createIndexModal'); + refreshData(); + } catch (error) { + console.error('Failed to create index:', error); + alert('Failed to create index. Please try again.'); + } + }); + + // Delete index modal + document.getElementById('deleteIndexModalClose').addEventListener('click', () => hideModal('deleteIndexModal')); + document.getElementById('deleteIndexCancelBtn').addEventListener('click', () => hideModal('deleteIndexModal')); + document.getElementById('deleteIndexConfirmBtn').addEventListener('click', deleteIndex); + document.getElementById('deleteIndexConfirm').addEventListener('input', (e) => { + document.getElementById('deleteIndexConfirmBtn').disabled = e.target.value !== document.getElementById('deleteIndexName').textContent; + }); + + // Create alias modal + document.getElementById('createAliasBtn').addEventListener('click', () => showModal('createAliasModal')); + document.getElementById('createAliasModalClose').addEventListener('click', () => hideModal('createAliasModal')); + document.getElementById('createAliasCancelBtn').addEventListener('click', () => hideModal('createAliasModal')); + document.getElementById('createAliasConfirmBtn').addEventListener('click', createAlias); + document.getElementById('aliasType').addEventListener('change', (e) => { + const isSingle = e.target.value === 'single'; + document.getElementById('singleTargetGroup').style.display = isSingle ? 'block' : 'none'; + document.getElementById('multiTargetGroup').style.display = isSingle ? 'none' : 'block'; + }); + + // Flip alias modal + document.getElementById('flipAliasModalClose').addEventListener('click', () => hideModal('flipAliasModal')); + document.getElementById('flipAliasCancelBtn').addEventListener('click', () => hideModal('flipAliasModal')); + document.getElementById('flipAliasConfirmBtn').addEventListener('click', flipAlias); + + // Delete alias modal + document.getElementById('deleteAliasModalClose').addEventListener('click', () => hideModal('deleteAliasModal')); + document.getElementById('deleteAliasCancelBtn').addEventListener('click', () => hideModal('deleteAliasModal')); + document.getElementById('deleteAliasConfirmBtn').addEventListener('click', deleteAlias); + document.getElementById('deleteAliasConfirm').addEventListener('input', (e) => { + document.getElementById('deleteAliasConfirmBtn').disabled = e.target.value !== document.getElementById('deleteAliasName').textContent; + }); + + // Close modals on backdrop click + document.querySelectorAll('.modal').forEach(modal => { + modal.addEventListener('click', (e) => { + if (e.target === modal) { + modal.classList.remove('active'); + } + }); + }); + } + function init() { initNavigation(); initMobileMenu(); initRefreshButton(); + initModals(); // Initial data fetch refreshData().then(() => { @@ -473,4 +969,11 @@ init(); } + // Export functions to global scope for onclick handlers + window.openSettingsModal = openSettingsModal; + window.confirmDeleteIndex = confirmDeleteIndex; + window.openFlipAliasModal = openFlipAliasModal; + window.showAliasHistory = showAliasHistory; + window.confirmDeleteAlias = confirmDeleteAlias; + })(); diff --git a/crates/miroir-proxy/admin-ui/dist/index.html b/crates/miroir-proxy/admin-ui/dist/index.html index 88247fd..59737a3 100644 --- a/crates/miroir-proxy/admin-ui/dist/index.html +++ b/crates/miroir-proxy/admin-ui/dist/index.html @@ -147,18 +147,231 @@ - +
-

Index Management

-

Coming soon โ€” List, create, delete indexes with 2PC settings preview

+
+

Index Management

+ +
+

Manage indexes and their settings with two-phase commit (2PC) preview

+ +
+ + + + + + + + + + + + + + +
UIDPrimary KeyDocumentsSettings VersionFingerprintActions
Loading...
+
+
+ + + + + + + + +
+
-

Alias Management

-

Coming soon โ€” List, create, flip, delete aliases with history timeline

+
+

Alias Management

+ +
+

Atomic index aliases for blue-green reindexing and zero-downtime migrations

+ +
+ + + + + + + + + + + + + + +
NameTypeCurrent TargetVersionCreatedActions
Loading...
+
+
+ + + + + + + + + + + +
diff --git a/crates/miroir-proxy/admin-ui/dist/styles.css b/crates/miroir-proxy/admin-ui/dist/styles.css index 988b4fa..fd93e06 100644 --- a/crates/miroir-proxy/admin-ui/dist/styles.css +++ b/crates/miroir-proxy/admin-ui/dist/styles.css @@ -545,3 +545,379 @@ button:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; } + +/* Buttons */ +.btn { + padding: 0.5rem 1rem; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition); + border: 1px solid transparent; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.btn-primary { + background: var(--accent-color); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--border-color); +} + +.btn-danger { + background: var(--error-color); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-sm { + padding: 0.25rem 0.625rem; + font-size: 0.75rem; +} + +/* Modals */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; +} + +.modal.active { + display: flex; +} + +.modal-content { + background: var(--bg-secondary); + border-radius: var(--radius-lg); + width: 90%; + max-width: 700px; + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-md); +} + +.modal-header { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + font-size: 1.125rem; + font-weight: 600; + margin: 0; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + line-height: 1; + color: var(--text-secondary); + cursor: pointer; + padding: 0; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + transition: var(--transition); +} + +.modal-close:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.modal-body { + padding: 1.5rem; + overflow-y: auto; + flex: 1; +} + +.modal-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + display: flex; + gap: 0.75rem; + justify-content: flex-end; +} + +/* Forms */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 0.375rem; +} + +.form-input { + width: 100%; + padding: 0.625rem 0.875rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + font-size: 0.875rem; + background: var(--bg-secondary); + color: var(--text-primary); + transition: var(--transition); +} + +.form-input:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.form-input:disabled { + background: var(--bg-tertiary); + color: var(--text-tertiary); +} + +.form-hint { + display: block; + font-size: 0.75rem; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +.form-select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.5rem; +} + +/* Settings Editor */ +.settings-preview, +.settings-editor, +.settings-diff { + margin-bottom: 1.5rem; +} + +.settings-preview h4, +.settings-editor h4, +.settings-diff h4 { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +.settings-json { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 1rem; + font-size: 0.75rem; + overflow-x: auto; + max-height: 300px; + overflow-y: auto; +} + +.settings-textarea { + width: 100%; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.75rem; + padding: 1rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-primary); + resize: vertical; + min-height: 300px; +} + +.settings-textarea:focus { + outline: none; + border-color: var(--accent-color); +} + +.diff-summary { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 1rem; + font-size: 0.875rem; +} + +.diff-line { + padding: 0.25rem 0.5rem; + margin: 0.125rem 0; + border-radius: 4px; +} + +.diff-line.added { + background: #d1fae5; + color: #065f46; +} + +.diff-line.removed { + background: #fee2e2; + color: #991b1b; +} + +.diff-fingerprint { + font-size: 0.875rem; + padding: 0.75rem; + background: var(--bg-tertiary); + border-radius: var(--radius-md); + margin-top: 0.5rem; +} + +.diff-fingerprint code { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.75rem; + background: var(--bg-secondary); + padding: 0.125rem 0.375rem; + border-radius: 4px; +} + +/* Timeline */ +.timeline { + position: relative; + padding-left: 2rem; +} + +.timeline::before { + content: ''; + position: absolute; + left: 0.5rem; + top: 0; + bottom: 0; + width: 2px; + background: var(--border-color); +} + +.timeline-entry { + position: relative; + padding-bottom: 1.5rem; +} + +.timeline-entry::before { + content: ''; + position: absolute; + left: -1.625rem; + top: 0.25rem; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent-color); + border: 2px solid var(--bg-secondary); +} + +.timeline-entry.current::before { + background: var(--success-color); +} + +.timeline-time { + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 0.25rem; +} + +.timeline-content { + background: var(--bg-tertiary); + border-radius: var(--radius-md); + padding: 0.75rem 1rem; +} + +.timeline-content strong { + display: block; + font-size: 0.875rem; + color: var(--text-primary); +} + +.timeline-content span { + font-size: 0.75rem; + color: var(--text-secondary); +} + +/* Utility classes */ +.warning { + color: var(--warning-color); + font-size: 0.875rem; + margin: 0.5rem 0; +} + +.info { + color: var(--accent-color); + font-size: 0.875rem; + margin: 0.5rem 0; +} + +.code { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.75rem; + background: var(--bg-tertiary); + padding: 0.125rem 0.375rem; + border-radius: 4px; +} + +/* Action buttons in tables */ +.action-buttons { + display: flex; + gap: 0.375rem; +} + +/* Mobile responsive for modals */ +@media (max-width: 640px) { + .modal-content { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + border-radius: 0; + } + + .modal-header, + .modal-body, + .modal-footer { + padding: 1rem; + } + + .btn { + flex: 1; + justify-content: center; + } +}