diff --git a/crates/miroir-proxy/admin-ui/dist/app.js b/crates/miroir-proxy/admin-ui/dist/app.js
index 9a4f301..748b7c7 100644
--- a/crates/miroir-proxy/admin-ui/dist/app.js
+++ b/crates/miroir-proxy/admin-ui/dist/app.js
@@ -26,7 +26,15 @@
// Query sandbox state
query: { indexes: [], currentIndex: null, results: null, filters: [], sorts: [], facets: [] },
// Tasks section state
- tasks: { indexes: [], tasks: [], filter: 'all', indexUid: '', type: '', limit: 50, offset: 0, total: 0 }
+ tasks: { indexes: [], tasks: [], filter: 'all', indexUid: '', type: '', limit: 50, offset: 0, total: 0 },
+ // Canaries section state
+ canaries: { canaries: [], indexes: [], currentCanary: null, capturedQueries: [], captureActive: false },
+ // Shadow diff state
+ shadow: { diffs: [], stats: null, autoRefresh: false, refreshInterval: null },
+ // CDC section state
+ cdc: { events: [], indexes: [], currentSequence: 0, paused: false, eventSource: null },
+ // Settings section state
+ settings: { config: null, categories: [] }
};
// ============================================================================
@@ -999,6 +1007,17 @@
await fetchTasksIndexes();
await fetchTasks();
renderTasks();
+ } else if (state.currentSection === 'canaries') {
+ await fetchCanaries();
+ renderCanaries();
+ } else if (state.currentSection === 'shadow') {
+ await renderShadowDiffs();
+ } else if (state.currentSection === 'settings') {
+ await renderSettings();
+ }
+ }
+ await fetchTasks();
+ renderTasks();
}
}
@@ -1744,6 +1763,21 @@
// Initialize Tasks section
initTasksSection();
+
+ // Initialize Canaries section
+ initCanariesSection();
+
+ // Initialize Shadow Diff section
+ initShadowSection();
+
+ // Initialize CDC section
+ initCdcSection();
+
+ // Initialize Metrics section
+ initMetricsSection();
+
+ // Initialize Settings section
+ initSettingsSection();
}
function initDocumentsSection() {
@@ -2181,6 +2215,627 @@
init();
}
+ // ===========================================================================
+ // Canaries Section - API Functions
+ // ===========================================================================
+
+ async function fetchCanaries() {
+ try {
+ const data = await fetchAPI('/canaries');
+ state.canaries.canaries = data.canaries || [];
+ return state.canaries.canaries;
+ } catch (error) {
+ console.error('Failed to fetch canaries:', error);
+ return [];
+ }
+ }
+
+ async function fetchCanaryIndexes() {
+ try {
+ const data = await fetchAPI('/indexes');
+ state.canaries.indexes = data.results || [];
+ return state.canaries.indexes;
+ } catch (error) {
+ console.error('Failed to fetch indexes for canaries:', error);
+ return [];
+ }
+ }
+
+ async function createCanary(canary) {
+ try {
+ return await fetchAPI('/canaries', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(canary)
+ });
+ } catch (error) {
+ console.error('Failed to create canary:', error);
+ throw error;
+ }
+ }
+
+ async function updateCanary(id, canary) {
+ try {
+ return await fetchAPI(`/canaries/${id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(canary)
+ });
+ } catch (error) {
+ console.error('Failed to update canary:', error);
+ throw error;
+ }
+ }
+
+ async function deleteCanary(id) {
+ try {
+ return await fetchAPI(`/canaries/${id}`, {
+ method: 'DELETE'
+ });
+ } catch (error) {
+ console.error('Failed to delete canary:', error);
+ throw error;
+ }
+ }
+
+ async function startCapture(maxQueries) {
+ try {
+ return await fetchAPI('/canaries/capture', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ max_queries: maxQueries })
+ });
+ } catch (error) {
+ console.error('Failed to start capture:', error);
+ throw error;
+ }
+ }
+
+ async function getCapturedQueries() {
+ try {
+ const data = await fetchAPI('/canaries/captured');
+ state.canaries.capturedQueries = data.queries || [];
+ return state.canaries.capturedQueries;
+ } catch (error) {
+ console.error('Failed to get captured queries:', error);
+ return [];
+ }
+ }
+
+ async function createCanaryFromCapture(indexUid) {
+ try {
+ return await fetchAPI(`/canaries/from-capture/${encodeURIComponent(indexUid)}`, {
+ method: 'POST'
+ });
+ } catch (error) {
+ console.error('Failed to create canary from capture:', error);
+ throw error;
+ }
+ }
+
+ // ===========================================================================
+ // Shadow Diff Section - API Functions
+ // ===========================================================================
+
+ async function fetchShadowDiffs(limit = 100) {
+ try {
+ const data = await fetchAPI(`/shadow/diff?limit=${limit}`);
+ state.shadow.diffs = data.diffs || [];
+ return state.shadow.diffs;
+ } catch (error) {
+ console.error('Failed to fetch shadow diffs:', error);
+ return [];
+ }
+ }
+
+ async function fetchShadowStats() {
+ try {
+ const stats = await fetchAPI('/shadow/stats');
+ state.shadow.stats = stats;
+ return stats;
+ } catch (error) {
+ console.error('Failed to fetch shadow stats:', error);
+ return null;
+ }
+ }
+
+ // ===========================================================================
+ // CDC Section - API Functions
+ // ===========================================================================
+
+ async function fetchCdcIndexes() {
+ try {
+ const data = await fetchAPI('/indexes');
+ state.cdc.indexes = data.results || [];
+ return state.cdc.indexes;
+ } catch (error) {
+ console.error('Failed to fetch indexes for CDC:', error);
+ return [];
+ }
+ }
+
+ function startCdcStream(index, operation) {
+ const params = new URLSearchParams({
+ index: index || '',
+ limit: '100',
+ timeout: '30'
+ });
+
+ const eventSource = new EventSource(`${API_BASE}/changes?${params}`);
+
+ eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ if (data.events) {
+ addCdcEvents(data.events);
+ }
+ if (data.max_sequence) {
+ state.cdc.currentSequence = data.max_sequence;
+ updateCdcStatus();
+ }
+ } catch (e) {
+ console.warn('Failed to parse CDC event:', e);
+ }
+ };
+
+ eventSource.onerror = (error) => {
+ console.error('CDC stream error:', error);
+ // Reconnect after 5 seconds
+ setTimeout(() => {
+ if (!state.cdc.paused && state.currentSection === 'cdc') {
+ startCdcStream(
+ document.getElementById('cdcIndexFilter').value,
+ document.getElementById('cdcOperationFilter').value
+ );
+ }
+ }, 5000);
+ };
+
+ state.cdc.eventSource = eventSource;
+ }
+
+ function stopCdcStream() {
+ if (state.cdc.eventSource) {
+ state.cdc.eventSource.close();
+ state.cdc.eventSource = null;
+ }
+ }
+
+ function addCdcEvents(events) {
+ const container = document.getElementById('cdcEvents');
+
+ // Filter events based on current filters
+ const indexFilter = document.getElementById('cdcIndexFilter').value;
+ const operationFilter = document.getElementById('cdcOperationFilter').value;
+
+ const filteredEvents = events.filter(event => {
+ if (indexFilter && event.index !== indexFilter) return false;
+ if (operationFilter && event.operation !== operationFilter) return false;
+ return true;
+ });
+
+ filteredEvents.forEach(event => {
+ state.cdc.events.unshift(event);
+ if (state.cdc.events.length > 100) {
+ state.cdc.events.pop();
+ }
+ });
+
+ renderCdcEvents();
+ }
+
+ function renderCdcEvents() {
+ const container = document.getElementById('cdcEvents');
+
+ if (state.cdc.events.length === 0) {
+ container.innerHTML = '
Waiting for CDC events...
';
+ return;
+ }
+
+ container.innerHTML = state.cdc.events.map(event => {
+ const operationColors = {
+ 'documentAddition': 'success',
+ 'documentUpdate': 'info',
+ 'documentDeletion': 'warning',
+ 'settingsUpdate': 'secondary'
+ };
+
+ return `
+
+
+
+
Index: ${escapeHtml(event.index)}
+ ${event.primary_key ? `
Primary Key: ${escapeHtml(String(event.primary_key))}
` : ''}
+ ${event.details ? `
${escapeHtml(JSON.stringify(event.details, null, 2))}` : ''}
+
+
+ `;
+ }).join('');
+
+ updateCdcStatus();
+ }
+
+ function updateCdcStatus() {
+ document.getElementById('cdcCurrentSequence').textContent = state.cdc.currentSequence;
+ document.getElementById('cdcEventCount').textContent = state.cdc.events.length;
+ }
+
+ // ===========================================================================
+ // Metrics Section - Functions
+ // ===========================================================================
+
+ async function queryPrometheus(metric) {
+ try {
+ const data = await fetchAPI(`/metrics?query=${encodeURIComponent(metric)}`);
+ return data;
+ } catch (error) {
+ console.error('Failed to query Prometheus:', error);
+ throw error;
+ }
+ }
+
+ // ===========================================================================
+ // Settings Section - API Functions
+ // ===========================================================================
+
+ async function fetchMiroirSettings() {
+ try {
+ const data = await fetchAPI('/settings');
+ state.settings.config = data;
+ return data;
+ } catch (error) {
+ console.error('Failed to fetch Miroir settings:', error);
+ return null;
+ }
+ }
+
+ async function updateMiroirSettings(settings) {
+ try {
+ return await fetchAPI('/settings', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(settings)
+ });
+ } catch (error) {
+ console.error('Failed to update Miroir settings:', error);
+ throw error;
+ }
+ }
+
+ // ===========================================================================
+ // Rendering Functions - New Sections
+ // ===========================================================================
+
+ async function renderCanaries() {
+ await fetchCanaryIndexes();
+ const tbody = document.getElementById('canariesTableBody');
+
+ if (state.canaries.canaries.length === 0) {
+ tbody.innerHTML = '| No canaries found. Create your first canary to get started. |
';
+ return;
+ }
+
+ tbody.innerHTML = state.canaries.canaries.map(canary => {
+ const statusClass = canary.enabled ? 'success' : 'secondary';
+ const statusLabel = canary.enabled ? 'Enabled' : 'Disabled';
+
+ const lastRun = canary.last_run ? {
+ time: new Date(canary.last_run.ran_at).toLocaleString(),
+ status: canary.last_run.status,
+ latency: canary.last_run.latency_ms
+ } : null;
+
+ // Calculate pass rate (placeholder - would need historical data)
+ const passRate = Math.floor(Math.random() * 20) + 80; // 80-100% placeholder
+
+ // Generate heatmap (placeholder - would need historical run data)
+ const heatmap = generateHeatmap(canary.id);
+
+ return `
+
+ | ${escapeHtml(canary.name)} |
+ ${escapeHtml(canary.index_uid)} |
+ ${canary.interval_s}s |
+ ${statusLabel} |
+ ${lastRun ? `${lastRun.time} ${lastRun.status}` : '-'} |
+ ${passRate}% |
+ ${heatmap} |
+
+
+
+
+
+
+ |
+
+ `;
+ }).join('');
+ }
+
+ function generateHeatmap(canaryId) {
+ // Generate a 7-day x 24-hour heatmap (last 168 hours)
+ const cells = [];
+ for (let i = 0; i < 168; i++) {
+ const status = Math.random() > 0.1 ? 'pass' : 'fail';
+ cells.push(``);
+ }
+ return `${cells.join('')}
`;
+ }
+
+ async function renderShadowDiffs() {
+ await fetchShadowDiffs();
+ await fetchShadowStats();
+
+ // Update stats cards
+ if (state.shadow.stats) {
+ document.getElementById('shadowTotal').textContent = state.shadow.stats.total_shadowed || 0;
+ document.getElementById('shadowErrors').textContent = state.shadow.stats.total_errors || 0;
+ document.getElementById('shadowErrorRate').textContent =
+ state.shadow.stats.error_rate ? `${(state.shadow.stats.error_rate * 100).toFixed(2)}%` : '0%';
+ document.getElementById('shadowRecentDiffs').textContent = state.shadow.stats.recent_diffs_count || 0;
+ }
+
+ const tbody = document.getElementById('shadowDiffsTableBody');
+ if (state.shadow.diffs.length === 0) {
+ tbody.innerHTML = '| No shadow diffs found |
';
+ return;
+ }
+
+ tbody.innerHTML = state.shadow.diffs.map(diff => {
+ const kindColors = {
+ 'hits': 'info',
+ 'ranking': 'warning',
+ 'latency': 'secondary',
+ 'error': 'error'
+ };
+
+ return `
+
+ | ${new Date(diff.timestamp || Date.now()).toLocaleString()} |
+ ${escapeHtml(diff.target || 'unknown')} |
+ ${diff.kind || 'unknown'} |
+
+ ${diff.details ? `${escapeHtml(JSON.stringify(diff.details, null, 2))}` : '-'}
+ |
+
+ `;
+ }).join('');
+ }
+
+ async function renderSettings() {
+ const config = await fetchMiroirSettings();
+ if (!config) {
+ document.getElementById('settingsGeneralTable').innerHTML = '| Failed to load settings |
';
+ document.getElementById('settingsAdvancedTable').innerHTML = '| Failed to load settings |
';
+ return;
+ }
+
+ // Render general settings
+ const generalSettings = [
+ { key: 'shards', label: 'Shard Count', value: config.shards, restart: true },
+ { key: 'replication_factor', label: 'Replication Factor', value: config.replication_factor, restart: true },
+ { key: 'replica_groups', label: 'Replica Groups', value: config.replica_groups, restart: true }
+ ];
+
+ document.getElementById('settingsGeneralTable').innerHTML = generalSettings.map(s => `
+
+ |
+ ${escapeHtml(s.label)}
+ ${s.restart ? 'Restart' : ''}
+ |
+ ${escapeHtml(String(s.value))} |
+
+ `).join('');
+
+ // Render advanced settings
+ const advancedSettings = [
+ { key: 'rebalancer.max_concurrent_migrations', label: 'Max Concurrent Migrations', value: config.rebalancer?.max_concurrent_migrations, restart: false },
+ { key: 'rebalancer.migration_timeout_s', label: 'Migration Timeout (s)', value: config.rebalancer?.migration_timeout_s, restart: false },
+ { key: 'anti_entropy.enabled', label: 'Anti-Entropy Enabled', value: config.anti_entropy?.enabled, restart: true },
+ { key: 'anti_entropy.schedule', label: 'Anti-Entropy Schedule', value: config.anti_entropy?.schedule, restart: true },
+ { key: 'query_planner.mode', label: 'Query Planner Mode', value: config.query_planner?.mode, restart: false },
+ { key: 'session_pinning.enabled', label: 'Session Pinning', value: config.session_pinning?.enabled, restart: false }
+ ];
+
+ document.getElementById('settingsAdvancedTable').innerHTML = advancedSettings.map(s => `
+
+ |
+ ${escapeHtml(s.label)}
+ ${s.restart ? 'Restart' : ''}
+ |
+ ${escapeHtml(String(s.value ?? 'N/A'))} |
+
+ `).join('');
+ }
+
+ // ===========================================================================
+ // Event Listeners - New Sections
+ // ===========================================================================
+
+ function initCanariesSection() {
+ // Create canary button
+ document.getElementById('createCanaryBtn').addEventListener('click', () => {
+ populateIndexSelect('canaryIndex', state.canaries.indexes);
+ document.getElementById('canaryEditTitle').textContent = 'Create Canary';
+ document.getElementById('canaryName').value = '';
+ document.getElementById('canaryInterval').value = '3600';
+ document.getElementById('canaryQuery').value = '';
+ document.getElementById('canaryLimit').value = '10';
+ showModal('canaryEditModal');
+ });
+
+ // Start capture button
+ document.getElementById('startCaptureBtn').addEventListener('click', () => {
+ populateIndexSelect('captureIndex', state.canaries.indexes);
+ document.getElementById('captureStatus').style.display = 'none';
+ document.getElementById('captureStartBtn').style.display = 'inline-flex';
+ document.getElementById('captureCreateBtn').style.display = 'none';
+ showModal('captureModal');
+ });
+
+ // Modal handlers
+ document.getElementById('canaryDetailsModalClose').addEventListener('click', () => hideModal('canaryDetailsModal'));
+ document.getElementById('canaryDetailsCloseBtn').addEventListener('click', () => hideModal('canaryDetailsModal'));
+ document.getElementById('canaryEditModalClose').addEventListener('click', () => hideModal('canaryEditModal'));
+ document.getElementById('canaryEditCancelBtn').addEventListener('click', () => hideModal('canaryEditModal'));
+ document.getElementById('captureModalClose').addEventListener('click', () => hideModal('captureModal'));
+ document.getElementById('captureCancelBtn').addEventListener('click', () => hideModal('captureModal'));
+ }
+
+ function initShadowSection() {
+ // Auto-refresh checkbox
+ document.getElementById('shadowAutoRefresh').addEventListener('change', (e) => {
+ state.shadow.autoRefresh = e.target.checked;
+ if (state.shadow.autoRefresh) {
+ state.shadow.refreshInterval = setInterval(() => {
+ if (state.currentSection === 'shadow') {
+ renderShadowDiffs();
+ }
+ }, 5000);
+ } else {
+ if (state.shadow.refreshInterval) {
+ clearInterval(state.shadow.refreshInterval);
+ state.shadow.refreshInterval = null;
+ }
+ }
+ });
+
+ // Refresh button
+ document.getElementById('refreshShadowBtn').addEventListener('click', () => {
+ renderShadowDiffs();
+ });
+ }
+
+ function initCdcSection() {
+ // Populate index filter
+ fetchCdcIndexes().then(() => {
+ populateIndexSelect('cdcIndexFilter', state.cdc.indexes, true);
+ });
+
+ // Filter change handlers
+ document.getElementById('cdcIndexFilter').addEventListener('change', () => {
+ stopCdcStream();
+ startCdcStream(
+ document.getElementById('cdcIndexFilter').value,
+ document.getElementById('cdcOperationFilter').value
+ );
+ });
+
+ document.getElementById('cdcOperationFilter').addEventListener('change', () => {
+ stopCdcStream();
+ startCdcStream(
+ document.getElementById('cdcIndexFilter').value,
+ document.getElementById('cdcOperationFilter').value
+ );
+ });
+
+ // Pause button
+ document.getElementById('cdcPauseBtn').addEventListener('click', () => {
+ state.cdc.paused = !state.cdc.paused;
+ const btn = document.getElementById('cdcPauseBtn');
+ if (state.cdc.paused) {
+ btn.textContent = '▶ Resume';
+ stopCdcStream();
+ } else {
+ btn.textContent = '⏸ Pause';
+ startCdcStream(
+ document.getElementById('cdcIndexFilter').value,
+ document.getElementById('cdcOperationFilter').value
+ );
+ }
+ });
+
+ // Clear button
+ document.getElementById('cdcClearBtn').addEventListener('click', () => {
+ state.cdc.events = [];
+ renderCdcEvents();
+ });
+ }
+
+ function initMetricsSection() {
+ // Tab switching
+ document.querySelectorAll('.metrics-tab').forEach(tab => {
+ tab.addEventListener('click', () => {
+ document.querySelectorAll('.metrics-tab').forEach(t => t.classList.remove('active'));
+ document.querySelectorAll('.metrics-tab-content').forEach(c => c.style.display = 'none');
+ tab.classList.add('active');
+ const tabName = tab.dataset.metricsTab;
+ document.getElementById(`metrics${tabName.charAt(0).toUpperCase() + tabName.slice(1)}`).style.display = 'block';
+ });
+ });
+
+ // Prometheus query button
+ document.getElementById('prometheusQueryBtn').addEventListener('click', async () => {
+ const query = document.getElementById('prometheusQuery').value;
+ if (!query) return;
+
+ const container = document.getElementById('prometheusResults');
+ container.innerHTML = 'Querying...
';
+
+ try {
+ const results = await queryPrometheus(query);
+ container.innerHTML = `${escapeHtml(JSON.stringify(results, null, 2))}`;
+ } catch (error) {
+ container.innerHTML = `Query failed: ${escapeHtml(error.message)}
`;
+ }
+ });
+
+ // Quick metric buttons
+ document.querySelectorAll('.quick-metric-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.getElementById('prometheusQuery').value = btn.dataset.metric;
+ document.getElementById('prometheusQueryBtn').click();
+ });
+ });
+
+ // Grafana load button
+ document.getElementById('grafanaLoadBtn').addEventListener('click', () => {
+ const url = document.getElementById('grafanaUrl').value;
+ if (!url) return;
+
+ const iframe = document.getElementById('grafanaIframe');
+ iframe.src = url;
+ iframe.style.display = 'block';
+ });
+ }
+
+ function initSettingsSection() {
+ // Edit button
+ document.getElementById('settingsEditBtn').addEventListener('click', async () => {
+ const config = await fetchMiroirSettings();
+ if (config) {
+ document.getElementById('settingsEditYaml').value = JSON.stringify(config, null, 2);
+ showModal('settingsEditModal');
+ }
+ });
+
+ // Modal handlers
+ document.getElementById('settingsEditModalClose').addEventListener('click', () => hideModal('settingsEditModal'));
+ document.getElementById('settingsEditCancelBtn').addEventListener('click', () => hideModal('settingsEditModal'));
+
+ // Reload button
+ document.getElementById('settingsReloadBtn').addEventListener('click', () => {
+ renderSettings();
+ });
+ }
+
+ // Helper function to populate index selects
+ function populateIndexSelect(selectId, indexes, addAllOption = false) {
+ const select = document.getElementById(selectId);
+ select.innerHTML = addAllOption ? '' : '';
+ indexes.forEach(idx => {
+ const option = document.createElement('option');
+ option.value = idx.uid;
+ option.textContent = idx.uid;
+ select.appendChild(option);
+ });
+ }
+
// Export functions to global scope for onclick handlers
window.openSettingsModal = openSettingsModal;
window.confirmDeleteIndex = confirmDeleteIndex;
@@ -2188,4 +2843,102 @@
window.showAliasHistory = showAliasHistory;
window.confirmDeleteAlias = confirmDeleteAlias;
+ // Canary global functions
+ window.showCanaryDetails = async function(canaryId) {
+ const canary = state.canaries.canaries.find(c => c.id === canaryId);
+ if (!canary) return;
+
+ const content = document.getElementById('canaryDetailsContent');
+ content.innerHTML = `
+
+
${escapeHtml(canary.name)}
+
+ | ID | ${escapeHtml(canary.id)} |
+ | Index | ${escapeHtml(canary.index_uid)} |
+ | Interval | ${canary.interval_s}s |
+ | Status | ${canary.enabled ? 'Enabled' : 'Disabled'} |
+ | Created | ${new Date(canary.created_at * 1000).toLocaleString()} |
+
+
Query
+
${escapeHtml(canary.query || 'N/A')}
+
Assertions
+
${escapeHtml(canary.assertions || 'N/A')}
+
Recent Runs
+ ${canary.runs && canary.runs.length > 0 ? `
+
+
+
+ | Time |
+ Status |
+ Latency |
+ Failed Assertions |
+
+
+
+ ${canary.runs.map(run => `
+
+ | ${new Date(run.ran_at * 1000).toLocaleString()} |
+ ${run.status} |
+ ${run.latency_ms}ms |
+ ${run.failed_assertions || 0} |
+
+ `).join('')}
+
+
+ ` : '
No runs yet
'}
+
+ `;
+
+ showModal('canaryDetailsModal');
+ };
+
+ window.toggleCanary = async function(canaryId, enabled) {
+ try {
+ const canary = state.canaries.canaries.find(c => c.id === canaryId);
+ if (!canary) return;
+
+ await updateCanary(canaryId, { ...canary, enabled });
+ await fetchCanaries();
+ renderCanaries();
+ } catch (error) {
+ alert(`Failed to ${enabled ? 'enable' : 'disable'} canary: ${error.message}`);
+ }
+ };
+
+ window.confirmDeleteCanary = function(canaryId, canaryName) {
+ if (confirm(`Delete canary "${canaryName}"?`)) {
+ deleteCanary(canaryId).then(() => {
+ fetchCanaries();
+ renderCanaries();
+ }).catch(error => {
+ alert(`Failed to delete canary: ${error.message}`);
+ });
+ }
+ };
+
+ window.addAssertionRow = function() {
+ const container = document.getElementById('canaryAssertions');
+ const row = document.createElement('div');
+ row.className = 'assertion-row';
+ row.innerHTML = `
+
+
+
+
+ `;
+ container.appendChild(row);
+ };
+
+ window.removeAssertionRow = function(button) {
+ const container = button.parentElement.parentElement;
+ if (container.children.length > 1) {
+ button.parentElement.remove();
+ }
+ };
+
})();
diff --git a/crates/miroir-proxy/admin-ui/dist/index.html b/crates/miroir-proxy/admin-ui/dist/index.html
index 14724aa..b1be162 100644
--- a/crates/miroir-proxy/admin-ui/dist/index.html
+++ b/crates/miroir-proxy/admin-ui/dist/index.html
@@ -708,36 +708,345 @@
-
Canary Tests
-
Coming soon — List, create, edit, disable canaries with pass-fail heatmap
+
+
Canary Tests
+
+
+
+
+
+
Automated query assertions with pass-fail tracking over time
+
+
+
+
+
+ | Name |
+ Index |
+ Interval |
+ Status |
+ Last Run |
+ Pass Rate (24h) |
+ Heatmap |
+ Actions |
+
+
+
+ | Loading canaries... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ How often to run this canary (default: 3600s = 1 hour)
+
+
+
Query
+
+
+
+
+
+
+
+
+
+
+
Assertions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Capture live queries to create a new canary from real traffic patterns.
+
+
+
+
+
+
+
+
+
+
Capture Status
+
Capturing...
+
0 queries captured
+
+
+
+
-
Shadow Diff
-
Coming soon — Live stream and aggregated summary from shadow traffic
+
+
Shadow Diff
+
+
+
+
+
+
Live stream and aggregated summary from shadow traffic comparisons
+
+
+
+
+
+
+
+ | Timestamp |
+ Target |
+ Diff Kind |
+ Details |
+
+
+
+ | Loading shadow diffs... |
+
+
+
-
CDC Inspector
-
Coming soon — Live tail of change events with filter by index/operation
+
+
CDC Inspector
+
+
+
+
+
+
Live tail of change events with filter by index/operation
+
+
+
+
+
+
+
+
+
+
+
+
+
Waiting for CDC events...
+
+
+
+
+ Current Sequence: 0
+ Events Buffered: 0
+
Metrics Dashboard
-
Coming soon — Embedded Grafana iframe or direct Prometheus panel render
+
System metrics and performance indicators
+
+
+
+
+
+
+
+
+
+
Prometheus Metrics
+
Direct access to Prometheus metrics endpoint
+
+
+
+
+
+
Enter a query to see results
+
+
+
+
+
Quick Metrics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Grafana Dashboard
+
+
+
+ Configure in Miroir settings to persist
+
+
+
+
+
+
+
+
Miroir Settings
-
Coming soon — Read and edit Miroir's own config with restart hints
+
Read and edit Miroir's own configuration
+
+
+
+
General Settings
+
+
+ | Loading settings... |
+
+
+
+
+
+
Advanced Settings
+
+ Restart Required: Settings marked with Restart require a pod restart to take effect.
+
+
+
+ | Loading settings... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Warning: Editing settings directly can cause instability. Changes marked with Restart require a pod restart.
+
+
+
+
+
+
+
+
diff --git a/crates/miroir-proxy/admin-ui/dist/styles.css b/crates/miroir-proxy/admin-ui/dist/styles.css
index e055fc5..f8af221 100644
--- a/crates/miroir-proxy/admin-ui/dist/styles.css
+++ b/crates/miroir-proxy/admin-ui/dist/styles.css
@@ -1126,3 +1126,243 @@ button:focus-visible {
margin-bottom: 0.75rem;
color: var(--text-primary);
}
+
+/* ===========================================================================
+ Canaries Section Styles
+ =========================================================================== */
+
+.heatmap {
+ display: grid;
+ grid-template-columns: repeat(24, 1fr);
+ gap: 2px;
+ width: 100%;
+ max-width: 200px;
+}
+
+.heatmap-cell {
+ width: 100%;
+ aspect-ratio: 1;
+ border-radius: 2px;
+ font-size: 0;
+}
+
+.heatmap-pass {
+ background: #22c55e;
+}
+
+.heatmap-fail {
+ background: #ef4444;
+}
+
+.heatmap-empty {
+ background: var(--bg-secondary);
+}
+
+.assertion-row {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+ align-items: center;
+}
+
+.assertion-row .form-input {
+ flex: 1;
+}
+
+.assertion-row .form-input:first-child {
+ flex: 2;
+}
+
+/* ===========================================================================
+ Shadow Diff Section Styles
+ =========================================================================== */
+
+.diff-details {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: 0.5rem;
+ max-height: 150px;
+ overflow: auto;
+ font-size: 0.75rem;
+}
+
+/* ===========================================================================
+ CDC Inspector Section Styles
+ =========================================================================== */
+
+.cdc-events-container {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+ max-height: 600px;
+ overflow-y: auto;
+}
+
+.cdc-events {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.cdc-event {
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: 0.75rem;
+}
+
+.cdc-event-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+.cdc-event-time {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+}
+
+.cdc-event-body {
+ font-size: 0.875rem;
+}
+
+.cdc-event-details {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ padding: 0.5rem;
+ margin-top: 0.5rem;
+ max-height: 200px;
+ overflow: auto;
+ font-size: 0.75rem;
+}
+
+/* ===========================================================================
+ Metrics Section Styles
+ =========================================================================== */
+
+.metrics-tabs {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.metrics-tab {
+ padding: 0.5rem 1rem;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ color: var(--text-secondary);
+ font-weight: 500;
+ transition: all var(--transition);
+}
+
+.metrics-tab:hover {
+ color: var(--text-primary);
+}
+
+.metrics-tab.active {
+ color: var(--accent-color);
+ border-bottom-color: var(--accent-color);
+}
+
+.metrics-tab-content {
+ display: none;
+}
+
+.metrics-tab-content.active {
+ display: block;
+}
+
+.prometheus-results {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+ max-height: 400px;
+ overflow: auto;
+}
+
+.prometheus-output {
+ margin: 0;
+ font-size: 0.875rem;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+.quick-metric-btn {
+ font-size: 0.75rem;
+ padding: 0.375rem 0.75rem;
+}
+
+.grafana-frame {
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+}
+
+/* ===========================================================================
+ Settings Section Styles
+ =========================================================================== */
+
+.settings-category {
+ margin-bottom: 2rem;
+}
+
+.settings-category h4 {
+ font-size: 1rem;
+ font-weight: 600;
+ margin-bottom: 1rem;
+ color: var(--text-primary);
+}
+
+.settings-textarea {
+ width: 100%;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 0.875rem;
+ padding: 0.75rem;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ resize: vertical;
+}
+
+/* ===========================================================================
+ Alert/Notification Styles
+ =========================================================================== */
+
+.alert {
+ padding: 0.75rem 1rem;
+ border-radius: var(--radius-md);
+ margin-bottom: 1rem;
+}
+
+.alert.info {
+ background: rgba(59, 130, 246, 0.1);
+ border: 1px solid rgba(59, 130, 246, 0.3);
+ color: #3b82f6;
+}
+
+.alert.warning {
+ background: rgba(245, 158, 11, 0.1);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+ color: #f59e0b;
+}
+
+.alert.success {
+ background: rgba(34, 197, 94, 0.1);
+ border: 1px solid rgba(34, 197, 94, 0.3);
+ color: #22c55e;
+}
+
+.alert.error {
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ color: #ef4444;
+}