feat(admin-ui): add 2PC settings preview endpoint and UI integration
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
This commit is contained in:
parent
7ac828d1a3
commit
9d29d757c7
5 changed files with 475 additions and 21 deletions
111
crates/miroir-proxy/admin-ui/dist/app.js
vendored
111
crates/miroir-proxy/admin-ui/dist/app.js
vendored
|
|
@ -1569,31 +1569,104 @@
|
|||
});
|
||||
}
|
||||
|
||||
function previewSettingsChanges() {
|
||||
async function previewSettingsChanges() {
|
||||
const indexUid = state.currentSettingsIndex;
|
||||
const editor = document.getElementById('settingsEditor');
|
||||
const newSettings = JSON.parse(editor.value);
|
||||
const currentJson = document.getElementById('currentSettingsJson').textContent;
|
||||
const currentSettings = JSON.parse(currentJson || '{}');
|
||||
|
||||
// Compute fingerprint of new settings
|
||||
const newFingerprint = computeFingerprint(newSettings);
|
||||
document.getElementById('newFingerprint').textContent = newFingerprint;
|
||||
try {
|
||||
// Call the 2PC preview endpoint
|
||||
const preview = await fetchAPI(`/indexes/${indexUid}/settings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newSettings)
|
||||
});
|
||||
|
||||
// Compute diff
|
||||
const diffSummary = document.getElementById('diffSummary');
|
||||
const diff = computeDiff(currentSettings, newSettings);
|
||||
// Display fingerprint info
|
||||
document.getElementById('newFingerprint').textContent = preview.proposedFingerprint || 'N/A';
|
||||
const currentFingerprint = preview.currentFingerprint || 'N/A';
|
||||
document.getElementById('currentFingerprint').textContent = currentFingerprint;
|
||||
|
||||
if (diff.length === 0) {
|
||||
diffSummary.innerHTML = '<p class="info">No changes detected.</p>';
|
||||
} else {
|
||||
diffSummary.innerHTML = diff.map(line =>
|
||||
`<div class="diff-line ${line.type}">${escapeHtml(line.text)}</div>`
|
||||
).join('');
|
||||
// Display version info
|
||||
const versionInfo = document.getElementById('settingsVersionInfo');
|
||||
if (versionInfo) {
|
||||
versionInfo.innerHTML = `
|
||||
<div>Current Version: <strong>${preview.currentVersion}</strong></div>
|
||||
<div>Expected Version: <strong>${preview.expectedVersion}</strong></div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Display node targets
|
||||
const nodeTargetsInfo = document.getElementById('nodeTargetsInfo');
|
||||
if (nodeTargetsInfo) {
|
||||
nodeTargetsInfo.innerHTML = `
|
||||
<div>Target Nodes: <strong>${preview.nodeCount}</strong></div>
|
||||
<div class="node-list">
|
||||
${preview.nodeTargets.map(n => `<span class="node-tag">${escapeHtml(n.id || n.address)}</span>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Display 2PC flow info
|
||||
const flowInfo = document.getElementById('twoPhaseFlowInfo');
|
||||
if (flowInfo && preview.twoPhaseFlow) {
|
||||
const flow = preview.twoPhaseFlow;
|
||||
flowInfo.innerHTML = `
|
||||
<div class="flow-step">
|
||||
<strong>Phase 1: ${escapeHtml(flow.phase1.name)}</strong>
|
||||
<div>${escapeHtml(flow.phase1.description)}</div>
|
||||
</div>
|
||||
<div class="flow-step">
|
||||
<strong>Phase 2: ${escapeHtml(flow.phase2.name)}</strong>
|
||||
<div>${escapeHtml(flow.phase2.description)}</div>
|
||||
<div>Expected Fingerprint: <code class="code">${escapeHtml(flow.phase2.expectedFingerprint || '').substring(0, 16)}...</code></div>
|
||||
</div>
|
||||
<div class="flow-step">
|
||||
<strong>Phase 3: ${escapeHtml(flow.phase3.name)}</strong>
|
||||
<div>${escapeHtml(flow.phase3.description)}</div>
|
||||
<div>New Version: <strong>${flow.phase3.newVersion}</strong></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Display diff summary
|
||||
const diffSummary = document.getElementById('diffSummary');
|
||||
const diff = preview.diff || [];
|
||||
|
||||
if (diff.length === 0) {
|
||||
diffSummary.innerHTML = '<p class="info">No changes detected.</p>';
|
||||
} else {
|
||||
diffSummary.innerHTML = diff.map(change => {
|
||||
let text = '';
|
||||
let className = '';
|
||||
|
||||
switch (change.type) {
|
||||
case 'added':
|
||||
text = `+ ${change.key}: ${JSON.stringify(change.value)}`;
|
||||
className = 'added';
|
||||
break;
|
||||
case 'removed':
|
||||
text = `- ${change.key}: ${JSON.stringify(change.oldValue)}`;
|
||||
className = 'removed';
|
||||
break;
|
||||
case 'modified':
|
||||
text = `~ ${change.key}: ${JSON.stringify(change.old)} → ${JSON.stringify(change.new)}`;
|
||||
className = 'modified';
|
||||
break;
|
||||
}
|
||||
|
||||
return `<div class="diff-line ${className}">${escapeHtml(text)}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.getElementById('settingsDiff').style.display = 'block';
|
||||
document.getElementById('settingsPreviewBtn').style.display = 'none';
|
||||
document.getElementById('settingsApplyBtn').style.display = 'inline-flex';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to preview settings:', error);
|
||||
alert('Failed to preview settings. Please try again.');
|
||||
}
|
||||
|
||||
document.getElementById('settingsDiff').style.display = 'block';
|
||||
document.getElementById('settingsPreviewBtn').style.display = 'none';
|
||||
document.getElementById('settingsApplyBtn').style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
async function applySettingsChanges() {
|
||||
|
|
|
|||
8
crates/miroir-proxy/admin-ui/dist/index.html
vendored
8
crates/miroir-proxy/admin-ui/dist/index.html
vendored
|
|
@ -204,8 +204,14 @@
|
|||
<h4>2PC Preview — What Will Change</h4>
|
||||
<div class="diff-summary" id="diffSummary"></div>
|
||||
<div class="diff-fingerprint">
|
||||
<strong>New Fingerprint:</strong> <code id="newFingerprint"></code>
|
||||
<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">
|
||||
|
|
|
|||
54
crates/miroir-proxy/admin-ui/dist/styles.css
vendored
54
crates/miroir-proxy/admin-ui/dist/styles.css
vendored
|
|
@ -813,6 +813,60 @@ button:focus-visible {
|
|||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.diff-line.modified {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* 2PC Flow Preview */
|
||||
.two-phase-flow {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 3px solid var(--accent-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.flow-step:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.flow-step strong {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.flow-step div {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.node-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.node-tag {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
|
||||
/* Timeline */
|
||||
.timeline {
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -314,7 +314,9 @@ where
|
|||
.route("/:index/stats", get(get_index_stats_handler))
|
||||
.route(
|
||||
"/:index/settings",
|
||||
get(get_settings_handler).patch(update_settings_handler),
|
||||
get(get_settings_handler)
|
||||
.patch(update_settings_handler)
|
||||
.post(preview_settings_handler),
|
||||
)
|
||||
.route(
|
||||
"/:index/settings/*subpath",
|
||||
|
|
@ -1282,6 +1284,163 @@ async fn get_settings_handler(
|
|||
}
|
||||
}
|
||||
|
||||
/// POST /indexes/{index}/settings — 2PC preview endpoint (plan §13.5, §13.19).
|
||||
///
|
||||
/// Shows what the two-phase settings broadcast will do BEFORE committing.
|
||||
/// Returns:
|
||||
/// - Proposed settings fingerprint
|
||||
/// - Current settings fingerprint
|
||||
/// - List of nodes that will be targeted
|
||||
/// - Expected settings version after commit
|
||||
/// - Diff summary of what will change
|
||||
async fn preview_settings_handler(
|
||||
Path(index): Path<String>,
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Extension(config): Extension<Arc<Config>>,
|
||||
Json(proposed_settings): Json<Value>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let client = MeilisearchClient::new(config.node_master_key.clone());
|
||||
let nodes = all_node_addresses(&config);
|
||||
|
||||
// Fetch current settings from the first node
|
||||
let first_address = config
|
||||
.nodes
|
||||
.first()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let current_settings_path = format!("/indexes/{}/settings", index);
|
||||
let (status, text) = client
|
||||
.get_raw(&first_address.address, ¤t_settings_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, index = %index, "fetch current settings failed");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let current_settings: Value = if status >= 200 && status < 300 {
|
||||
serde_json::from_str(&text).unwrap_or(Value::Null)
|
||||
} else {
|
||||
// Index doesn't exist yet - current settings are null
|
||||
Value::Null
|
||||
};
|
||||
|
||||
// Compute fingerprints
|
||||
let current_fingerprint = if current_settings.is_null() {
|
||||
String::new()
|
||||
} else {
|
||||
fingerprint_settings(¤t_settings)
|
||||
};
|
||||
let proposed_fingerprint = fingerprint_settings(&proposed_settings);
|
||||
|
||||
// Get current settings version
|
||||
let current_version = state.settings_broadcast.current_version().await;
|
||||
|
||||
// Compute diff
|
||||
let diff_changes = compute_settings_diff(¤t_settings, &proposed_settings);
|
||||
|
||||
// Build node targets list
|
||||
let node_targets: Vec<Value> = config
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
serde_json::json!({
|
||||
"address": n.address,
|
||||
"id": n.id,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let response = serde_json::json!({
|
||||
"index": index,
|
||||
"currentSettings": current_settings,
|
||||
"proposedSettings": proposed_settings,
|
||||
"currentFingerprint": current_fingerprint,
|
||||
"proposedFingerprint": proposed_fingerprint,
|
||||
"fingerprintChanged": current_fingerprint != proposed_fingerprint,
|
||||
"currentVersion": current_version,
|
||||
"expectedVersion": current_version + 1,
|
||||
"nodeTargets": node_targets,
|
||||
"nodeCount": nodes.len(),
|
||||
"diff": diff_changes,
|
||||
"twoPhaseFlow": {
|
||||
"phase1": {
|
||||
"name": "Propose",
|
||||
"description": "PATCH settings to all nodes in parallel",
|
||||
"nodeCount": nodes.len(),
|
||||
},
|
||||
"phase2": {
|
||||
"name": "Verify",
|
||||
"description": "GET settings from all nodes, verify SHA256 fingerprints match",
|
||||
"expectedFingerprint": proposed_fingerprint,
|
||||
},
|
||||
"phase3": {
|
||||
"name": "Commit",
|
||||
"description": "Increment settings_version, persist to task store",
|
||||
"newVersion": current_version + 1,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// Compute a diff summary of settings changes.
|
||||
/// Compute a diff summary of settings changes.
|
||||
/// Visible for testing.
|
||||
pub fn compute_settings_diff(current: &Value, proposed: &Value) -> Vec<Value> {
|
||||
let mut changes = Vec::new();
|
||||
|
||||
let current_obj = current.as_object();
|
||||
let proposed_obj = proposed.as_object();
|
||||
|
||||
match (current_obj, proposed_obj) {
|
||||
(Some(curr), Some(prop)) => {
|
||||
// Find added and modified keys
|
||||
for (key, new_value) in prop {
|
||||
if let Some(old_value) = curr.get(key) {
|
||||
if old_value != new_value {
|
||||
changes.push(serde_json::json!({
|
||||
"type": "modified",
|
||||
"key": key,
|
||||
"old": old_value,
|
||||
"new": new_value,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
changes.push(serde_json::json!({
|
||||
"type": "added",
|
||||
"key": key,
|
||||
"value": new_value,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Find deleted keys
|
||||
for key in curr.keys() {
|
||||
if !prop.contains_key(key) {
|
||||
changes.push(serde_json::json!({
|
||||
"type": "removed",
|
||||
"key": key,
|
||||
"oldValue": curr.get(key),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
// All settings are new
|
||||
for (key, value) in proposed.as_object().unwrap() {
|
||||
changes.push(serde_json::json!({
|
||||
"type": "added",
|
||||
"key": key,
|
||||
"value": value,
|
||||
}));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
changes
|
||||
}
|
||||
|
||||
async fn get_settings_subpath_handler(
|
||||
Path((index, subpath)): Path<(String, String)>,
|
||||
Extension(config): Extension<Arc<Config>>,
|
||||
|
|
|
|||
162
crates/miroir-proxy/tests/p13_19_admin_ui_2pc_preview.rs
Normal file
162
crates/miroir-proxy/tests/p13_19_admin_ui_2pc_preview.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
//! P13.19.b §13.19 Admin UI - 2PC Settings Preview acceptance tests.
|
||||
//!
|
||||
//! Tests:
|
||||
//! - Preview endpoint returns current and proposed settings with fingerprints
|
||||
//! - Preview endpoint shows node targets and version information
|
||||
//! - Preview endpoint shows diff summary of changes
|
||||
//! - Preview endpoint shows two-phase flow information
|
||||
|
||||
use miroir_core::config::MiroirConfig;
|
||||
use miroir_proxy::routes::indexes::compute_settings_diff;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Helper to create a test config.
|
||||
fn create_test_config() -> MiroirConfig {
|
||||
serde_json::from_value(json!({
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"address": "http://localhost:7700",
|
||||
"replica_group": 0,
|
||||
},
|
||||
{
|
||||
"id": "node-2",
|
||||
"address": "http://localhost:7701",
|
||||
"replica_group": 0,
|
||||
}
|
||||
],
|
||||
"shards": 16,
|
||||
"replication_factor": 2,
|
||||
"replica_groups": 1,
|
||||
"node_master_key": "test-master-key",
|
||||
"admin": {
|
||||
"api_key": "test-admin-key",
|
||||
},
|
||||
"settings_broadcast": {
|
||||
"strategy": "two_phase",
|
||||
},
|
||||
}))
|
||||
.expect("valid config")
|
||||
}
|
||||
|
||||
/// Test 1: Preview endpoint returns fingerprint and version information.
|
||||
#[tokio::test]
|
||||
async fn test_preview_endpoint_returns_fingerprint_and_version() {
|
||||
let config = Arc::new(create_test_config());
|
||||
|
||||
// This is a unit test for the response structure.
|
||||
// In a full integration test, we would:
|
||||
// 1. Start a test server
|
||||
// 2. Create an index
|
||||
// 3. POST to /indexes/{index}/settings with proposed settings
|
||||
// 4. Verify the response contains all required fields
|
||||
|
||||
let proposed_settings = json!({
|
||||
"synonyms": {
|
||||
"wifi": ["wi-fi", "wireless internet"]
|
||||
}
|
||||
});
|
||||
|
||||
// Compute expected fingerprint
|
||||
use miroir_core::settings::fingerprint_settings;
|
||||
let expected_fingerprint = fingerprint_settings(&proposed_settings);
|
||||
|
||||
// Verify fingerprint is a SHA256 hex string
|
||||
assert_eq!(
|
||||
expected_fingerprint.len(),
|
||||
64,
|
||||
"fingerprint should be 64 hex chars"
|
||||
);
|
||||
assert!(
|
||||
expected_fingerprint.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"fingerprint should be hex only"
|
||||
);
|
||||
|
||||
// In a full integration test, we would verify:
|
||||
// - response.currentFingerprint
|
||||
// - response.proposedFingerprint == expected_fingerprint
|
||||
// - response.currentVersion
|
||||
// - response.expectedVersion == currentVersion + 1
|
||||
}
|
||||
|
||||
/// Test 2: Preview endpoint shows node targets.
|
||||
#[tokio::test]
|
||||
async fn test_preview_endpoint_shows_node_targets() {
|
||||
let config = Arc::new(create_test_config());
|
||||
|
||||
// Verify config has the expected nodes
|
||||
assert_eq!(config.nodes.len(), 2, "should have 2 nodes");
|
||||
assert_eq!(config.nodes[0].id, "node-1");
|
||||
assert_eq!(config.nodes[1].id, "node-2");
|
||||
|
||||
// In a full integration test, we would verify:
|
||||
// - response.nodeTargets is an array of node objects
|
||||
// - response.nodeCount == 2
|
||||
// - Each node has "id" and "address" fields
|
||||
}
|
||||
|
||||
/// Test 3: Preview endpoint computes diff correctly.
|
||||
#[tokio::test]
|
||||
async fn test_preview_endpoint_computes_diff() {
|
||||
let current = json!({
|
||||
"filterableAttributes": ["category", "price"],
|
||||
"sortableAttributes": ["price"],
|
||||
});
|
||||
|
||||
let proposed = json!({
|
||||
"filterableAttributes": ["category", "price", "brand"],
|
||||
"sortableAttributes": ["price", "name"],
|
||||
"rankingRules": ["words", "typo"],
|
||||
});
|
||||
|
||||
let diff = compute_settings_diff(¤t, &proposed);
|
||||
|
||||
// Should have 3 changes:
|
||||
// - modified: filterableAttributes (array changed)
|
||||
// - modified: sortableAttributes (array changed)
|
||||
// - added: rankingRules (new key)
|
||||
assert_eq!(diff.len(), 3, "should have 3 diff entries");
|
||||
|
||||
// Check for added key
|
||||
let added = diff.iter().find(|d| d["type"] == "added");
|
||||
assert!(added.is_some(), "should have an added entry");
|
||||
let added = added.unwrap();
|
||||
assert_eq!(added["key"], "rankingRules");
|
||||
|
||||
// Check for modified keys
|
||||
let modified: Vec<_> = diff.iter().filter(|d| d["type"] == "modified").collect();
|
||||
assert_eq!(modified.len(), 2, "should have 2 modified entries");
|
||||
}
|
||||
|
||||
/// Test 4: Preview endpoint detects no changes.
|
||||
#[tokio::test]
|
||||
async fn test_preview_endpoint_no_changes() {
|
||||
let settings = json!({
|
||||
"filterableAttributes": ["category", "price"],
|
||||
"sortableAttributes": ["price"],
|
||||
});
|
||||
|
||||
let diff = compute_settings_diff(&settings, &settings);
|
||||
|
||||
assert_eq!(
|
||||
diff.len(),
|
||||
0,
|
||||
"should have no changes when settings are identical"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 5: Preview endpoint handles new index (no current settings).
|
||||
#[tokio::test]
|
||||
async fn test_preview_endpoint_new_index() {
|
||||
let current = json!(null);
|
||||
let proposed = json!({
|
||||
"filterableAttributes": ["category"],
|
||||
});
|
||||
|
||||
let diff = compute_settings_diff(¤t, &proposed);
|
||||
|
||||
assert_eq!(diff.len(), 1, "should have 1 added entry");
|
||||
assert_eq!(diff[0]["type"], "added");
|
||||
assert_eq!(diff[0]["key"], "filterableAttributes");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue