feat(admin-ui): implement Indexes and Aliases sections with 2PC preview (P5.19.b)

Implements plan §13.19 Indexes and Aliases sections for the Admin Web UI.

**Indexes section:**
- List indexes with UID, primary key, document count, settings version, and fingerprint
- Create index modal with UID and primary key fields
- Delete index with confirmation (requires retyping index name)
- Settings viewer/editor with live 2PC preview:
  - Display current settings as JSON
  - Edit settings in textarea
  - Preview changes with diff view (added/removed/changed fields)
  - Show new fingerprint before commit
  - Apply changes via PATCH /indexes/{uid}/settings

**Aliases section:**
- List aliases with name, type (single/multi), current target, version, created date
- Create alias modal with type selection and target(s)
- Flip alias modal for single-target aliases (blue-green deployments)
- Delete alias with confirmation (requires retyping alias name)
- History timeline showing all alias flips with timestamps

**UI components added:**
- Modal system (overlay, content, header, body, footer)
- Form styles (inputs, labels, hints)
- Button styles (primary, secondary, danger)
- Settings editor (JSON display, textarea, diff view)
- Timeline component for alias history
- Responsive design for mobile devices

Closes: miroir-uhj.19.2
This commit is contained in:
jedarden 2026-05-24 12:40:21 -04:00
parent ddd84f53e1
commit f83e891214
3 changed files with 1099 additions and 7 deletions

View file

@ -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 = '<tr><td colspan="6" class="loading">Loading...</td></tr>';
return;
}
if (state.indexes.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="loading">No indexes found. Create your first index to get started.</td></tr>';
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 `
<tr>
<td data-label="UID">${escapeHtml(idx.uid)}</td>
<td data-label="Primary Key">${escapeHtml(primaryKey)}</td>
<td data-label="Documents">${docCount.toLocaleString()}</td>
<td data-label="Settings Version">${settingsVersion}</td>
<td data-label="Fingerprint"><code class="code">${escapeHtml(fingerprint.substring(0, 12))}${fingerprint.length > 12 ? '...' : ''}</code></td>
<td data-label="Actions">
<div class="action-buttons">
<button class="btn btn-sm btn-secondary" onclick="openSettingsModal('${escapeHtml(idx.uid)}')">Settings</button>
<button class="btn btn-sm btn-danger" onclick="confirmDeleteIndex('${escapeHtml(idx.uid)}')">Delete</button>
</div>
</td>
</tr>
`;
}).join('');
}
// ============================================================================
// Rendering - Aliases Section
// ============================================================================
async function renderAliases() {
const tbody = document.getElementById('aliasesTableBody');
if (!state.aliases) {
tbody.innerHTML = '<tr><td colspan="6" class="loading">Loading...</td></tr>';
return;
}
if (state.aliases.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="loading">No aliases found. Create an alias to get started.</td></tr>';
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 `
<tr>
<td data-label="Name">${escapeHtml(alias.name)}</td>
<td data-label="Type"><span class="badge info">${escapeHtml(kindLabel)}</span></td>
<td data-label="Current Target">${escapeHtml(String(target))}</td>
<td data-label="Version">${alias.version || 0}</td>
<td data-label="Created">${alias.createdAt ? new Date(alias.createdAt * 1000).toLocaleString() : '-'}</td>
<td data-label="Actions">
<div class="action-buttons">
${alias.kind === 'single' ? `<button class="btn btn-sm btn-primary" onclick="openFlipAliasModal('${escapeHtml(alias.name)}', '${escapeHtml(String(target))}')">Flip</button>` : ''}
<button class="btn btn-sm btn-secondary" onclick="showAliasHistory('${escapeHtml(alias.name)}')">History</button>
<button class="btn btn-sm btn-danger" onclick="confirmDeleteAlias('${escapeHtml(alias.name)}')">Delete</button>
</div>
</td>
</tr>
`;
}).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 = '<p class="placeholder">No history available</p>';
return;
}
timeline.innerHTML = history.map((entry, i) => `
<div class="timeline-entry ${i === 0 ? 'current' : ''}">
<div class="timeline-time">${new Date(entry.flippedAt * 1000).toLocaleString()}</div>
<div class="timeline-content">
<strong>${escapeHtml(entry.uid)}</strong>
<span>${i === 0 ? 'Current target' : 'Previous target'}</span>
</div>
</div>
`).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 = '<p class="info">No changes detected.</p>';
} else {
diffSummary.innerHTML = diff.map(line =>
`<div class="diff-line ${line.type}">${escapeHtml(line.text)}</div>`
).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;
})();

View file

@ -147,18 +147,231 @@
</div>
</section>
<!-- Other sections (placeholders) -->
<!-- Indexes Section -->
<section id="indexes" class="section">
<div class="card">
<h3>Index Management</h3>
<p class="placeholder">Coming soon — List, create, delete indexes with 2PC settings preview</p>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3>Index Management</h3>
<button class="btn btn-primary" id="createIndexBtn">Create Index</button>
</div>
<p class="card-subtitle">Manage indexes and their settings with two-phase commit (2PC) preview</p>
<div class="table-container">
<table class="data-table" id="indexesTable">
<thead>
<tr>
<th>UID</th>
<th>Primary Key</th>
<th>Documents</th>
<th>Settings Version</th>
<th>Fingerprint</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="indexesTableBody">
<tr><td colspan="6" class="loading">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Settings Editor Modal -->
<div id="settingsModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="settingsModalTitle">Index Settings</h3>
<button class="modal-close" id="settingsModalClose">&times;</button>
</div>
<div class="modal-body">
<div class="settings-preview" id="settingsPreview">
<h4>Current Settings</h4>
<pre id="currentSettingsJson" class="settings-json"></pre>
</div>
<div class="settings-editor">
<h4>Edit Settings (JSON)</h4>
<textarea id="settingsEditor" class="settings-textarea" rows="15"></textarea>
</div>
<div class="settings-diff" id="settingsDiff" style="display: none;">
<h4>2PC Preview — What Will Change</h4>
<div class="diff-summary" id="diffSummary"></div>
<div class="diff-fingerprint">
<strong>New Fingerprint:</strong> <code id="newFingerprint"></code>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="settingsPreviewBtn">Preview Changes</button>
<button class="btn btn-primary" id="settingsApplyBtn" style="display: none;">Apply Changes</button>
<button class="btn btn-secondary" id="settingsCancelBtn">Cancel</button>
</div>
</div>
</div>
<!-- Create Index Modal -->
<div id="createIndexModal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h3>Create Index</h3>
<button class="modal-close" id="createIndexModalClose">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="indexUid">Index UID *</label>
<input type="text" id="indexUid" class="form-input" placeholder="my-index" required>
</div>
<div class="form-group">
<label for="indexPrimaryKey">Primary Key</label>
<input type="text" id="indexPrimaryKey" class="form-input" placeholder="id">
<small class="form-hint">Required for sharding. If omitted, Meilisearch will infer it from the first document.</small>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="createIndexCancelBtn">Cancel</button>
<button class="btn btn-primary" id="createIndexConfirmBtn">Create</button>
</div>
</div>
</div>
<!-- Delete Index Confirmation Modal -->
<div id="deleteIndexModal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h3>Delete Index</h3>
<button class="modal-close" id="deleteIndexModalClose">&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete index <strong id="deleteIndexName"></strong>?</p>
<p class="warning">This action cannot be undone. All documents in this index will be permanently deleted.</p>
<div class="form-group">
<label for="deleteIndexConfirm">Type the index name to confirm</label>
<input type="text" id="deleteIndexConfirm" class="form-input">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="deleteIndexCancelBtn">Cancel</button>
<button class="btn btn-danger" id="deleteIndexConfirmBtn" disabled>Delete</button>
</div>
</div>
</div>
</section>
<!-- Aliases Section -->
<section id="aliases" class="section">
<div class="card">
<h3>Alias Management</h3>
<p class="placeholder">Coming soon — List, create, flip, delete aliases with history timeline</p>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3>Alias Management</h3>
<button class="btn btn-primary" id="createAliasBtn">Create Alias</button>
</div>
<p class="card-subtitle">Atomic index aliases for blue-green reindexing and zero-downtime migrations</p>
<div class="table-container">
<table class="data-table" id="aliasesTable">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Current Target</th>
<th>Version</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="aliasesTableBody">
<tr><td colspan="6" class="loading">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Alias History Timeline -->
<div id="aliasHistoryCard" class="card" style="display: none;">
<h3>Alias History: <span id="aliasHistoryName"></span></h3>
<div id="aliasHistoryTimeline" class="timeline"></div>
</div>
<!-- Create Alias Modal -->
<div id="createAliasModal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h3>Create Alias</h3>
<button class="modal-close" id="createAliasModalClose">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="aliasName">Alias Name *</label>
<input type="text" id="aliasName" class="form-input" placeholder="products" required>
</div>
<div class="form-group">
<label>Alias Type *</label>
<select id="aliasType" class="form-input">
<option value="single">Single Target (for blue-green deployments)</option>
<option value="multi">Multi Target (for ILM rollups)</option>
</select>
</div>
<div class="form-group" id="singleTargetGroup">
<label for="aliasTarget">Target Index *</label>
<input type="text" id="aliasTarget" class="form-input" placeholder="products_v3">
</div>
<div class="form-group" id="multiTargetGroup" style="display: none;">
<label for="aliasTargets">Target Indexes (comma-separated) *</label>
<input type="text" id="aliasTargets" class="form-input" placeholder="logs-2026-01-01, logs-2026-01-02">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="createAliasCancelBtn">Cancel</button>
<button class="btn btn-primary" id="createAliasConfirmBtn">Create</button>
</div>
</div>
</div>
<!-- Flip Alias Modal -->
<div id="flipAliasModal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h3>Flip Alias</h3>
<button class="modal-close" id="flipAliasModalClose">&times;</button>
</div>
<div class="modal-body">
<p>Flip alias <strong id="flipAliasName"></strong> to a new target:</p>
<div class="form-group">
<label>Current Target</label>
<input type="text" id="flipAliasCurrent" class="form-input" disabled>
</div>
<div class="form-group">
<label for="flipAliasNew">New Target *</label>
<input type="text" id="flipAliasNew" class="form-input" placeholder="products_v4">
</div>
<p class="info">This atomically updates the alias. All queries will immediately resolve to the new target.</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="flipAliasCancelBtn">Cancel</button>
<button class="btn btn-primary" id="flipAliasConfirmBtn">Flip</button>
</div>
</div>
</div>
<!-- Delete Alias Confirmation Modal -->
<div id="deleteAliasModal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h3>Delete Alias</h3>
<button class="modal-close" id="deleteAliasModalClose">&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete alias <strong id="deleteAliasName"></strong>?</p>
<p class="warning">This action cannot be undone. Queries using this alias will fail after deletion.</p>
<div class="form-group">
<label for="deleteAliasConfirm">Type the alias name to confirm</label>
<input type="text" id="deleteAliasConfirm" class="form-input">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="deleteAliasCancelBtn">Cancel</button>
<button class="btn btn-danger" id="deleteAliasConfirmBtn" disabled>Delete</button>
</div>
</div>
</div>
</section>

View file

@ -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;
}
}