diff --git a/crates/miroir-proxy/admin-ui/dist/app.js b/crates/miroir-proxy/admin-ui/dist/app.js
index 7839c2a..9a4f301 100644
--- a/crates/miroir-proxy/admin-ui/dist/app.js
+++ b/crates/miroir-proxy/admin-ui/dist/app.js
@@ -20,7 +20,13 @@
indexes: null,
aliases: null,
currentSettingsIndex: null,
- currentAliasForHistory: null
+ currentAliasForHistory: null,
+ // Documents section state
+ documents: { indexes: [], currentIndex: null, documents: [], limit: 50, offset: 0, filters: [], totalHits: 0 },
+ // 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 }
};
// ============================================================================
@@ -177,6 +183,792 @@
}
}
+ // ===========================================================================
+ // Documents Section
+ // ===========================================================================
+
+ async function fetchDocumentsIndexes() {
+ try {
+ const data = await fetchAPI('/indexes');
+ state.documents.indexes = data.results || [];
+ return state.documents.indexes;
+ } catch (error) {
+ console.error('Failed to fetch indexes for documents:', error);
+ return [];
+ }
+ }
+
+ async function fetchDocuments(indexUid, limit, offset, filters) {
+ try {
+ const filter = filters.length > 0 ? buildFilterString(filters) : undefined;
+ const params = new URLSearchParams({
+ limit: limit.toString(),
+ offset: offset.toString()
+ });
+ if (filter) params.set('filter', filter);
+
+ const data = await fetchAPI(`/indexes/${encodeURIComponent(indexUid)}/documents?${params}`);
+ return {
+ results: data.results || [],
+ total: data.total || 0,
+ limit: data.limit || limit,
+ offset: data.offset || offset
+ };
+ } catch (error) {
+ console.error(`Failed to fetch documents for ${indexUid}:`, error);
+ return { results: [], total: 0, limit, offset };
+ }
+ }
+
+ async function fetchDocumentFields(indexUid) {
+ try {
+ const settings = await fetchAPI(`/indexes/${encodeURIComponent(indexUid)}/settings`);
+ return {
+ filterable: settings.filterableAttributes || [],
+ sortable: settings.sortableAttributes || [],
+ displayed: settings.displayedAttributes || ['*']
+ };
+ } catch (error) {
+ console.error(`Failed to fetch settings for ${indexUid}:`, error);
+ return { filterable: [], sortable: [], displayed: ['*'] };
+ }
+ }
+
+ async function importDocuments(indexUid, formData, onProgress) {
+ const streaming = document.getElementById('importMethod').value === 'stream';
+ const endpoint = streaming
+ ? `/_miroir/indexes/${encodeURIComponent(indexUid)}/documents/stream`
+ : `/indexes/${encodeURIComponent(indexUid)}/documents`;
+
+ try {
+ const response = await fetch(`${API_BASE}${endpoint}`, {
+ method: 'POST',
+ headers: {
+ 'Accept': 'application/json'
+ },
+ body: formData
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ // For streaming, we need to handle the response differently
+ if (streaming) {
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+
+ for (const line of lines) {
+ if (line.trim()) {
+ try {
+ const event = JSON.parse(line);
+ if (event.type === 'progress' && onProgress) {
+ onProgress(event);
+ }
+ } catch (e) {
+ console.warn('Failed to parse SSE line:', line);
+ }
+ }
+ }
+ }
+ return { success: true };
+ } else {
+ return await response.json();
+ }
+ } catch (error) {
+ console.error('Failed to import documents:', error);
+ throw error;
+ }
+ }
+
+ // ===========================================================================
+ // Query Sandbox Section
+ // ===========================================================================
+
+ async function fetchQueryIndexes() {
+ try {
+ const data = await fetchAPI('/indexes');
+ state.query.indexes = data.results || [];
+ return state.query.indexes;
+ } catch (error) {
+ console.error('Failed to fetch indexes for query:', error);
+ return [];
+ }
+ }
+
+ async function runQuery(indexUid, query) {
+ try {
+ const response = await fetch(`${API_BASE}/indexes/${encodeURIComponent(indexUid)}/search`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json'
+ },
+ body: JSON.stringify(query)
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ return await response.json();
+ } catch (error) {
+ console.error('Failed to run query:', error);
+ throw error;
+ }
+ }
+
+ async function explainQuery(indexUid, query) {
+ try {
+ const response = await fetch(`${API_BASE}/indexes/${encodeURIComponent(indexUid)}/explain`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json'
+ },
+ body: JSON.stringify(query)
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ return await response.json();
+ } catch (error) {
+ console.error('Failed to explain query:', error);
+ throw error;
+ }
+ }
+
+ async function runShadowDiff(indexUid, query) {
+ try {
+ const response = await fetch(`${API_BASE}/_miroir/shadow/query`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json'
+ },
+ body: JSON.stringify({ indexUid, query })
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ return await response.json();
+ } catch (error) {
+ console.error('Failed to run shadow diff:', error);
+ throw error;
+ }
+ }
+
+ // ===========================================================================
+ // Tasks Section
+ // ===========================================================================
+
+ async function fetchTasksIndexes() {
+ try {
+ const data = await fetchAPI('/indexes');
+ state.tasks.indexes = data.results || [];
+ return state.tasks.indexes;
+ } catch (error) {
+ console.error('Failed to fetch indexes for tasks:', error);
+ return [];
+ }
+ }
+
+ async function fetchTasks() {
+ try {
+ const params = new URLSearchParams({
+ limit: state.tasks.limit.toString(),
+ from: state.tasks.offset.toString()
+ });
+
+ // Add filters
+ if (state.tasks.filter !== 'all') {
+ const statuses = {
+ 'active': ['enqueued', 'processing'],
+ 'pending': ['enqueued'],
+ 'succeeded': ['succeeded'],
+ 'failed': ['failed']
+ };
+ if (statuses[state.tasks.filter]) {
+ params.set('statuses', statuses[state.tasks.filter].join(','));
+ }
+ }
+
+ if (state.tasks.indexUid) {
+ params.set('indexUids', state.tasks.indexUid);
+ }
+
+ if (state.tasks.type) {
+ const types = {
+ 'documentAddition': 'documentAddition',
+ 'documentUpdate': 'documentUpdate',
+ 'documentDeletion': 'documentDeletion',
+ 'settingsUpdate': 'settingsUpdate',
+ 'indexCreation': 'indexCreation',
+ 'indexDeletion': 'indexDeletion',
+ 'indexUpdate': 'indexUpdate',
+ 'indexSwap': 'indexSwap'
+ };
+ if (types[state.tasks.type]) {
+ params.set('types', types[state.tasks.type]);
+ }
+ }
+
+ const data = await fetchAPI(`/tasks?${params}`);
+ state.tasks.tasks = data.results || [];
+ state.tasks.total = data.total || 0;
+ return state.tasks;
+ } catch (error) {
+ console.error('Failed to fetch tasks:', error);
+ return { tasks: [], total: 0 };
+ }
+ }
+
+ async function fetchTaskDetails(taskUid) {
+ try {
+ return await fetchAPI(`/tasks/${taskUid}`);
+ } catch (error) {
+ console.error(`Failed to fetch task ${taskUid}:`, error);
+ return null;
+ }
+ }
+
+ async function cancelTask(taskUid) {
+ try {
+ return await fetchAPI(`/tasks/${taskUid}/cancel`, {
+ method: 'POST'
+ });
+ } catch (error) {
+ console.error(`Failed to cancel task ${taskUid}:`, error);
+ throw error;
+ }
+ }
+
+ async function deleteTask(taskUid) {
+ try {
+ return await fetchAPI(`/tasks/${taskUid}`, {
+ method: 'DELETE'
+ });
+ } catch (error) {
+ console.error(`Failed to delete task ${taskUid}:`, error);
+ throw error;
+ }
+ }
+
+ // ===========================================================================
+ // Utility Functions
+ // ===========================================================================
+
+ function buildFilterString(filters) {
+ if (!filters || filters.length === 0) return '';
+
+ const parts = filters.map(f => {
+ const { field, operator, value } = f;
+ switch (operator) {
+ case 'equals': return `${field} = "${value}"`;
+ case 'notEquals': return `${field} != "${value}"`;
+ case 'gt': return `${field} > "${value}"`;
+ case 'gte': return `${field} >= "${value}"`;
+ case 'lt': return `${field} < "${value}"`;
+ case 'lte': return `${field} <= "${value}"`;
+ case 'in': return `${field} IN [${value.split(',').map(v => `"${v.trim()}"`).join(', ')}]`;
+ case 'exists': return `${field} EXISTS`;
+ default: return '';
+ }
+ }).filter(p => p);
+
+ return parts.join(' AND ');
+ }
+
+ function formatDuration(ms) {
+ if (!ms) return '-';
+ const seconds = Math.floor(ms / 1000);
+ if (seconds < 60) return `${seconds}s`;
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds % 60;
+ return `${minutes}m ${remainingSeconds}s`;
+ }
+
+ function formatDate(timestamp) {
+ if (!timestamp) return '-';
+ return new Date(timestamp).toISOString().replace('T', ' ').substring(0, 19);
+ }
+
+ function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ function extractReplicaGroup(node) {
+ // Try to extract from address or use a default
+ const match = node.address.match(/(\d+)$/);
+ return match ? parseInt(match[1]) : 0;
+ }
+
+ function formatLastSeen(ms) {
+ if (!ms) return 'Unknown';
+ const seconds = Math.floor(ms / 1000);
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ return `${hours}h ago`;
+ }
+
+ function showModal(modalId) {
+ document.getElementById(modalId).classList.add('active');
+ }
+
+ function hideModal(modalId) {
+ document.getElementById(modalId).classList.remove('active');
+ }
+
+ // ===========================================================================
+ // Rendering - Documents Section
+ // ===========================================================================
+
+ function renderDocuments() {
+ const select = document.getElementById('documentIndexSelect');
+ select.innerHTML = '';
+ state.documents.indexes.forEach(idx => {
+ const option = document.createElement('option');
+ option.value = idx.uid;
+ option.textContent = idx.uid;
+ if (state.documents.currentIndex === idx.uid) {
+ option.selected = true;
+ }
+ select.appendChild(option);
+ });
+
+ if (state.documents.currentIndex && state.documents.documents.length > 0) {
+ renderDocumentsTable();
+ }
+ }
+
+ async function loadDocuments() {
+ if (!state.documents.currentIndex) return;
+
+ const data = await fetchDocuments(
+ state.documents.currentIndex,
+ state.documents.limit,
+ state.documents.offset,
+ state.documents.filters
+ );
+
+ state.documents.documents = data.results;
+ state.documents.totalHits = data.total;
+
+ renderDocumentsTable();
+ renderDocumentsPagination();
+ }
+
+ async function renderDocumentsTable() {
+ const thead = document.getElementById('documentsTableHead');
+ const tbody = document.getElementById('documentsTableBody');
+
+ if (!state.documents.documents || state.documents.documents.length === 0) {
+ thead.innerHTML = '
| Select an index to browse documents |
';
+ tbody.innerHTML = '| Select an index to browse documents |
';
+ return;
+ }
+
+ // Get columns from first document
+ const firstDoc = state.documents.documents[0];
+ const columns = Object.keys(firstDoc);
+
+ thead.innerHTML = '' + columns.map(col => `| ${escapeHtml(col)} | `).join('') + '
';
+
+ tbody.innerHTML = state.documents.documents.map(doc => {
+ return '' + columns.map(col => {
+ const value = doc[col];
+ if (value === null || value === undefined) return '| null | ';
+ if (typeof value === 'object') return `${escapeHtml(JSON.stringify(value, null, 2))} | `;
+ return `${escapeHtml(String(value))} | `;
+ }).join('') + '
';
+ }).join('');
+
+ document.getElementById('documentCount').textContent =
+ `${state.documents.totalHits} total documents`;
+ }
+
+ function renderDocumentsPagination() {
+ const pagination = document.getElementById('documentPagination');
+ const prevBtn = document.getElementById('documentPrevPage');
+ const nextBtn = document.getElementById('documentNextPage');
+ const pageInfo = document.getElementById('documentPageInfo');
+
+ if (state.documents.totalHits === 0) {
+ pagination.style.display = 'none';
+ return;
+ }
+
+ pagination.style.display = 'flex';
+
+ const from = state.documents.offset + 1;
+ const to = Math.min(state.documents.offset + state.documents.limit, state.documents.totalHits);
+
+ pageInfo.textContent = `Showing ${from}-${to} of ${state.documents.totalHits}`;
+
+ prevBtn.disabled = state.documents.offset === 0;
+ nextBtn.disabled = to >= state.documents.totalHits;
+ }
+
+ // ===========================================================================
+ // Rendering - Query Sandbox Section
+ // ===========================================================================
+
+ function renderQuerySandbox() {
+ const select = document.getElementById('queryIndexSelect');
+ select.innerHTML = '';
+ state.query.indexes.forEach(idx => {
+ const option = document.createElement('option');
+ option.value = idx.uid;
+ option.textContent = idx.uid;
+ if (state.query.currentIndex === idx.uid) {
+ option.selected = true;
+ }
+ select.appendChild(option);
+ });
+
+ if (state.query.currentIndex) {
+ loadQueryFields();
+ }
+ }
+
+ async function loadQueryFields() {
+ if (!state.query.currentIndex) return;
+
+ const settings = await fetchDocumentFields(state.query.currentIndex);
+
+ // Update filter field selects
+ document.querySelectorAll('#queryFilterBuilder .filter-field').forEach(select => {
+ select.innerHTML = '' +
+ settings.filterable.map(f => ``).join('');
+ });
+
+ // Update sort field selects
+ document.querySelectorAll('#sortBuilder .sort-field').forEach(select => {
+ select.innerHTML = '' +
+ settings.sortable.map(f => ``).join('');
+ });
+
+ // Update facet field selects
+ document.querySelectorAll('#facetBuilder .facet-field').forEach(select => {
+ select.innerHTML = '' +
+ settings.filterable.map(f => ``).join('');
+ });
+ }
+
+ function renderQueryResults(results, duration) {
+ const container = document.getElementById('queryResults');
+ const stats = document.getElementById('queryStats');
+
+ if (!results || !results.hits || results.hits.length === 0) {
+ container.innerHTML = 'No results found
';
+ stats.style.display = 'none';
+ return;
+ }
+
+ const columns = Object.keys(results.hits[0]);
+
+ container.innerHTML = `
+
+
+
+ ${columns.map(col => `| ${escapeHtml(col)} | `).join('')}
+
+
+ ${results.hits.map(hit => `
+ ${columns.map(col => {
+ const value = hit[col];
+ if (value === null || value === undefined) return '| null | ';
+ if (typeof value === 'object') return `${escapeHtml(JSON.stringify(value, null, 2))} | `;
+ return `${escapeHtml(String(value))} | `;
+ }).join('')}
+ `).join('')}
+
+
+ `;
+
+ stats.style.display = 'block';
+ document.getElementById('queryHits').textContent = results.estimatedTotalHits || results.totalHits;
+ document.getElementById('queryProcessingTime').textContent = duration ? `${duration}ms` : '-';
+ document.getElementById('queryShardsQueried').textContent = results.shardsQueried || '-';
+
+ // Render latency breakdown if available
+ if (results.shardBreakdown) {
+ renderShardLatencyBreakdown(results.shardBreakdown);
+ }
+ }
+
+ function renderShardLatencyBreakdown(breakdown) {
+ const container = document.getElementById('latencyBreakdown');
+ const table = document.getElementById('shardLatencyTable');
+
+ container.style.display = 'block';
+
+ table.innerHTML = `
+
+
+
+ | Shard ID |
+ Node |
+ Latency |
+ Hits |
+
+
+
+ ${breakdown.map(b => `
+
+ | ${b.shardId} |
+ ${escapeHtml(b.node)} |
+ ${b.latencyMs}ms |
+ ${b.hits} |
+
+ `).join('')}
+
+
+ `;
+ }
+
+ function renderExplainResults(explain) {
+ const container = document.getElementById('explainResults');
+ const pre = document.getElementById('explainJson');
+
+ container.style.display = 'block';
+ pre.textContent = JSON.stringify(explain, null, 2);
+ }
+
+ function renderShadowDiffResults(diff) {
+ const container = document.getElementById('shadowDiffResults');
+ const content = document.getElementById('shadowDiffContent');
+
+ container.style.display = 'block';
+
+ if (!diff || !diff.diffs || diff.diffs.length === 0) {
+ content.innerHTML = 'No differences found between live and shadow results
';
+ return;
+ }
+
+ content.innerHTML = `
+
+
+
Differences Found
+
${diff.diffs.length}
+
+
+
Hit Differences
+
${diff.hitDiffs || 0}
+
+
+
Ranking Differences
+
${diff.rankingDiffs || 0}
+
+
+
Errors
+
${diff.errors || 0}
+
+
+
+
+
+ | Document ID |
+ Type |
+ Live |
+ Shadow |
+
+
+
+ ${diff.diffs.map(d => `
+
+ | ${escapeHtml(d.docId)} |
+ ${d.type} |
+ ${escapeHtml(JSON.stringify(d.live))} |
+ ${escapeHtml(JSON.stringify(d.shadow))} |
+
+ `).join('')}
+
+
+ `;
+ }
+
+ // ===========================================================================
+ // Rendering - Tasks Section
+ // ===========================================================================
+
+ function renderTasks() {
+ // Populate index filter
+ const indexSelect = document.getElementById('taskIndex');
+ indexSelect.innerHTML = '';
+ state.tasks.indexes.forEach(idx => {
+ const option = document.createElement('option');
+ option.value = idx.uid;
+ option.textContent = idx.uid;
+ if (state.tasks.indexUid === idx.uid) {
+ option.selected = true;
+ }
+ indexSelect.appendChild(option);
+ });
+
+ renderTasksTable();
+ renderTasksPagination();
+ }
+
+ function renderTasksTable() {
+ const tbody = document.getElementById('tasksTableBody');
+
+ if (!state.tasks.tasks || state.tasks.tasks.length === 0) {
+ tbody.innerHTML = '| No tasks found |
';
+ return;
+ }
+
+ tbody.innerHTML = state.tasks.tasks.map(task => {
+ const statusClass = {
+ 'enqueued': 'info',
+ 'processing': 'warning',
+ 'succeeded': 'success',
+ 'failed': 'error',
+ 'canceled': 'secondary'
+ }[task.status] || 'secondary';
+
+ const progress = task.details && task.details.totalDocuments
+ ? `${Math.round((task.details.loadedDocuments || 0) / task.details.totalDocuments * 100)}%`
+ : '-';
+
+ const duration = task.startedAt && task.finishedAt
+ ? formatDuration(new Date(task.finishedAt) - new Date(task.startedAt))
+ : task.startedAt ? formatDuration(Date.now() - new Date(task.startedAt)) : '-';
+
+ const actions = [];
+ if (task.status === 'enqueued' || task.status === 'processing') {
+ actions.push(``);
+ }
+ actions.push(``);
+
+ return `
+
+ | ${task.uid} |
+ ${task.type} |
+ ${task.indexUid || '-'} |
+ ${task.status} |
+ ${progress} |
+ ${task.enqueuedAt ? formatDate(task.enqueuedAt) : '-'} |
+ ${task.finishedAt ? formatDate(task.finishedAt) : '-'} |
+ ${duration} |
+ ${actions.join(' ')} |
+
+ `;
+ }).join('');
+ }
+
+ function renderTasksPagination() {
+ const prevBtn = document.getElementById('taskPrevPage');
+ const nextBtn = document.getElementById('taskNextPage');
+ const pageInfo = document.getElementById('taskPageInfo');
+
+ const from = state.tasks.offset + 1;
+ const to = Math.min(state.tasks.offset + state.tasks.limit, state.tasks.total);
+
+ pageInfo.textContent = `Showing ${from}-${to} of ${state.tasks.total} tasks`;
+
+ prevBtn.disabled = state.tasks.offset === 0;
+ nextBtn.disabled = to >= state.tasks.total;
+ }
+
+ async function showTaskDetails(taskUid) {
+ const details = await fetchTaskDetails(taskUid);
+ if (!details) {
+ alert('Failed to load task details');
+ return;
+ }
+
+ const content = document.getElementById('taskDetailsContent');
+ content.innerHTML = `
+
+
Task ${taskUid}
+
+ | UID | ${details.uid} |
+ | Type | ${details.type} |
+ | Index | ${details.indexUid || '-'} |
+ | Status | ${details.status} |
+ | Enqueued | ${details.enqueuedAt ? formatDate(details.enqueuedAt) : '-'} |
+ | Started | ${details.startedAt ? formatDate(details.startedAt) : '-'} |
+ | Finished | ${details.finishedAt ? formatDate(details.finishedAt) : '-'} |
+ | Duration | ${details.startedAt && details.finishedAt ? formatDuration(new Date(details.finishedAt) - new Date(details.startedAt)) : '-'} |
+ | Error | ${details.error ? `${escapeHtml(details.error)}` : '-'} |
+
+ ${details.details ? `
+
Details
+
${escapeHtml(JSON.stringify(details.details, null, 2))}
+ ` : ''}
+
+ `;
+
+ showModal('taskDetailsModal');
+ }
+
+ // Global function for onclick handler
+ window.cancelTaskById = async function(taskUid) {
+ if (!confirm(`Cancel task ${taskUid}?`)) return;
+
+ try {
+ await cancelTask(taskUid);
+ await fetchTasks();
+ renderTasks();
+ } catch (error) {
+ alert(`Failed to cancel task: ${error.message}`);
+ }
+ };
+
+ // Global functions for filter/sort/facet builders
+ window.addFilterRow = function(containerId) {
+ const container = document.getElementById(containerId);
+ const row = document.createElement('div');
+ row.className = 'filter-row';
+ row.innerHTML = `
+
+
+
+
+
+ `;
+ container.appendChild(row);
+ };
+
+ window.removeFilterRow = function(button) {
+ const container = button.parentElement.parentElement;
+ if (container.children.length > 1) {
+ button.parentElement.remove();
+ }
+ };
+
async function refreshData() {
// Always fetch topology (used by overview and topology sections)
await fetchTopology();
@@ -197,6 +989,16 @@
} else if (state.currentSection === 'aliases') {
await fetchAliases();
renderAliases();
+ } else if (state.currentSection === 'documents') {
+ await fetchDocumentsIndexes();
+ renderDocuments();
+ } else if (state.currentSection === 'query') {
+ await fetchQueryIndexes();
+ renderQuerySandbox();
+ } else if (state.currentSection === 'tasks') {
+ await fetchTasksIndexes();
+ await fetchTasks();
+ renderTasks();
}
}
@@ -933,6 +1735,416 @@
}
});
});
+
+ // Initialize Documents section
+ initDocumentsSection();
+
+ // Initialize Query Sandbox section
+ initQuerySandboxSection();
+
+ // Initialize Tasks section
+ initTasksSection();
+ }
+
+ function initDocumentsSection() {
+ // Index selection
+ document.getElementById('documentIndexSelect').addEventListener('change', async (e) => {
+ state.documents.currentIndex = e.target.value;
+ if (state.documents.currentIndex) {
+ await loadDocuments();
+ }
+ });
+
+ // Pagination
+ document.getElementById('documentLimit').addEventListener('change', (e) => {
+ state.documents.limit = parseInt(e.target.value);
+ if (state.documents.currentIndex) loadDocuments();
+ });
+
+ document.getElementById('documentOffset').addEventListener('change', (e) => {
+ state.documents.offset = parseInt(e.target.value);
+ if (state.documents.currentIndex) loadDocuments();
+ });
+
+ document.getElementById('documentPrevPage').addEventListener('click', () => {
+ const newOffset = Math.max(0, state.documents.offset - state.documents.limit);
+ state.documents.offset = newOffset;
+ document.getElementById('documentOffset').value = newOffset;
+ if (state.documents.currentIndex) loadDocuments();
+ });
+
+ document.getElementById('documentNextPage').addEventListener('click', () => {
+ const newOffset = state.documents.offset + state.documents.limit;
+ state.documents.offset = newOffset;
+ document.getElementById('documentOffset').value = newOffset;
+ if (state.documents.currentIndex) loadDocuments();
+ });
+
+ // Filter builder
+ document.getElementById('applyDocumentFilterBtn').addEventListener('click', async () => {
+ const filters = [];
+ document.querySelectorAll('#documentFilterBuilder .filter-row').forEach(row => {
+ const field = row.querySelector('.filter-field').value;
+ const operator = row.querySelector('.filter-operator').value;
+ const value = row.querySelector('.filter-value').value;
+ if (field && value) {
+ filters.push({ field, operator, value });
+ }
+ });
+ state.documents.filters = filters;
+ state.documents.offset = 0;
+ document.getElementById('documentOffset').value = 0;
+ if (state.documents.currentIndex) loadDocuments();
+ });
+
+ document.getElementById('clearDocumentFilterBtn').addEventListener('click', async () => {
+ state.documents.filters = [];
+ state.documents.offset = 0;
+ document.getElementById('documentOffset').value = 0;
+ // Clear filter inputs
+ document.querySelectorAll('#documentFilterBuilder .filter-row').forEach(row => {
+ row.querySelector('.filter-field').value = '';
+ row.querySelector('.filter-value').value = '';
+ });
+ if (state.documents.currentIndex) loadDocuments();
+ });
+
+ // Import documents
+ document.getElementById('importDocumentsBtn').addEventListener('click', () => showModal('importDocumentsModal'));
+ document.getElementById('importDocumentsModalClose').addEventListener('click', () => hideModal('importDocumentsModal'));
+ document.getElementById('importCancelBtn').addEventListener('click', () => hideModal('importDocumentsModal'));
+
+ // File dropzone
+ const dropzone = document.getElementById('importDropzone');
+ const fileInput = document.getElementById('importFileInput');
+
+ dropzone.addEventListener('click', () => fileInput.click());
+ dropzone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropzone.classList.add('dragover');
+ });
+ dropzone.addEventListener('dragleave', () => {
+ dropzone.classList.remove('dragover');
+ });
+ dropzone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropzone.classList.remove('dragover');
+ handleFiles(e.dataTransfer.files);
+ });
+
+ fileInput.addEventListener('change', (e) => {
+ handleFiles(e.target.files);
+ });
+
+ let selectedFiles = [];
+
+ function handleFiles(files) {
+ selectedFiles = Array.from(files);
+ const preview = document.getElementById('importPreview');
+ const previewContent = document.getElementById('importPreviewContent');
+ const fileCount = document.getElementById('importFileCount');
+ const confirmBtn = document.getElementById('importConfirmBtn');
+
+ if (selectedFiles.length === 0) {
+ preview.style.display = 'none';
+ confirmBtn.disabled = true;
+ return;
+ }
+
+ // Show preview of first file
+ const firstFile = selectedFiles[0];
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ previewContent.textContent = e.target.result.substring(0, 1000);
+ if (firstFile.size > 1000) {
+ previewContent.textContent += '\n... (truncated)';
+ }
+ };
+ reader.readAsText(firstFile);
+
+ fileCount.textContent = `${selectedFiles.length} file(s) selected: ${selectedFiles.map(f => f.name).join(', ')}`;
+ preview.style.display = 'block';
+ confirmBtn.disabled = !state.documents.currentIndex;
+ }
+
+ document.getElementById('importConfirmBtn').addEventListener('click', async () => {
+ if (!state.documents.currentIndex || selectedFiles.length === 0) return;
+
+ const format = document.getElementById('importFormat').value;
+ const formData = new FormData();
+
+ selectedFiles.forEach(file => {
+ formData.append('files', file);
+ });
+ formData.append('format', format);
+
+ const progress = document.getElementById('importProgress');
+ const progressBar = document.getElementById('importProgressBar');
+ const status = document.getElementById('importStatus');
+
+ progress.style.display = 'block';
+
+ try {
+ await importDocuments(state.documents.currentIndex, formData, (event) => {
+ if (event.progress !== undefined) {
+ const pct = Math.round(event.progress * 100);
+ progressBar.style.width = `${pct}%`;
+ status.textContent = `Processing: ${pct}%`;
+ }
+ if (event.status) {
+ status.textContent = event.status;
+ }
+ });
+
+ status.textContent = 'Import completed successfully!';
+ setTimeout(() => {
+ hideModal('importDocumentsModal');
+ progress.style.display = 'none';
+ loadDocuments();
+ }, 1500);
+ } catch (error) {
+ status.textContent = `Import failed: ${error.message}`;
+ progressBar.style.width = '0%';
+ }
+ });
+
+ // Export documents
+ document.getElementById('exportDocumentsBtn').addEventListener('click', async () => {
+ if (!state.documents.currentIndex) {
+ alert('Please select an index first');
+ return;
+ }
+
+ try {
+ const data = await fetchDocuments(state.documents.currentIndex, 1000, 0, []);
+ const blob = new Blob([JSON.stringify(data.results, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${state.documents.currentIndex}-export-${Date.now()}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch (error) {
+ alert(`Export failed: ${error.message}`);
+ }
+ });
+ }
+
+ function initQuerySandboxSection() {
+ // Index selection
+ document.getElementById('queryIndexSelect').addEventListener('change', async (e) => {
+ state.query.currentIndex = e.target.value;
+ if (state.query.currentIndex) {
+ await loadQueryFields();
+ }
+ });
+
+ // Run query
+ document.getElementById('runQueryBtn').addEventListener('click', async () => {
+ if (!state.query.currentIndex) {
+ alert('Please select an index first');
+ return;
+ }
+
+ const query = buildQuery();
+ const startTime = Date.now();
+
+ try {
+ const results = await runQuery(state.query.currentIndex, query);
+ const duration = Date.now() - startTime;
+ renderQueryResults(results, duration);
+ } catch (error) {
+ alert(`Query failed: ${error.message}`);
+ }
+ });
+
+ // Explain query
+ document.getElementById('explainQueryBtn').addEventListener('click', async () => {
+ if (!state.query.currentIndex) {
+ alert('Please select an index first');
+ return;
+ }
+
+ const query = buildQuery();
+
+ try {
+ const explain = await explainQuery(state.query.currentIndex, query);
+ renderExplainResults(explain);
+ } catch (error) {
+ alert(`Explain failed: ${error.message}`);
+ }
+ });
+
+ // Shadow diff
+ document.getElementById('shadowDiffBtn').addEventListener('click', async () => {
+ if (!state.query.currentIndex) {
+ alert('Please select an index first');
+ return;
+ }
+
+ const query = buildQuery();
+
+ try {
+ const diff = await runShadowDiff(state.query.currentIndex, query);
+ renderShadowDiffResults(diff);
+ } catch (error) {
+ alert(`Shadow diff failed: ${error.message}`);
+ }
+ });
+
+ // Add sort button
+ document.getElementById('addSortBtn').addEventListener('click', () => {
+ const container = document.getElementById('sortBuilder');
+ const row = document.createElement('div');
+ row.className = 'sort-row';
+ row.innerHTML = `
+
+
+
+ `;
+ container.appendChild(row);
+ // Load fields if index is selected
+ if (state.query.currentIndex) {
+ loadQueryFields();
+ }
+ });
+
+ // Add facet button
+ document.getElementById('addFacetBtn').addEventListener('click', () => {
+ const container = document.getElementById('facetBuilder');
+ const row = document.createElement('div');
+ row.className = 'facet-row';
+ row.innerHTML = `
+
+
+ `;
+ container.appendChild(row);
+ // Load fields if index is selected
+ if (state.query.currentIndex) {
+ loadQueryFields();
+ }
+ });
+
+ // Add filter button
+ document.getElementById('addQueryFilterBtn').addEventListener('click', () => {
+ addFilterRow('queryFilterBuilder');
+ // Load fields if index is selected
+ if (state.query.currentIndex) {
+ loadQueryFields();
+ }
+ });
+ }
+
+ function buildQuery() {
+ const query = {
+ q: document.getElementById('queryQ').value || '',
+ limit: parseInt(document.getElementById('queryLimit').value) || 20,
+ offset: parseInt(document.getElementById('queryOffset').value) || 0
+ };
+
+ // Build filter
+ const filters = [];
+ document.querySelectorAll('#queryFilterBuilder .filter-row').forEach(row => {
+ const field = row.querySelector('.filter-field').value;
+ const operator = row.querySelector('.filter-operator').value;
+ const value = row.querySelector('.filter-value').value;
+ if (field && value) {
+ filters.push({ field, operator, value });
+ }
+ });
+
+ if (filters.length > 0) {
+ query.filter = buildFilterString(filters);
+ }
+
+ // Build sort
+ const sorts = [];
+ document.querySelectorAll('#sortBuilder .sort-row').forEach(row => {
+ const field = row.querySelector('.sort-field').value;
+ const direction = row.querySelector('.sort-direction').value;
+ if (field) {
+ sorts.push(`${field}:${direction}`);
+ }
+ });
+
+ if (sorts.length > 0) {
+ query.sort = sorts;
+ }
+
+ // Build facets
+ const facets = [];
+ document.querySelectorAll('#facetBuilder .facet-row').forEach(row => {
+ const field = row.querySelector('.facet-field').value;
+ if (field) {
+ facets.push(field);
+ }
+ });
+
+ if (facets.length > 0) {
+ query.facets = facets;
+ }
+
+ return query;
+ }
+
+ // Global functions for removing rows
+ window.removeSortRow = function(button) {
+ const container = button.parentElement.parentElement;
+ if (container.children.length > 1) {
+ button.parentElement.remove();
+ }
+ };
+
+ window.removeFacetRow = function(button) {
+ const container = button.parentElement.parentElement;
+ if (container.children.length > 1) {
+ button.parentElement.remove();
+ }
+ };
+
+ function initTasksSection() {
+ // Filter controls
+ document.getElementById('taskFilter').addEventListener('change', (e) => {
+ state.tasks.filter = e.target.value;
+ state.tasks.offset = 0;
+ fetchTasks().then(renderTasks);
+ });
+
+ document.getElementById('taskIndex').addEventListener('change', (e) => {
+ state.tasks.indexUid = e.target.value;
+ state.tasks.offset = 0;
+ fetchTasks().then(renderTasks);
+ });
+
+ document.getElementById('taskType').addEventListener('change', (e) => {
+ state.tasks.type = e.target.value;
+ state.tasks.offset = 0;
+ fetchTasks().then(renderTasks);
+ });
+
+ // Pagination
+ document.getElementById('taskPrevPage').addEventListener('click', () => {
+ const newOffset = Math.max(0, state.tasks.offset - state.tasks.limit);
+ state.tasks.offset = newOffset;
+ fetchTasks().then(renderTasks);
+ });
+
+ document.getElementById('taskNextPage').addEventListener('click', () => {
+ const newOffset = state.tasks.offset + state.tasks.limit;
+ state.tasks.offset = newOffset;
+ fetchTasks().then(renderTasks);
+ });
+
+ // Task details modal
+ document.getElementById('taskDetailsModalClose').addEventListener('click', () => hideModal('taskDetailsModal'));
+ document.getElementById('taskDetailsCloseBtn').addEventListener('click', () => hideModal('taskDetailsModal'));
}
function init() {
diff --git a/crates/miroir-proxy/admin-ui/dist/index.html b/crates/miroir-proxy/admin-ui/dist/index.html
index 59737a3..14724aa 100644
--- a/crates/miroir-proxy/admin-ui/dist/index.html
+++ b/crates/miroir-proxy/admin-ui/dist/index.html
@@ -375,24 +375,334 @@
+
-
Document Browser
-
Coming soon â Paginated document browser with filter builder and CSV/NDJSON import
+
+
+
+
+
+
+
+
+
+
+
+
Filter Builder
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0 documents
+
+
+
+
+
+
+
+
+
+ | Loading... |
+
+
+ | Select an index to browse documents |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Drag & drop files here, or click to select
+
+
+
+
+
Import Progress
+
+
Initializing...
+
+
+
+
+
-
Query Sandbox
-
Coming soon â Filter builder, sort builder, facet-request builder with per-shard latency breakdown
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Query Builder
+
+
+
+
+
+
+
Filter Builder
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Sort Builder
+
+
+
+
+
+
+
+
+
+
+
+
Facet Request Builder
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Results
+
+
Run a query to see results
+
+
+
+ Hits:
+ -
+
+
+ Processing Time:
+ -
+
+
+ Shards Queried:
+ -
+
+
+
+
+
+
+
+
Per-Shard Latency Breakdown
+
+
+
+
+
+
Query Plan Explanation
+
+
+
+
+
+
Shadow Diff Results
+
+
+
-
Active Tasks
-
Coming soon â Active and recent tasks with per-node breakdown
+
+
Active Tasks
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Task UID |
+ Type |
+ Index |
+ Status |
+ Progress |
+ Started |
+ Finished |
+ Duration |
+ Actions |
+
+
+
+ | Loading tasks... |
+
+
+
+
+
+
+
+
+
diff --git a/crates/miroir-proxy/admin-ui/dist/styles.css b/crates/miroir-proxy/admin-ui/dist/styles.css
index fd93e06..e055fc5 100644
--- a/crates/miroir-proxy/admin-ui/dist/styles.css
+++ b/crates/miroir-proxy/admin-ui/dist/styles.css
@@ -921,3 +921,208 @@ button:focus-visible {
justify-content: center;
}
}
+
+/* ===========================================================================
+ Documents Section Styles
+ =========================================================================== */
+
+.filter-builder {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.filter-row,
+.sort-row,
+.facet-row {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.filter-row select,
+.sort-row select,
+.facet-row select {
+ flex: 1;
+}
+
+.filter-row .filter-field,
+.sort-row .sort-field,
+.facet-row .facet-field {
+ flex: 2;
+}
+
+.filter-row .filter-operator,
+.sort-row .sort-direction {
+ flex: 1;
+}
+
+.filter-row .filter-value {
+ flex: 2;
+}
+
+.dropzone {
+ border: 2px dashed var(--border-color);
+ border-radius: var(--radius-lg);
+ padding: 2rem;
+ text-align: center;
+ cursor: pointer;
+ transition: all var(--transition);
+ background: var(--bg-tertiary);
+}
+
+.dropzone:hover,
+.dropzone.dragover {
+ border-color: var(--accent-color);
+ background: var(--bg-secondary);
+}
+
+.dropzone p {
+ margin: 0;
+ color: var(--text-secondary);
+}
+
+.pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 1rem;
+}
+
+/* ===========================================================================
+ Query Sandbox Section Styles
+ =========================================================================== */
+
+.query-results {
+ max-height: 500px;
+ overflow-y: auto;
+}
+
+.results-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid var(--border-color);
+ margin-bottom: 0.5rem;
+}
+
+.query-stats {
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+}
+
+.stat-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.25rem 0;
+}
+
+.stat-row .stat-label {
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.stat-row .stat-value {
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+#latencyBreakdown table {
+ font-size: 0.875rem;
+}
+
+/* ===========================================================================
+ Tasks Section Styles
+ =========================================================================== */
+
+.task-details {
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+}
+
+.task-details pre {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: 0.75rem;
+ overflow-x: auto;
+}
+
+.task-details .error {
+ color: var(--error-color);
+ background: rgba(239, 68, 68, 0.1);
+ padding: 0.5rem;
+ border-radius: var(--radius-md);
+}
+
+/* ===========================================================================
+ Utility Classes for New Sections
+ =========================================================================== */
+
+.text-secondary {
+ color: var(--text-secondary);
+}
+
+.loading {
+ color: var(--text-secondary);
+ text-align: center;
+ padding: 1rem;
+}
+
+.placeholder {
+ color: var(--text-secondary);
+ text-align: center;
+ padding: 2rem;
+ font-style: italic;
+}
+
+/* Form inputs styling consistency */
+.form-input {
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: 0.5rem 0.75rem;
+ font-size: 0.875rem;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ transition: border-color 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-group {
+ margin-bottom: 1rem;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 0.375rem;
+ font-weight: 500;
+ color: var(--text-primary);
+}
+
+.form-hint {
+ display: block;
+ margin-top: 0.25rem;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+}
+
+/* Card within card (for nested sections) */
+.card .card {
+ background: var(--bg-tertiary);
+ margin: 0.5rem 0;
+}
+
+.card .card h4 {
+ font-size: 0.875rem;
+ font-weight: 600;
+ margin-bottom: 0.75rem;
+ color: var(--text-primary);
+}