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:
parent
ddd84f53e1
commit
f83e891214
3 changed files with 1099 additions and 7 deletions
507
crates/miroir-proxy/admin-ui/dist/app.js
vendored
507
crates/miroir-proxy/admin-ui/dist/app.js
vendored
|
|
@ -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;
|
||||
|
||||
})();
|
||||
|
|
|
|||
223
crates/miroir-proxy/admin-ui/dist/index.html
vendored
223
crates/miroir-proxy/admin-ui/dist/index.html
vendored
|
|
@ -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">×</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">×</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">×</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">×</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">×</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">×</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>
|
||||
|
||||
|
|
|
|||
376
crates/miroir-proxy/admin-ui/dist/styles.css
vendored
376
crates/miroir-proxy/admin-ui/dist/styles.css
vendored
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue