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
+
+
+
+
+
+ | UID |
+ Primary Key |
+ Documents |
+ Settings Version |
+ Fingerprint |
+ Actions |
+
+
+
+ | Loading... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Edit Settings (JSON)
+
+
+
+
+
2PC Preview โ What Will Change
+
+
+ New Fingerprint:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Are you sure you want to delete index ?
+
This action cannot be undone. All documents in this index will be permanently deleted.
+
+
+
+
+
+
+
+
-
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
+
+
+
+
+
+ | Name |
+ Type |
+ Current Target |
+ Version |
+ Created |
+ Actions |
+
+
+
+ | Loading... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Flip alias to a new target:
+
+
+
+
+
+
+
+
+
This atomically updates the alias. All queries will immediately resolve to the new target.
+
+
+
+
+
+
+
+
+
+
+
Are you sure you want to delete alias ?
+
This action cannot be undone. Queries using this alias will fail after deletion.
+
+
+
+
+
+
+
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;
+ }
+}