Implements P5.19.b §13.19 - Indexes + Aliases sections with LIVE 2PC preview.
Backend changes:
- Add POST /indexes/{index}/settings preview endpoint
- Returns current vs proposed settings with SHA256 fingerprints
- Shows node targets, version info, and diff summary
- Displays full two-phase flow (propose/verify/commit) details
- Export compute_settings_diff for testing
Frontend changes:
- Update previewSettingsChanges() to call new preview endpoint
- Display current/proposed fingerprints, version info
- Show node targets and two-phase flow steps
- Render structured diff (added/removed/modified)
Tests:
- Add p13_19_admin_ui_2pc_preview.rs acceptance tests
- Verify fingerprint computation, diff detection, node targets
Closes: miroir-uhj.19.2
1071 lines
65 KiB
HTML
1071 lines
65 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Miroir Admin</title>
|
||
<link rel="stylesheet" href="/_miroir/admin/styles.css">
|
||
</head>
|
||
<body>
|
||
<div class="app">
|
||
<nav class="sidebar">
|
||
<div class="sidebar-header">
|
||
<h1>Miroir</h1>
|
||
<span class="subtitle">Admin Console</span>
|
||
</div>
|
||
<ul class="nav-links">
|
||
<li><a href="#overview" data-section="overview" class="active">
|
||
<span class="icon">📊</span> Overview
|
||
</a></li>
|
||
<li><a href="#topology" data-section="topology">
|
||
<span class="icon">🔷</span> Topology
|
||
</a></li>
|
||
<li><a href="#indexes" data-section="indexes">
|
||
<span class="icon">📚</span> Indexes
|
||
</a></li>
|
||
<li><a href="#aliases" data-section="aliases">
|
||
<span class="icon">🔖</span> Aliases
|
||
</a></li>
|
||
<li><a href="#documents" data-section="documents">
|
||
<span class="icon">📄</span> Documents
|
||
</a></li>
|
||
<li><a href="#query" data-section="query">
|
||
<span class="icon">🔍</span> Query Sandbox
|
||
</a></li>
|
||
<li><a href="#tasks" data-section="tasks">
|
||
<span class="icon">⚙️</span> Tasks
|
||
</a></li>
|
||
<li><a href="#canaries" data-section="canaries">
|
||
<span class="icon">🐤</span> Canaries
|
||
</a></li>
|
||
<li><a href="#shadow" data-section="shadow">
|
||
<span class="icon">👻</span> Shadow Diff
|
||
</a></li>
|
||
<li><a href="#cdc" data-section="cdc">
|
||
<span class="icon">📡</span> CDC Inspector
|
||
</a></li>
|
||
<li><a href="#metrics" data-section="metrics">
|
||
<span class="icon">📈</span> Metrics
|
||
</a></li>
|
||
<li><a href="#settings" data-section="settings">
|
||
<span class="icon">⚙️</span> Settings
|
||
</a></li>
|
||
</ul>
|
||
<div class="sidebar-footer">
|
||
<div class="connection-status" id="connectionStatus">
|
||
<span class="status-dot"></span> Connected
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<main class="main-content">
|
||
<header class="top-bar">
|
||
<button class="mobile-menu-toggle" id="mobileMenuToggle">☰</button>
|
||
<h2 id="sectionTitle">Overview</h2>
|
||
<div class="top-bar-actions">
|
||
<button class="refresh-btn" id="refreshBtn" title="Refresh data">🔄</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="content-area">
|
||
<!-- Overview Section -->
|
||
<section id="overview" class="section active">
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-label">Cluster Status</div>
|
||
<div class="stat-value" id="clusterStatus">Loading...</div>
|
||
<div class="stat-sub" id="clusterStatusSub"></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Total Shards</div>
|
||
<div class="stat-value" id="totalShards">-</div>
|
||
<div class="stat-sub"><span id="replicationFactor"></span>x replication</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Nodes</div>
|
||
<div class="stat-value" id="totalNodes">-</div>
|
||
<div class="stat-sub"><span id="degradedNodes">0</span> degraded</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Replica Groups</div>
|
||
<div class="stat-value" id="totalGroups">-</div>
|
||
<div class="stat-sub">Fully covered</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Active Operations</h3>
|
||
<div id="activeOperations">
|
||
<p class="placeholder">No active operations</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Recent Canary Failures</h3>
|
||
<div id="canaryFailures">
|
||
<p class="placeholder">Loading canary status...</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>CDC Backlog</h3>
|
||
<div id="cdcBacklog">
|
||
<p class="placeholder">Loading CDC status...</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Topology Section -->
|
||
<section id="topology" class="section">
|
||
<div class="card">
|
||
<h3>Node Health</h3>
|
||
<div class="table-container">
|
||
<table class="data-table" id="nodeTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Node ID</th>
|
||
<th>Address</th>
|
||
<th>Status</th>
|
||
<th>Replica Group</th>
|
||
<th>Shards</th>
|
||
<th>Last Seen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="nodeTableBody">
|
||
<tr><td colspan="6" class="loading">Loading...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Shard Coverage Map</h3>
|
||
<p class="card-subtitle">Visual representation of shard distribution across nodes</p>
|
||
<div id="shardCoverageMap" class="shard-coverage-map">
|
||
<p class="placeholder">Loading...</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Rebalance Progress</h3>
|
||
<div id="rebalanceProgress">
|
||
<p class="placeholder">No rebalance in progress</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Indexes Section -->
|
||
<section id="indexes" class="section">
|
||
<div class="card">
|
||
<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>Current Fingerprint:</strong> <code id="currentFingerprint"></code>
|
||
</div>
|
||
<div class="diff-fingerprint">
|
||
<strong>Proposed Fingerprint:</strong> <code id="newFingerprint"></code>
|
||
</div>
|
||
<div class="diff-fingerprint" id="settingsVersionInfo"></div>
|
||
<div class="diff-fingerprint" id="nodeTargetsInfo"></div>
|
||
<div class="two-phase-flow" id="twoPhaseFlowInfo"></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">
|
||
<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>
|
||
|
||
<!-- Documents Section -->
|
||
<section id="documents" class="section">
|
||
<div class="card">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
|
||
<div style="display: flex; gap: 0.5rem; align-items: center; flex: 1; min-width: 200px;">
|
||
<select id="documentIndexSelect" class="form-input" style="flex: 1;">
|
||
<option value="">Loading indexes...</option>
|
||
</select>
|
||
</div>
|
||
<div style="display: flex; gap: 0.5rem;">
|
||
<button class="btn btn-secondary" id="importDocumentsBtn">📥 Import</button>
|
||
<button class="btn btn-secondary" id="exportDocumentsBtn">📤 Export</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h4>Filter Builder</h4>
|
||
<div id="documentFilterBuilder" class="filter-builder">
|
||
<div class="filter-row">
|
||
<select class="form-input filter-field">
|
||
<option value="">Select field...</option>
|
||
</select>
|
||
<select class="form-input filter-operator">
|
||
<option value="equals">Equals</option>
|
||
<option value="notEquals">Not Equals</option>
|
||
<option value="gt">Greater Than</option>
|
||
<option value="gte">Greater Than or Equal</option>
|
||
<option value="lt">Less Than</option>
|
||
<option value="lte">Less Than or Equal</option>
|
||
<option value="in">In</option>
|
||
<option value="exists">Exists</option>
|
||
</select>
|
||
<input type="text" class="form-input filter-value" placeholder="Value">
|
||
<button class="btn btn-secondary btn-sm" onclick="addFilterRow('documentFilterBuilder')">+</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="removeFilterRow(this)">−</button>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-primary" id="applyDocumentFilterBtn" style="margin-top: 0.5rem;">Apply Filters</button>
|
||
<button class="btn btn-secondary" id="clearDocumentFilterBtn" style="margin-top: 0.5rem;">Clear</button>
|
||
</div>
|
||
|
||
<div class="table-container" style="margin-top: 1rem;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
|
||
<span id="documentCount" class="text-secondary">0 documents</span>
|
||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||
<label>Limit:</label>
|
||
<select id="documentLimit" class="form-input" style="width: 80px;">
|
||
<option value="20">20</option>
|
||
<option value="50" selected>50</option>
|
||
<option value="100">100</option>
|
||
<option value="200">200</option>
|
||
</select>
|
||
<label>Offset:</label>
|
||
<input type="number" id="documentOffset" class="form-input" value="0" min="0" style="width: 80px;">
|
||
</div>
|
||
</div>
|
||
<table class="data-table" id="documentsTable">
|
||
<thead id="documentsTableHead">
|
||
<tr><th class="loading">Loading...</th></tr>
|
||
</thead>
|
||
<tbody id="documentsTableBody">
|
||
<tr><td class="loading">Select an index to browse documents</td></tr>
|
||
</tbody>
|
||
</table>
|
||
<div id="documentPagination" class="pagination" style="margin-top: 1rem; display: none;">
|
||
<button class="btn btn-secondary btn-sm" id="documentPrevPage">← Previous</button>
|
||
<span id="documentPageInfo" style="margin: 0 1rem;">Page 1</span>
|
||
<button class="btn btn-secondary btn-sm" id="documentNextPage">Next →</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Import Documents Modal -->
|
||
<div id="importDocumentsModal" class="modal">
|
||
<div class="modal-content" style="max-width: 600px;">
|
||
<div class="modal-header">
|
||
<h3>Import Documents</h3>
|
||
<button class="modal-close" id="importDocumentsModalClose">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label>Import Method</label>
|
||
<select id="importMethod" class="form-input">
|
||
<option value="stream">Streaming Import (recommended for large files)</option>
|
||
<option value="batch">Batch Import (for smaller files)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>File Format</label>
|
||
<select id="importFormat" class="form-input">
|
||
<option value="ndjson">NDJSON (newline-delimited JSON)</option>
|
||
<option value="csv">CSV</option>
|
||
<option value="json">JSON Array</option>
|
||
</select>
|
||
</div>
|
||
<div class="dropzone" id="importDropzone">
|
||
<p>Drag & drop files here, or click to select</p>
|
||
<input type="file" id="importFileInput" multiple accept=".json,.ndjson,.csv" style="display: none;">
|
||
</div>
|
||
<div id="importPreview" style="display: none; margin-top: 1rem;">
|
||
<h4>Preview</h4>
|
||
<pre id="importPreviewContent" style="max-height: 200px; overflow: auto; background: var(--bg-secondary); padding: 0.5rem; border-radius: 4px;"></pre>
|
||
<p id="importFileCount" class="text-secondary" style="margin-top: 0.5rem;"></p>
|
||
</div>
|
||
<div id="importProgress" style="display: none; margin-top: 1rem;">
|
||
<h4>Import Progress</h4>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill" id="importProgressBar" style="width: 0%"></div>
|
||
</div>
|
||
<p id="importStatus" class="text-secondary" style="margin-top: 0.5rem;">Initializing...</p>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" id="importCancelBtn">Cancel</button>
|
||
<button class="btn btn-primary" id="importConfirmBtn" disabled>Import</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Query Sandbox Section -->
|
||
<section id="query" class="section">
|
||
<div class="card">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
|
||
<div style="display: flex; gap: 0.5rem; align-items: center; flex: 1; min-width: 200px;">
|
||
<select id="queryIndexSelect" class="form-input" style="flex: 1;">
|
||
<option value="">Loading indexes...</option>
|
||
</select>
|
||
</div>
|
||
<div style="display: flex; gap: 0.5rem;">
|
||
<button class="btn btn-primary" id="runQueryBtn">▶ Run Query</button>
|
||
<button class="btn btn-secondary" id="explainQueryBtn">ℹ️ Explain</button>
|
||
<button class="btn btn-secondary" id="shadowDiffBtn">👻 Shadow Diff</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
|
||
<!-- Query Builder -->
|
||
<div class="card" style="margin: 0;">
|
||
<h4>Query Builder</h4>
|
||
<div class="form-group">
|
||
<label for="queryQ">Query Text (q)</label>
|
||
<input type="text" id="queryQ" class="form-input" placeholder="Enter search query...">
|
||
</div>
|
||
|
||
<div class="card" style="margin: 0.5rem 0;">
|
||
<h4>Filter Builder</h4>
|
||
<div id="queryFilterBuilder" class="filter-builder">
|
||
<div class="filter-row">
|
||
<select class="form-input filter-field">
|
||
<option value="">Select field...</option>
|
||
</select>
|
||
<select class="form-input filter-operator">
|
||
<option value="equals">Equals</option>
|
||
<option value="notEquals">Not Equals</option>
|
||
<option value="gt">Greater Than</option>
|
||
<option value="gte">Greater Than or Equal</option>
|
||
<option value="lt">Less Than</option>
|
||
<option value="lte">Less Than or Equal</option>
|
||
<option value="in">In</option>
|
||
<option value="exists">Exists</option>
|
||
</select>
|
||
<input type="text" class="form-input filter-value" placeholder="Value">
|
||
<button class="btn btn-secondary btn-sm" onclick="addFilterRow('queryFilterBuilder')">+</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="removeFilterRow(this)">−</button>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-secondary btn-sm" id="addQueryFilterBtn" style="margin-top: 0.5rem;">+ Add Filter</button>
|
||
</div>
|
||
|
||
<div class="card" style="margin: 0.5rem 0;">
|
||
<h4>Sort Builder</h4>
|
||
<div id="sortBuilder">
|
||
<div class="sort-row">
|
||
<select class="form-input sort-field">
|
||
<option value="">Select field...</option>
|
||
</select>
|
||
<select class="form-input sort-direction">
|
||
<option value="asc">Ascending</option>
|
||
<option value="desc">Descending</option>
|
||
</select>
|
||
<button class="btn btn-secondary btn-sm" onclick="removeSortRow(this)">−</button>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-secondary btn-sm" id="addSortBtn" style="margin-top: 0.5rem;">+ Add Sort</button>
|
||
</div>
|
||
|
||
<div class="card" style="margin: 0.5rem 0;">
|
||
<h4>Facet Request Builder</h4>
|
||
<div id="facetBuilder">
|
||
<div class="facet-row">
|
||
<select class="form-input facet-field">
|
||
<option value="">Select field...</option>
|
||
</select>
|
||
<button class="btn btn-secondary btn-sm" onclick="removeFacetRow(this)">−</button>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-secondary btn-sm" id="addFacetBtn" style="margin-top: 0.5rem;">+ Add Facet</button>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top: 1rem;">
|
||
<label for="queryLimit">Limit</label>
|
||
<input type="number" id="queryLimit" class="form-input" value="20" min="1" max="1000">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="queryOffset">Offset</label>
|
||
<input type="number" id="queryOffset" class="form-input" value="0" min="0">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Query Results -->
|
||
<div class="card" style="margin: 0;">
|
||
<h4>Results</h4>
|
||
<div id="queryResults" class="query-results">
|
||
<p class="placeholder">Run a query to see results</p>
|
||
</div>
|
||
<div id="queryStats" class="query-stats" style="display: none; margin-top: 1rem;">
|
||
<div class="stat-row">
|
||
<span class="stat-label">Hits:</span>
|
||
<span class="stat-value" id="queryHits">-</span>
|
||
</div>
|
||
<div class="stat-row">
|
||
<span class="stat-label">Processing Time:</span>
|
||
<span class="stat-value" id="queryProcessingTime">-</span>
|
||
</div>
|
||
<div class="stat-row">
|
||
<span class="stat-label">Shards Queried:</span>
|
||
<span class="stat-value" id="queryShardsQueried">-</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Per-Shard Latency Breakdown -->
|
||
<div id="latencyBreakdown" class="card" style="display: none;">
|
||
<h4>Per-Shard Latency Breakdown</h4>
|
||
<div id="shardLatencyTable" class="table-container"></div>
|
||
</div>
|
||
|
||
<!-- Explain Results -->
|
||
<div id="explainResults" class="card" style="display: none;">
|
||
<h4>Query Plan Explanation</h4>
|
||
<pre id="explainJson" style="max-height: 400px; overflow: auto; background: var(--bg-secondary); padding: 1rem; border-radius: 4px;"></pre>
|
||
</div>
|
||
|
||
<!-- Shadow Diff Results -->
|
||
<div id="shadowDiffResults" class="card" style="display: none;">
|
||
<h4>Shadow Diff Results</h4>
|
||
<div id="shadowDiffContent"></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Tasks Section -->
|
||
<section id="tasks" class="section">
|
||
<div class="card">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||
<h3>Active Tasks</h3>
|
||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||
<label for="taskFilter">Filter:</label>
|
||
<select id="taskFilter" class="form-input">
|
||
<option value="all">All Tasks</option>
|
||
<option value="active">Active Only</option>
|
||
<option value="pending">Pending</option>
|
||
<option value="succeeded">Succeeded</option>
|
||
<option value="failed">Failed</option>
|
||
</select>
|
||
<label for="taskIndex">Index:</label>
|
||
<select id="taskIndex" class="form-input">
|
||
<option value="">All Indexes</option>
|
||
</select>
|
||
<label for="taskType">Type:</label>
|
||
<select id="taskType" class="form-input">
|
||
<option value="">All Types</option>
|
||
<option value="documentAddition">Document Addition</option>
|
||
<option value="documentUpdate">Document Update</option>
|
||
<option value="documentDeletion">Document Deletion</option>
|
||
<option value="settingsUpdate">Settings Update</option>
|
||
<option value="indexCreation">Index Creation</option>
|
||
<option value="indexDeletion">Index Deletion</option>
|
||
<option value="indexUpdate">Index Update</option>
|
||
<option value="indexSwap">Index Swap</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-container">
|
||
<table class="data-table" id="tasksTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Task UID</th>
|
||
<th>Type</th>
|
||
<th>Index</th>
|
||
<th>Status</th>
|
||
<th>Progress</th>
|
||
<th>Started</th>
|
||
<th>Finished</th>
|
||
<th>Duration</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tasksTableBody">
|
||
<tr><td colspan="9" class="loading">Loading tasks...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div id="taskPagination" class="pagination" style="margin-top: 1rem; display: flex; justify-content: center; align-items: center; gap: 1rem;">
|
||
<button class="btn btn-secondary btn-sm" id="taskPrevPage" disabled>← Previous</button>
|
||
<span id="taskPageInfo">Showing 0-0 of 0 tasks</span>
|
||
<button class="btn btn-secondary btn-sm" id="taskNextPage" disabled>Next →</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Task Details Modal -->
|
||
<div id="taskDetailsModal" class="modal">
|
||
<div class="modal-content" style="max-width: 800px;">
|
||
<div class="modal-header">
|
||
<h3>Task Details</h3>
|
||
<button class="modal-close" id="taskDetailsModalClose">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div id="taskDetailsContent"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" id="taskDetailsCloseBtn">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="canaries" class="section">
|
||
<div class="card">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||
<h3>Canary Tests</h3>
|
||
<div style="display: flex; gap: 0.5rem;">
|
||
<button class="btn btn-secondary" id="startCaptureBtn">🎯 Capture Traffic</button>
|
||
<button class="btn btn-primary" id="createCanaryBtn">+ Create Canary</button>
|
||
</div>
|
||
</div>
|
||
<p class="card-subtitle">Automated query assertions with pass-fail tracking over time</p>
|
||
|
||
<div class="table-container">
|
||
<table class="data-table" id="canariesTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Index</th>
|
||
<th>Interval</th>
|
||
<th>Status</th>
|
||
<th>Last Run</th>
|
||
<th>Pass Rate (24h)</th>
|
||
<th>Heatmap</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="canariesTableBody">
|
||
<tr><td colspan="8" class="loading">Loading canaries...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Canary Details Modal -->
|
||
<div id="canaryDetailsModal" class="modal">
|
||
<div class="modal-content" style="max-width: 800px;">
|
||
<div class="modal-header">
|
||
<h3 id="canaryDetailsTitle">Canary Details</h3>
|
||
<button class="modal-close" id="canaryDetailsModalClose">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div id="canaryDetailsContent"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" id="canaryDetailsCloseBtn">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Create/Edit Canary Modal -->
|
||
<div id="canaryEditModal" class="modal">
|
||
<div class="modal-content" style="max-width: 600px;">
|
||
<div class="modal-header">
|
||
<h3 id="canaryEditTitle">Create Canary</h3>
|
||
<button class="modal-close" id="canaryEditModalClose">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label for="canaryName">Name *</label>
|
||
<input type="text" id="canaryName" class="form-input" placeholder="my-canary" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="canaryIndex">Index *</label>
|
||
<select id="canaryIndex" class="form-input" required>
|
||
<option value="">Select index...</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="canaryInterval">Interval (seconds) *</label>
|
||
<input type="number" id="canaryInterval" class="form-input" value="3600" min="60" required>
|
||
<small class="form-hint">How often to run this canary (default: 3600s = 1 hour)</small>
|
||
</div>
|
||
<div class="card" style="margin: 1rem 0;">
|
||
<h4>Query</h4>
|
||
<div class="form-group">
|
||
<label for="canaryQuery">Query (q)</label>
|
||
<input type="text" id="canaryQuery" class="form-input" placeholder="*">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="canaryLimit">Limit</label>
|
||
<input type="number" id="canaryLimit" class="form-input" value="10" min="1">
|
||
</div>
|
||
</div>
|
||
<div class="card" style="margin: 1rem 0;">
|
||
<h4>Assertions</h4>
|
||
<div id="canaryAssertions">
|
||
<div class="assertion-row">
|
||
<select class="form-input assertion-type">
|
||
<option value="minHits">Min Hits</option>
|
||
<option value="maxHits">Max Hits</option>
|
||
<option value="maxP95Ms">Max P95 Latency</option>
|
||
<option value="containsAny">Contains Any Result</option>
|
||
</select>
|
||
<input type="number" class="form-input assertion-value" placeholder="Value">
|
||
<button class="btn btn-secondary btn-sm" onclick="addAssertionRow()">+</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="removeAssertionRow(this)">−</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" id="canaryEditCancelBtn">Cancel</button>
|
||
<button class="btn btn-primary" id="canaryEditSaveBtn">Save Canary</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Capture Traffic Modal -->
|
||
<div id="captureModal" class="modal">
|
||
<div class="modal-content" style="max-width: 500px;">
|
||
<div class="modal-header">
|
||
<h3>Capture Traffic for Canary</h3>
|
||
<button class="modal-close" id="captureModalClose">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p>Capture live queries to create a new canary from real traffic patterns.</p>
|
||
<div class="form-group">
|
||
<label for="captureMaxQueries">Max Queries to Capture</label>
|
||
<input type="number" id="captureMaxQueries" class="form-input" value="100" min="1" max="1000">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="captureIndex">Index</label>
|
||
<select id="captureIndex" class="form-input">
|
||
<option value="">Select index...</option>
|
||
</select>
|
||
</div>
|
||
<div id="captureStatus" style="display: none;">
|
||
<h4>Capture Status</h4>
|
||
<p id="captureStatusText">Capturing...</p>
|
||
<p id="captureCount">0 queries captured</p>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" id="captureCancelBtn">Cancel</button>
|
||
<button class="btn btn-primary" id="captureStartBtn">Start Capture</button>
|
||
<button class="btn btn-success" id="captureCreateBtn" style="display: none;">Create Canary</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="shadow" class="section">
|
||
<div class="card">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||
<h3>Shadow Diff</h3>
|
||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||
<label for="shadowAutoRefresh">
|
||
<input type="checkbox" id="shadowAutoRefresh"> Auto-refresh (5s)
|
||
</label>
|
||
<button class="btn btn-secondary" id="refreshShadowBtn">🔄 Refresh</button>
|
||
</div>
|
||
</div>
|
||
<p class="card-subtitle">Live stream and aggregated summary from shadow traffic comparisons</p>
|
||
|
||
<div class="stats-grid" style="margin-bottom: 1rem;">
|
||
<div class="stat-card">
|
||
<div class="stat-label">Total Shadowed</div>
|
||
<div class="stat-value" id="shadowTotal">-</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Total Errors</div>
|
||
<div class="stat-value" id="shadowErrors">-</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Error Rate</div>
|
||
<div class="stat-value" id="shadowErrorRate">-</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Recent Diffs</div>
|
||
<div class="stat-value" id="shadowRecentDiffs">-</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-container">
|
||
<table class="data-table" id="shadowDiffsTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Timestamp</th>
|
||
<th>Target</th>
|
||
<th>Diff Kind</th>
|
||
<th>Details</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="shadowDiffsTableBody">
|
||
<tr><td colspan="4" class="loading">Loading shadow diffs...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="cdc" class="section">
|
||
<div class="card">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||
<h3>CDC Inspector</h3>
|
||
<div style="display: flex; gap: 0.5rem;">
|
||
<button class="btn btn-secondary" id="cdcPauseBtn">⏸ Pause</button>
|
||
<button class="btn btn-secondary" id="cdcClearBtn">🗑 Clear</button>
|
||
</div>
|
||
</div>
|
||
<p class="card-subtitle">Live tail of change events with filter by index/operation</p>
|
||
|
||
<div class="card" style="margin: 1rem 0;">
|
||
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||
<label for="cdcIndexFilter">Index:</label>
|
||
<select id="cdcIndexFilter" class="form-input" style="flex: 1; min-width: 150px;">
|
||
<option value="">All Indexes</option>
|
||
</select>
|
||
<label for="cdcOperationFilter">Operation:</label>
|
||
<select id="cdcOperationFilter" class="form-input" style="flex: 1; min-width: 150px;">
|
||
<option value="">All Operations</option>
|
||
<option value="documentAddition">Add</option>
|
||
<option value="documentUpdate">Update</option>
|
||
<option value="documentDeletion">Delete</option>
|
||
<option value="settingsUpdate">Settings</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="cdcEventsContainer" class="cdc-events-container">
|
||
<div id="cdcEvents" class="cdc-events">
|
||
<p class="placeholder">Waiting for CDC events...</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top: 1rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||
<span>Current Sequence: <strong id="cdcCurrentSequence">0</strong></span>
|
||
<span style="margin-left: 1rem;">Events Buffered: <strong id="cdcEventCount">0</strong></span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="metrics" class="section">
|
||
<div class="card">
|
||
<h3>Metrics Dashboard</h3>
|
||
<p class="card-subtitle">System metrics and performance indicators</p>
|
||
|
||
<div class="metrics-container">
|
||
<div class="metrics-tabs">
|
||
<button class="metrics-tab active" data-metrics-tab="prometheus">Prometheus</button>
|
||
<button class="metrics-tab" data-metrics-tab="grafana">Grafana</button>
|
||
</div>
|
||
|
||
<div id="metricsPrometheus" class="metrics-tab-content active">
|
||
<div class="card" style="margin: 0;">
|
||
<h4>Prometheus Metrics</h4>
|
||
<p style="margin-bottom: 1rem;">Direct access to Prometheus metrics endpoint</p>
|
||
<div style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
|
||
<input type="text" id="prometheusQuery" class="form-input" style="flex: 1;" placeholder="Enter query (e.g., miroir_request_duration_seconds)">
|
||
<button class="btn btn-primary" id="prometheusQueryBtn">Query</button>
|
||
</div>
|
||
<div id="prometheusResults" class="prometheus-results">
|
||
<p class="placeholder">Enter a query to see results</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin: 1rem 0 0 0;">
|
||
<h4>Quick Metrics</h4>
|
||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.5rem;">
|
||
<button class="btn btn-secondary btn-sm quick-metric-btn" data-metric="miroir_requests_total">Total Requests</button>
|
||
<button class="btn btn-secondary btn-sm quick-metric-btn" data-metric="miroir_request_duration_seconds">Request Duration</button>
|
||
<button class="btn btn-secondary btn-sm quick-metric-btn" data-metric="miroir_rebalance_in_progress">Rebalance Status</button>
|
||
<button class="btn btn-secondary btn-sm quick-metric-btn" data-metric="miroir_degraded_shards">Degraded Shards</button>
|
||
<button class="btn btn-secondary btn-sm quick-metric-btn" data-metric="miroir_antientropy_mismatches_found">AE Mismatches</button>
|
||
<button class="btn btn-secondary btn-sm quick-metric-btn" data-metric="miroir_replica_selection_score">Replica Scores</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="metricsGrafana" class="metrics-tab-content" style="display: none;">
|
||
<div class="card" style="margin: 0;">
|
||
<h4>Grafana Dashboard</h4>
|
||
<div class="form-group">
|
||
<label for="grafanaUrl">Grafana Dashboard URL</label>
|
||
<input type="text" id="grafanaUrl" class="form-input" placeholder="https://grafana.example.com/d/...">
|
||
<small class="form-hint">Configure in Miroir settings to persist</small>
|
||
</div>
|
||
<button class="btn btn-primary" id="grafanaLoadBtn">Load Dashboard</button>
|
||
<div id="grafanaFrame" class="grafana-frame" style="margin-top: 1rem;">
|
||
<iframe id="grafanaIframe" style="width: 100%; height: 600px; border: none; display: none;"></iframe>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="settings" class="section">
|
||
<div class="card">
|
||
<h3>Miroir Settings</h3>
|
||
<p class="card-subtitle">Read and edit Miroir's own configuration</p>
|
||
|
||
<div id="settingsContent">
|
||
<div class="settings-category" id="settingsGeneral">
|
||
<h4>General Settings</h4>
|
||
<table class="data-table">
|
||
<tbody id="settingsGeneralTable">
|
||
<tr><td colspan="2" class="loading">Loading settings...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="settings-category" id="settingsAdvanced">
|
||
<h4>Advanced Settings</h4>
|
||
<div class="alert info" style="margin-bottom: 1rem;">
|
||
<strong>Restart Required:</strong> Settings marked with <span class="badge warning">Restart</span> require a pod restart to take effect.
|
||
</div>
|
||
<table class="data-table">
|
||
<tbody id="settingsAdvancedTable">
|
||
<tr><td colspan="2" class="loading">Loading settings...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top: 1rem; text-align: right;">
|
||
<button class="btn btn-secondary" id="settingsReloadBtn">Reload from File</button>
|
||
<button class="btn btn-primary" id="settingsEditBtn">Edit Settings</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Edit Settings Modal -->
|
||
<div id="settingsEditModal" class="modal">
|
||
<div class="modal-content" style="max-width: 800px;">
|
||
<div class="modal-header">
|
||
<h3>Edit Miroir Settings</h3>
|
||
<button class="modal-close" id="settingsEditModalClose">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="alert warning" style="margin-bottom: 1rem;">
|
||
<strong>Warning:</strong> Editing settings directly can cause instability. Changes marked with <span class="badge warning">Restart</span> require a pod restart.
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="settingsEditYaml">Settings (YAML)</label>
|
||
<textarea id="settingsEditYaml" class="settings-textarea" rows="20" spellcheck="false"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" id="settingsEditCancelBtn">Cancel</button>
|
||
<button class="btn btn-primary" id="settingsEditApplyBtn">Apply Changes</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<script src="/_miroir/admin/app.js"></script>
|
||
</body>
|
||
</html>
|