389-ds-base/0024-Issue-7194-Repl-Log-Analysis-Add-CSN-propagation-det.patch
2026-07-21 07:46:19 -04:00

1646 lines
75 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

From c2921e87bf8e8fee566edd09cb528b1c3ac3e41d Mon Sep 17 00:00:00 2001
From: Simon Pichugin <spichugi@redhat.com>
Date: Tue, 3 Feb 2026 17:17:02 -0800
Subject: [PATCH] Issue 7194 - Repl Log Analysis - Add CSN propagation details
(#7195)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Description: The replication log analyzer now shows perCSN propagation
details and the console UI can drill into them from chart points. This
adds CSN IDs to chart datapoints, builds detailed arrivals/hops data, and
links replica IDs to origin servers for more accurate origin detection.
The report JSON now includes csnDetails and sampling metadata; when
sampling is active, CSN details are limited to sampled IDs to control
memory use. A new originIncludedInArrivals flag is exposed and the UI
shows an explicit note when origin records are outside the time range.
The cockpit report modal gains an interactive CSN detail view and
clickable chart points.
Tests were expanded to cover CSN details, origin outofscope behavior,
and partial replication, and include helper functions to reduce duplication.
Fixes: https://github.com/389ds/389-ds-base/issues/7194
Reviewed by: @progier389, @mreynolds389 (Thanks!!)
---
.../replication/repl_log_monitoring_test.py | 481 +++++++++++++++--
.../src/lib/monitor/monitorModals.jsx | 484 +++++++++++++++++-
src/lib389/lib389/repltools.py | 270 +++++++++-
3 files changed, 1166 insertions(+), 69 deletions(-)
diff --git a/dirsrvtests/tests/suites/replication/repl_log_monitoring_test.py b/dirsrvtests/tests/suites/replication/repl_log_monitoring_test.py
index 665fcb96f..855005ff9 100644
--- a/dirsrvtests/tests/suites/replication/repl_log_monitoring_test.py
+++ b/dirsrvtests/tests/suites/replication/repl_log_monitoring_test.py
@@ -21,7 +21,7 @@ from lib389.backend import Backends
from lib389.topologies import topology_m4 as topo_m4
from lib389.idm.user import UserAccount
from lib389.replica import ReplicationManager
-from lib389.repltools import ReplicationLogAnalyzer
+from lib389.repltools import ReplicationLogAnalyzer, DSLogParser
from lib389._constants import *
pytestmark = pytest.mark.tier0
@@ -105,6 +105,101 @@ def _cleanup_multi_suffix_test(test_users_by_suffix, tmp_dir, suppliers, extra_s
log.error(f"Error cleaning up temporary directory: {e}")
+def _clear_access_logs(suppliers):
+ """Clear access logs for all suppliers and restart."""
+ for supplier in suppliers:
+ supplier.deleteAccessLogs(restart=True)
+
+
+def _restart_suppliers(suppliers):
+ """Restart all suppliers."""
+ for supplier in suppliers:
+ supplier.restart()
+
+
+def _get_log_dirs(suppliers):
+ """Return log directories for all suppliers."""
+ return [s.ds_paths.log_dir for s in suppliers]
+
+
+def _load_json(path):
+ """Load and return JSON from file."""
+ with open(path, 'r') as f:
+ return json.load(f)
+
+
+def _pause_agreements(supplier, suffix):
+ """Pause outbound agreements and return list of paused tuples."""
+ paused = []
+ for agmt in supplier.agreement.list(suffix=suffix):
+ supplier.agreement.pause(agmt.dn)
+ paused.append((supplier, agmt.dn))
+ return paused
+
+
+def _resume_agreements(paused_agreements):
+ """Resume paused replication agreements."""
+ for supplier_obj, dn in paused_agreements:
+ try:
+ supplier_obj.agreement.resume(dn)
+ except Exception as e:
+ log.warning(f"Failed to resume agreement {dn}: {e}")
+
+
+def _assert_csn_details_schema(json_data):
+ """Validate csnDetails presence and basic structure."""
+ assert 'csnDetails' in json_data, "Expected csnDetails in JSON output for drill-down"
+ csn_details = json_data['csnDetails']
+ if csn_details:
+ # Check structure of at least one CSN detail entry
+ first_csn = next(iter(csn_details.values()))
+ assert 'csn' in first_csn, "CSN detail should contain 'csn' field"
+ assert 'targetDn' in first_csn, "CSN detail should contain 'targetDn' field"
+ assert 'suffix' in first_csn, "CSN detail should contain 'suffix' field"
+ assert 'globalLag' in first_csn, "CSN detail should contain 'globalLag' field"
+ assert 'originServer' in first_csn, "CSN detail should contain 'originServer' field"
+ assert 'arrivals' in first_csn, "CSN detail should contain 'arrivals' list"
+ assert 'hops' in first_csn, "CSN detail should contain 'hops' list"
+ assert isinstance(first_csn['arrivals'], list), "arrivals should be a list"
+
+ # Verify arrivals structure
+ if first_csn['arrivals']:
+ first_arrival = first_csn['arrivals'][0]
+ assert 'server' in first_arrival, "Arrival should contain 'server' field"
+ assert 'timestamp' in first_arrival, "Arrival should contain 'timestamp' field"
+ assert 'relativeDelay' in first_arrival, "Arrival should contain 'relativeDelay' field"
+
+ # Verify csnId is included in datapoints for cross-reference
+ if 'replicationLags' in json_data and json_data['replicationLags'].get('series'):
+ for series in json_data['replicationLags']['series']:
+ for datapoint in series['datapoints']:
+ assert 'csnId' in datapoint, "Datapoint should contain 'csnId' for drill-down"
+
+ return csn_details
+
+
+def _find_latest_logtime_for_prefix(log_dir, suffix, start_time, end_time, user_prefix):
+ latest = None
+ for fname in os.listdir(log_dir):
+ if not fname.startswith('access'):
+ continue
+ full_path = os.path.join(log_dir, fname)
+ parser = DSLogParser(
+ logname=full_path,
+ suffixes=[suffix],
+ tz=timezone.utc,
+ start_time=start_time,
+ end_time=end_time
+ )
+ for record in parser.parse_file():
+ target_dn = record.get('target_dn') or ''
+ if user_prefix in target_dn:
+ ts = record.get('timestamp')
+ if ts and (latest is None or ts > latest):
+ latest = ts
+ return latest
+
+
def test_replication_log_monitoring_basic(topo_m4):
"""Test basic replication log monitoring functionality
@@ -128,8 +223,7 @@ def test_replication_log_monitoring_basic(topo_m4):
try:
# Clear logs and restart servers
- for supplier in suppliers:
- supplier.deleteAccessLogs(restart=True)
+ _clear_access_logs(suppliers)
# Generate test data with known patterns
log.info('Creating test data...')
@@ -140,11 +234,10 @@ def test_replication_log_monitoring_basic(topo_m4):
repl.test_replication_topology(topo_m4)
# Restart to flush logs
- for supplier in suppliers:
- supplier.restart()
+ _restart_suppliers(suppliers)
# Configure monitoring
- log_dirs = [s.ds_paths.log_dir for s in suppliers]
+ log_dirs = _get_log_dirs(suppliers)
repl_monitor = ReplicationLogAnalyzer(
log_dirs=log_dirs,
suffixes=[DEFAULT_SUFFIX],
@@ -177,23 +270,23 @@ def test_replication_log_monitoring_basic(topo_m4):
assert DEFAULT_SUFFIX in csv_content
# Verify PatternFly JSON content
- with open(generated_files['json'], 'r') as f:
- json_data = json.load(f)
- assert 'replicationLags' in json_data
- assert json_data['replicationLags']['series'], "Expected replication lag series in JSON output"
+ json_data = _load_json(generated_files['json'])
+ assert 'replicationLags' in json_data
+ assert json_data['replicationLags']['series'], "Expected replication lag series in JSON output"
+
+ _assert_csn_details_schema(json_data)
# Verify JSON summary
- with open(generated_files['summary'], 'r') as f:
- summary = json.load(f)
- assert 'analysis_summary' in summary
- stats = summary['analysis_summary']
+ summary = _load_json(generated_files['summary'])
+ assert 'analysis_summary' in summary
+ stats = summary['analysis_summary']
- # Verify basic stats
- assert stats['total_servers'] == len(suppliers)
- assert stats['total_updates'] > 0
- assert stats['updates_by_suffix'][DEFAULT_SUFFIX] > 0
- assert 'average_lag' in stats
- assert 'maximum_lag' in stats
+ # Verify basic stats
+ assert stats['total_servers'] == len(suppliers)
+ assert stats['total_updates'] > 0
+ assert stats['updates_by_suffix'][DEFAULT_SUFFIX] > 0
+ assert 'average_lag' in stats
+ assert 'maximum_lag' in stats
finally:
_cleanup_test_data(test_users, tmp_dir)
@@ -221,8 +314,7 @@ def test_replication_log_monitoring_advanced(topo_m4):
try:
# Clear logs and restart servers
- for supplier in suppliers:
- supplier.deleteAccessLogs(restart=True)
+ _clear_access_logs(suppliers)
# Generate test data
start_time = datetime.now(timezone.utc)
@@ -240,10 +332,9 @@ def test_replication_log_monitoring_advanced(topo_m4):
end_time = datetime.now(timezone.utc)
# Restart to flush logs
- for supplier in suppliers:
- supplier.restart()
+ _restart_suppliers(suppliers)
- log_dirs = [s.ds_paths.log_dir for s in suppliers]
+ log_dirs = _get_log_dirs(suppliers)
# Test 1: Lag time filtering
repl_monitor = ReplicationLogAnalyzer(
@@ -374,8 +465,7 @@ def test_replication_log_monitoring_multi_suffix(topo_m4):
repl = ReplicationManager(suffix)
repl.test_replication_topology(topo_m4)
- for supplier in suppliers:
- supplier.deleteAccessLogs(restart=True)
+ _clear_access_logs(suppliers)
start_time = datetime.now(timezone.utc)
@@ -400,11 +490,10 @@ def test_replication_log_monitoring_multi_suffix(topo_m4):
end_time = datetime.now(timezone.utc)
# Restart to flush logs
- for supplier in suppliers:
- supplier.restart()
+ _restart_suppliers(suppliers)
# Monitor all suffixes
- log_dirs = [s.ds_paths.log_dir for s in suppliers]
+ log_dirs = _get_log_dirs(suppliers)
repl_monitor = ReplicationLogAnalyzer(
log_dirs=log_dirs,
suffixes=all_suffixes,
@@ -466,8 +555,7 @@ def test_replication_log_monitoring_filter_combinations(topo_m4):
try:
# Clear logs and restart servers
- for supplier in suppliers:
- supplier.deleteAccessLogs(restart=True)
+ _clear_access_logs(suppliers)
# Generate varied test data
start_time = datetime.now(timezone.utc)
@@ -475,9 +563,7 @@ def test_replication_log_monitoring_filter_combinations(topo_m4):
# Create different lag patterns
# Pause outbound agreements from supplier1 to build a replication backlog
- for agmt in suppliers[0].agreement.list(suffix=DEFAULT_SUFFIX):
- suppliers[0].agreement.pause(agmt.dn)
- paused_agreements.append((suppliers[0], agmt.dn))
+ paused_agreements = _pause_agreements(suppliers[0], DEFAULT_SUFFIX)
for i, user in enumerate(test_users):
if i % 3 == 0:
@@ -502,10 +588,9 @@ def test_replication_log_monitoring_filter_combinations(topo_m4):
end_time = datetime.now(timezone.utc)
# Restart to flush logs
- for supplier in suppliers:
- supplier.restart()
+ _restart_suppliers(suppliers)
- log_dirs = [s.ds_paths.log_dir for s in suppliers]
+ log_dirs = _get_log_dirs(suppliers)
# Test combined filters
lag_threshold = 0.5
@@ -543,11 +628,321 @@ def test_replication_log_monitoring_filter_combinations(topo_m4):
dt = datetime.fromtimestamp(t, timezone.utc)
assert start_time <= dt <= end_time, "Time range filter violated"
finally:
- for supplier_obj, dn in paused_agreements:
- try:
- supplier_obj.agreement.resume(dn)
- except Exception as e:
- log.warning(f"Failed to resume agreement {dn}: {e}")
+ _resume_agreements(paused_agreements)
+ _cleanup_test_data(test_users, tmp_dir)
+
+
+def test_replication_log_monitoring_csn_details_edge_cases(topo_m4):
+ """Test CSN details edge cases and structure validation
+
+ :id: f43dc473-4428-4971-be4c-169c4a78726e
+ :setup: Four suppliers replication setup
+ :steps:
+ 1. Test CSN details structure with various replication patterns
+ 2. Verify arrivals ordering and hop lag calculations
+ 3. Test partial replication scenarios
+ 4. Verify origin server detection
+ :expectedresults:
+ 1. CSN details should have correct structure
+ 2. Arrivals should be ordered by timestamp
+ 3. Partial replication should be detected
+ 4. Origin server should be correctly identified
+ """
+ tmp_dir = tempfile.mkdtemp(prefix='repl_csn_edge_')
+ test_users = []
+ suppliers = [topo_m4.ms[f"supplier{i}"] for i in range(1, 5)]
+
+ try:
+ _clear_access_logs(suppliers)
+
+ log.info('Creating test data for CSN details edge case testing...')
+ test_users = _generate_test_data(suppliers[0], DEFAULT_SUFFIX, 5)
+
+ repl = ReplicationManager(DEFAULT_SUFFIX)
+ repl.test_replication_topology(topo_m4)
+
+ _restart_suppliers(suppliers)
+
+ log_dirs = _get_log_dirs(suppliers)
+ repl_monitor = ReplicationLogAnalyzer(
+ log_dirs=log_dirs,
+ suffixes=[DEFAULT_SUFFIX],
+ anonymous=False,
+ only_fully_replicated=True
+ )
+
+ repl_monitor.parse_logs()
+ generated_files = repl_monitor.generate_report(
+ output_dir=tmp_dir,
+ formats=['json'],
+ report_name='csn_edge_test'
+ )
+
+ assert os.path.exists(generated_files['json'])
+
+ json_data = _load_json(generated_files['json'])
+
+ assert 'csnDetails' in json_data, "csnDetails should be present"
+ csn_details = json_data['csnDetails']
+
+ if csn_details:
+ for csn, details in csn_details.items():
+ arrivals = details.get('arrivals', [])
+ if len(arrivals) > 1:
+ timestamps = [a['timestamp'] for a in arrivals]
+ assert timestamps == sorted(timestamps), \
+ f"Arrivals for CSN {csn} should be ordered by timestamp"
+
+ if arrivals:
+ origin_arrival = next((a for a in arrivals if a.get('isOrigin')), None)
+ assert origin_arrival is not None, "Expected an arrival marked as origin"
+ assert origin_arrival.get('server') == details.get('originServer'), \
+ "Origin arrival server should match originServer field"
+
+ for i, arrival in enumerate(arrivals[1:], start=1):
+ assert 'hopLag' in arrival, \
+ f"Arrival {i} should have hopLag"
+ assert arrival['hopLag'] >= 0, \
+ "hopLag should be non-negative"
+
+ if len(arrivals) > 1:
+ global_lag = details.get('globalLag', 0)
+ assert global_lag >= 0, "globalLag should be non-negative"
+
+ last_delay = arrivals[-1].get('relativeDelay', 0)
+ assert abs(last_delay - global_lag) < 0.001, \
+ "Last arrival's relativeDelay should match globalLag"
+
+ server_count = details.get('serverCount', 0)
+ assert server_count == len(arrivals), \
+ "serverCount should match number of arrivals"
+
+ total_hops = details.get('totalHops', 0)
+ expected_hops = max(0, len(arrivals) - 1)
+ assert total_hops == expected_hops, \
+ f"totalHops should be {expected_hops}, got {total_hops}"
+
+ hops = details.get('hops', [])
+ assert len(hops) == total_hops, \
+ "hops list length should match totalHops"
+
+ if 'replicationLags' in json_data and json_data['replicationLags'].get('series'):
+ for series in json_data['replicationLags']['series']:
+ for datapoint in series.get('datapoints', []):
+ csn_id = datapoint.get('csnId')
+ if csn_id:
+ assert csn_id in csn_details, \
+ f"csnId {csn_id} in datapoint should exist in csnDetails"
+
+ finally:
+ _cleanup_test_data(test_users, tmp_dir)
+
+
+def test_replication_log_monitoring_origin_out_of_scope(topo_m4):
+ """Test origin detection when origin server record is outside time range
+
+ :id: d73fd9c5-f930-47d6-ade4-ada1cf0d2c21
+ :setup: Four suppliers replication setup
+ :steps:
+ 1. Pause outbound agreements from the origin supplier
+ 2. Generate changes, then resume agreements to delay consumer arrivals
+ 3. Use time range that excludes the origin log but includes consumer logs
+ :expectedresults:
+ 1. Origin server should be identified from replica ID mapping
+ 2. At least one CSN should have origin outside the selected time range
+ 3. JSON report should include csnDetails entries for validation
+ """
+ tmp_dir = tempfile.mkdtemp(prefix='repl_origin_scope_')
+ test_users = []
+ suppliers = [topo_m4.ms[f"supplier{i}"] for i in range(1, 5)]
+ paused_agreements = []
+
+ try:
+ # Reset access logs to make time-range cuts easier to reason about
+ _clear_access_logs(suppliers)
+
+ # Pause outbound agreements so consumer logs won't see the pre-resume CSNs
+ paused_agreements = _pause_agreements(suppliers[0], DEFAULT_SUFFIX)
+
+ log.info('Creating pre-resume changes for origin out-of-scope test...')
+ # Create CSNs that originate on supplier1 but won't reach consumers yet
+ pre_start = datetime.now(timezone.utc)
+ test_users = _generate_test_data(
+ suppliers[0], DEFAULT_SUFFIX, 2, user_prefix="origin_scope_pre"
+ )
+ pre_end = datetime.now(timezone.utc)
+ # Locate the latest origin log time for these CSNs to set a deterministic cutoff
+ origin_log_time = _find_latest_logtime_for_prefix(
+ suppliers[0].ds_paths.log_dir,
+ DEFAULT_SUFFIX,
+ pre_start,
+ pre_end,
+ "origin_scope_pre_"
+ )
+ assert origin_log_time is not None, "Expected origin server log entries for pre-resume data"
+ # Cut the analysis window just after origin logging, excluding supplier1 entries
+ start_time = origin_log_time + timedelta(seconds=1)
+ time.sleep(1)
+
+ # Resume agreements so the pre-resume CSNs replicate to consumers after start_time
+ _resume_agreements(paused_agreements)
+ paused_agreements.clear()
+
+ log.info('Creating post-resume changes for origin mapping...')
+ # Additional CSNs after resume ensure normal replication continues
+ test_users += _generate_test_data(
+ suppliers[0], DEFAULT_SUFFIX, 2, user_prefix="origin_scope_post"
+ )
+
+ # Wait for replication to finish and capture the upper bound of the time window
+ repl = ReplicationManager(DEFAULT_SUFFIX)
+ repl.test_replication_topology(topo_m4)
+ end_time = datetime.now(timezone.utc)
+
+ # Restart to flush logs before analysis
+ _restart_suppliers(suppliers)
+
+ log_dirs = _get_log_dirs(suppliers)
+ repl_monitor = ReplicationLogAnalyzer(
+ log_dirs=log_dirs,
+ suffixes=[DEFAULT_SUFFIX],
+ time_range={'start': start_time, 'end': end_time}
+ )
+
+ # Parse logs within the time window and produce JSON details
+ repl_monitor.parse_logs()
+ generated_files = repl_monitor.generate_report(
+ output_dir=tmp_dir,
+ formats=['json'],
+ report_name='origin_scope_test'
+ )
+
+ json_data = _load_json(generated_files['json'])
+
+ csn_details = json_data.get('csnDetails', {})
+ origin_server = suppliers[0].serverid
+ found = False
+ if csn_details:
+ origin_counts = {}
+ for details in csn_details.values():
+ origin = details.get('originServer', 'unknown')
+ origin_counts[origin] = origin_counts.get(origin, 0) + 1
+ if origin_server not in origin_counts and f"slapd-{origin_server}" in origin_counts:
+ origin_server = f"slapd-{origin_server}"
+
+ # Focus on the pre-resume CSNs; these should have origin out of scope
+ pre_details = [
+ details for details in csn_details.values()
+ if "origin_scope_pre_" in (details.get('targetDn') or '')
+ ]
+ assert pre_details, "Expected pre-resume CSNs in csnDetails"
+ # Confirm at least one CSN shows origin server missing from arrivals
+ for details in pre_details:
+ if details.get('originServer') != origin_server:
+ continue
+ arrivals = details.get('arrivals', [])
+ arrival_servers = {a.get('server') for a in arrivals}
+ if arrival_servers and origin_server not in arrival_servers:
+ found = True
+ break
+ log.info(
+ "Origin out-of-scope candidate: csn=%s arrivals=%s",
+ details.get('csn'),
+ sorted(arrival_servers)
+ )
+
+ assert found, (
+ "Expected at least one CSN where the origin server is outside the time range "
+ "but still identified via replica ID mapping"
+ )
+
+ finally:
+ _resume_agreements(paused_agreements)
+ _cleanup_test_data(test_users, tmp_dir)
+
+
+def test_replication_log_monitoring_partial_replication(topo_m4):
+ """Test CSN details with partial replication (not all servers reached)
+
+ :id: d4026fd0-d83b-400e-8c2e-44fcf676368f
+ :setup: Four suppliers replication setup
+ :steps:
+ 1. Pause replication agreements to create partial replication
+ 2. Generate changes and verify partial replication detection
+ 3. Verify replicatedToAll flag is correct
+ :expectedresults:
+ 1. Partial replication should be detected
+ 2. replicatedToAll should be False for partially replicated CSNs
+ 3. serverCount should reflect actual servers reached
+ """
+ tmp_dir = tempfile.mkdtemp(prefix='repl_partial_')
+ test_users = []
+ suppliers = [topo_m4.ms[f"supplier{i}"] for i in range(1, 5)]
+ paused_agreements = []
+
+ try:
+ _clear_access_logs(suppliers)
+
+ log.info('Creating fully replicated test data...')
+ test_users = _generate_test_data(suppliers[0], DEFAULT_SUFFIX, 3)
+
+ repl = ReplicationManager(DEFAULT_SUFFIX)
+ repl.test_replication_topology(topo_m4)
+
+ _restart_suppliers(suppliers)
+
+ # Pause outbound agreements from supplier1 to create partial replication
+ paused_agreements = _pause_agreements(suppliers[0], DEFAULT_SUFFIX)
+
+ log.info('Creating partially replicated test data...')
+ test_users += _generate_test_data(suppliers[0], DEFAULT_SUFFIX, 3, user_prefix="partial_user")
+
+ # Allow some time for local logging; do not wait for full replication
+ time.sleep(2)
+
+ log_dirs = _get_log_dirs(suppliers)
+ repl_monitor = ReplicationLogAnalyzer(
+ log_dirs=log_dirs,
+ suffixes=[DEFAULT_SUFFIX],
+ anonymous=False,
+ only_fully_replicated=False,
+ only_not_replicated=False
+ )
+
+ repl_monitor.parse_logs()
+ generated_files = repl_monitor.generate_report(
+ output_dir=tmp_dir,
+ formats=['json'],
+ report_name='partial_repl_test'
+ )
+
+ json_data = _load_json(generated_files['json'])
+
+ assert 'csnDetails' in json_data
+ csn_details = json_data['csnDetails']
+
+ if csn_details:
+ fully_replicated_count = sum(
+ 1 for details in csn_details.values()
+ if details.get('replicatedToAll', False)
+ )
+
+ assert fully_replicated_count > 0, \
+ "Should have some fully replicated CSNs"
+
+ total_servers = len(suppliers)
+ for csn, details in csn_details.items():
+ server_count = details.get('serverCount', 0)
+ assert server_count <= total_servers, \
+ f"serverCount ({server_count}) should not exceed total servers ({total_servers})"
+
+ replicated_to_all = details.get('replicatedToAll', False)
+ if replicated_to_all:
+ assert server_count == total_servers, \
+ "replicatedToAll=True requires serverCount == total_servers"
+
+ finally:
+ _resume_agreements(paused_agreements)
_cleanup_test_data(test_users, tmp_dir)
diff --git a/src/cockpit/389-console/src/lib/monitor/monitorModals.jsx b/src/cockpit/389-console/src/lib/monitor/monitorModals.jsx
index facbd9f5f..3c5a46a5b 100644
--- a/src/cockpit/389-console/src/lib/monitor/monitorModals.jsx
+++ b/src/cockpit/389-console/src/lib/monitor/monitorModals.jsx
@@ -3,17 +3,22 @@ import React from "react";
import {
Button,
Checkbox,
+ ClipboardCopy,
+ ClipboardCopyVariant,
EmptyState,
EmptyStateIcon,
EmptyStateBody,
Grid,
GridItem,
Form,
+ Label,
Modal,
ModalVariant,
NumberInput,
Radio,
Spinner,
+ Split,
+ SplitItem,
Tab,
Tabs,
TabTitleText,
@@ -36,9 +41,12 @@ import {
ListItem
} from "@patternfly/react-core";
import {
+ ArrowRightIcon,
+ CheckCircleIcon,
CopyIcon,
OutlinedQuestionCircleIcon,
DownloadIcon,
+ ServerIcon
} from '@patternfly/react-icons';
import PropTypes from "prop-types";
import { get_date_string } from "../tools.jsx";
@@ -71,6 +79,19 @@ const MAX_REPORT_JSON_SIZE = 64 * 1024 * 1024; // 64 MiB
const MAX_BINARY_READ_SIZE = 64 * 1024 * 1024; // 64 MiB
const CSV_PREVIEW_LINES = 20;
+const formatLagSeconds = (seconds, precision = 3) => {
+ if (seconds === undefined || seconds === null) {
+ return null;
+ }
+ if (seconds >= 3600) {
+ return `${(seconds / 3600).toFixed(precision)}h`;
+ }
+ if (seconds >= 60) {
+ return `${(seconds / 60).toFixed(precision)}m`;
+ }
+ return `${seconds.toFixed(precision)}s`;
+};
+
class TaskLogModal extends React.Component {
render() {
const {
@@ -1168,6 +1189,13 @@ class ScatterLineChart extends React.PureComponent {
}, 250);
};
this.toggleLegendItem = this.toggleLegendItem.bind(this);
+ this.handlePointClick = this.handlePointClick.bind(this);
+ }
+
+ handlePointClick(datum, seriesIndex) {
+ if (this.props.onPointClick && datum.csnId) {
+ this.props.onPointClick(datum);
+ }
}
componentDidMount() {
@@ -1267,15 +1295,7 @@ class ScatterLineChart extends React.PureComponent {
const { series, yDomain } = this._getSeriesSnapshot();
// Helper function to format time values
- const formatTimeValue = (seconds) => {
- if (seconds >= 3600) {
- return `${(seconds / 3600).toFixed(3)}h`;
- } else if (seconds >= 60) {
- return `${(seconds / 60).toFixed(3)}m`;
- } else {
- return `${seconds.toFixed(3)}s`;
- }
- };
+ const formatTimeValue = (seconds) => formatLagSeconds(seconds, 3);
// Process tooltip HTML tags
const formatTooltip = (datum) => {
@@ -1308,13 +1328,35 @@ class ScatterLineChart extends React.PureComponent {
labels={({ datum }) => formatTooltip(datum)}
constrainToVisibleArea
labelComponent={
- <ChartTooltip
- style={{
- fontSize: "12px",
- padding: 10,
- whiteSpace: "pre-line" // Important for newlines
- }}
- />
+ <ChartTooltip
+ orientation={({ datum }) => {
+ // Position tooltip below for high points, above for low points
+ // This prevents the tooltip from blocking clicks on points near the top
+ const yMax = yDomain.max;
+ const yMin = yDomain.min;
+ const yRange = yMax - yMin;
+ const threshold = yMin + (yRange * 0.6);
+ return datum.y > threshold ? "bottom" : "top";
+ }}
+ style={{
+ fontSize: "12px",
+ padding: 10,
+ whiteSpace: "pre-line", // Important for newlines
+ pointerEvents: "none"
+ }}
+ flyoutStyle={{
+ pointerEvents: "none"
+ }}
+ dx={0}
+ dy={({ datum }) => {
+ // Add extra offset to keep tooltip away from the point
+ const yMax = yDomain.max;
+ const yMin = yDomain.min;
+ const yRange = yMax - yMin;
+ const threshold = yMin + (yRange * 0.6);
+ return datum.y > threshold ? 10 : -10;
+ }}
+ />
}
/>
}
@@ -1410,6 +1452,7 @@ class ScatterLineChart extends React.PureComponent {
if (this.state.hiddenSeries[idx]) {
return null;
}
+ const hasClickHandler = !!this.props.onPointClick;
return (
<ChartScatter
key={`scatter-${idx}`}
@@ -1417,9 +1460,39 @@ class ScatterLineChart extends React.PureComponent {
data={s.datapoints}
style={{
data: {
- fill: s.color
+ fill: s.color,
+ cursor: hasClickHandler ? 'pointer' : 'default'
}
}}
+ events={hasClickHandler ? [{
+ target: "data",
+ eventHandlers: {
+ onClick: () => [{
+ target: "data",
+ mutation: (props) => {
+ this.handlePointClick(props.datum, idx);
+ return null;
+ }
+ }],
+ onMouseOver: () => [{
+ target: "data",
+ mutation: (props) => ({
+ style: {
+ ...props.style,
+ fill: s.color,
+ cursor: 'pointer',
+ strokeWidth: 2,
+ stroke: 'var(--pf-v5-global--active-color--100, #0066cc)',
+ r: 6
+ }
+ })
+ }],
+ onMouseOut: () => [{
+ target: "data",
+ mutation: () => null
+ }]
+ }
+ }] : undefined}
/>
);
})}
@@ -1501,17 +1574,320 @@ class ScatterLineChart extends React.PureComponent {
}
}
+/**
+ * CSNDetailModal - Displays detailed CSN propagation path information
+ * Shows the hop-by-hop timing of how a change propagated through the replication topology
+ */
+class CSNDetailModal extends React.Component {
+ constructor(props) {
+ super(props);
+ this.formatTimestamp = this.formatTimestamp.bind(this);
+ this.formatLag = this.formatLag.bind(this);
+ }
+
+ formatTimestamp(isoString) {
+ if (!isoString) return _("Unknown");
+ try {
+ const date = new Date(isoString);
+ if (isNaN(date.getTime())) {
+ console.warn("Invalid timestamp format:", isoString);
+ return cockpit.format(_("Invalid: $0"), isoString);
+ }
+ return date.toLocaleString(undefined, {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ fractionalSecondDigits: 3,
+ hour12: false
+ });
+ } catch (e) {
+ console.warn("Error formatting timestamp:", isoString, e);
+ return cockpit.format(_("Invalid: $0"), isoString);
+ }
+ }
+
+ formatLag(seconds) {
+ return formatLagSeconds(seconds, 3) || _("N/A");
+ }
+
+ render() {
+ const { csnData, onClose } = this.props;
+
+ if (!csnData) {
+ return null;
+ }
+
+ const arrivals = csnData.arrivals || [];
+ const pathJson = JSON.stringify(csnData, null, 2);
+
+ return (
+ <Modal
+ variant={ModalVariant.large}
+ title={_("CSN Propagation Details")}
+ isOpen={!!csnData}
+ onClose={onClose}
+ aria-label={_("CSN propagation details")}
+ actions={[
+ <Button key="close" variant="primary" onClick={onClose}>
+ {_("Close")}
+ </Button>
+ ]}
+ >
+ {/* CSN Summary Information */}
+ <Card isFlat className="ds-margin-bottom-md">
+ <CardBody>
+ <Grid hasGutter>
+ <GridItem span={6}>
+ <DescriptionList isHorizontal isCompact>
+ <DescriptionListGroup>
+ <DescriptionListTerm>{_("CSN")}</DescriptionListTerm>
+ <DescriptionListDescription>
+ <ClipboardCopy
+ variant={ClipboardCopyVariant.inline}
+ >
+ {csnData.csn}
+ </ClipboardCopy>
+ </DescriptionListDescription>
+ </DescriptionListGroup>
+ <DescriptionListGroup>
+ <DescriptionListTerm>{_("Entry DN")}</DescriptionListTerm>
+ <DescriptionListDescription>
+ <Tooltip content={csnData.targetDn}>
+ <span className="pf-v5-u-text-truncate" style={{ maxWidth: '300px', display: 'inline-block' }}>
+ {csnData.targetDn}
+ </span>
+ </Tooltip>
+ </DescriptionListDescription>
+ </DescriptionListGroup>
+ <DescriptionListGroup>
+ <DescriptionListTerm>{_("Suffix")}</DescriptionListTerm>
+ <DescriptionListDescription>{csnData.suffix}</DescriptionListDescription>
+ </DescriptionListGroup>
+ </DescriptionList>
+ </GridItem>
+ <GridItem span={6}>
+ <DescriptionList isHorizontal isCompact>
+ <DescriptionListGroup>
+ <DescriptionListTerm>{_("Origin Server")}</DescriptionListTerm>
+ <DescriptionListDescription>
+ <Label color="blue" icon={<ServerIcon />}>
+ {csnData.originServer}
+ </Label>
+ </DescriptionListDescription>
+ </DescriptionListGroup>
+ {csnData.originIncludedInArrivals === false && (
+ <DescriptionListGroup>
+ <DescriptionListTerm>{_("Origin Note")}</DescriptionListTerm>
+ <DescriptionListDescription>
+ <Text component={TextVariants.small} style={{ color: 'var(--pf-v5-global--Color--200)' }}>
+ {_("Origin server record is outside the selected time range; entry details reflect the earliest arrival.")}
+ </Text>
+ </DescriptionListDescription>
+ </DescriptionListGroup>
+ )}
+ <DescriptionListGroup>
+ <DescriptionListTerm>{_("Total Lag")}</DescriptionListTerm>
+ <DescriptionListDescription>
+ <strong>{this.formatLag(csnData.globalLag)}</strong>
+ </DescriptionListDescription>
+ </DescriptionListGroup>
+ <DescriptionListGroup>
+ <DescriptionListTerm>{_("Servers Reached")}</DescriptionListTerm>
+ <DescriptionListDescription>
+ {csnData.serverCount}
+ {csnData.replicatedToAll && (
+ <Label color="green" icon={<CheckCircleIcon />} className="ds-left-margin">
+ {_("All")}
+ </Label>
+ )}
+ </DescriptionListDescription>
+ </DescriptionListGroup>
+ </DescriptionList>
+ </GridItem>
+ </Grid>
+ </CardBody>
+ </Card>
+
+ {/* Arrival Timeline Visualization */}
+ <Card isFlat className="ds-margin-bottom-md">
+ <CardTitle>{_("Arrival Timeline")}</CardTitle>
+ <CardBody>
+ <Text
+ component={TextVariants.small}
+ className="ds-margin-bottom"
+ style={{ color: 'var(--pf-v5-global--Color--200)', fontStyle: 'italic' }}
+ >
+ {_("Note: Shows arrival order by time. Actual replication topology may differ in fan-out configurations.")}
+ </Text>
+ <div
+ role="list"
+ aria-label={_("CSN propagation timeline")}
+ style={{
+ display: 'flex',
+ flexWrap: 'wrap',
+ alignItems: 'center',
+ gap: 'var(--pf-v5-global--spacer--sm)',
+ padding: 'var(--pf-v5-global--spacer--sm)'
+ }}
+ >
+ {arrivals.map((arrival, idx) => (
+ <React.Fragment key={idx}>
+ {/* Server Node */}
+ <div
+ role="listitem"
+ aria-label={cockpit.format(
+ arrival.isOrigin
+ ? _("Origin server: $0")
+ : _("Server $0, delay: $1"),
+ arrival.server,
+ arrival.isOrigin ? "" : this.formatLag(arrival.relativeDelay)
+ )}
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: 'var(--pf-v5-global--spacer--sm)',
+ backgroundColor: arrival.isOrigin
+ ? 'var(--pf-v5-global--palette--blue-50, #e7f1fa)'
+ : 'var(--pf-v5-global--BackgroundColor--200, #f0f0f0)',
+ borderRadius: 'var(--pf-v5-global--BorderRadius--sm)',
+ border: arrival.isOrigin
+ ? '2px solid var(--pf-v5-global--primary-color--100, #0066cc)'
+ : '1px solid var(--pf-v5-global--BorderColor--100, #d2d2d2)',
+ minWidth: '120px'
+ }}>
+ <Text component={TextVariants.small} style={{ fontWeight: 'bold' }}>
+ {arrival.server}
+ </Text>
+ <Text component={TextVariants.small} style={{ fontSize: '0.75rem', color: 'var(--pf-v5-global--Color--200)' }}>
+ {this.formatTimestamp(arrival.timestamp)}
+ </Text>
+ {arrival.isOrigin && (
+ <Label color="blue" isCompact style={{ marginTop: '4px' }}>
+ {_("Origin")}
+ </Label>
+ )}
+ {!arrival.isOrigin && (
+ <Text component={TextVariants.small} style={{ marginTop: '4px', color: 'var(--pf-v5-global--success-color--100)' }}>
+ +{this.formatLag(arrival.relativeDelay)}
+ </Text>
+ )}
+ </div>
+
+ {/* Arrow between nodes */}
+ {idx < arrivals.length - 1 && (
+ <div
+ role="presentation"
+ aria-hidden="true"
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: '0 var(--pf-v5-global--spacer--xs)'
+ }}
+ >
+ <ArrowRightIcon style={{ color: 'var(--pf-v5-global--Color--200)' }} />
+ <Text component={TextVariants.small} style={{
+ fontSize: '0.7rem',
+ color: 'var(--pf-v5-global--Color--200)',
+ whiteSpace: 'nowrap'
+ }}>
+ {arrivals[idx + 1].hopLag !== undefined
+ ? this.formatLag(arrivals[idx + 1].hopLag)
+ : ''}
+ </Text>
+ </div>
+ )}
+ </React.Fragment>
+ ))}
+ </div>
+ </CardBody>
+ </Card>
+
+ {/* Detailed Arrivals Table */}
+ <Card isFlat className="ds-margin-bottom-md">
+ <CardTitle>{_("Arrival Details")}</CardTitle>
+ <CardBody>
+ <table className="pf-v5-c-table pf-m-compact" role="grid">
+ <thead>
+ <tr>
+ <th>{_("Server")}</th>
+ <th>{_("Arrival Time")}</th>
+ <th>{_("Hop Lag")}</th>
+ <th>{_("Cumulative Delay")}</th>
+ <th>{_("Duration")}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {arrivals.map((arrival, idx) => (
+ <tr key={idx}>
+ <td>
+ {arrival.server}
+ {arrival.isOrigin && (
+ <span style={{ color: 'var(--pf-v5-global--primary-color--100)', marginLeft: 'var(--pf-v5-global--spacer--xs)' }}>
+ ({_("Origin")})
+ </span>
+ )}
+ </td>
+ <td>{this.formatTimestamp(arrival.timestamp)}</td>
+ <td>
+ {arrival.isOrigin
+ ? <em>{_("Origin")}</em>
+ : this.formatLag(arrival.hopLag)}
+ </td>
+ <td>{this.formatLag(arrival.relativeDelay)}</td>
+ <td>{arrival.duration ? this.formatLag(arrival.duration) : _("N/A")}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </CardBody>
+ </Card>
+
+ {/* Copy Actions */}
+ <Split hasGutter>
+ <SplitItem>
+ <ClipboardCopy
+ variant={ClipboardCopyVariant.expansion}
+ isExpanded={false}
+ isCode
+ isReadOnly
+ hoverTip={_("Copy full path JSON")}
+ clickTip={_("Copied!")}
+ >
+ {pathJson}
+ </ClipboardCopy>
+ </SplitItem>
+ </Split>
+ </Modal>
+ );
+ }
+}
+
+CSNDetailModal.propTypes = {
+ csnData: PropTypes.object,
+ onClose: PropTypes.func.isRequired
+};
+
+CSNDetailModal.defaultProps = {
+ csnData: null
+};
+
class LagReportModal extends React.Component {
constructor(props) {
super(props);
this.state = {
- activeTabKey: 0, // 0 = Summary, 1 = Charts, 2 = PNG Report, 3 = CSV Report, 4 = Report Files
+ activeTabKey: 0,
...this._freshReportState(),
loadingSummary: false,
loadingJson: false,
loadingCsv: false,
- loadingPng: false
+ loadingPng: false,
+ selectedCsnId: null
};
this.handleTabClick = this.handleTabClick.bind(this);
@@ -1524,6 +1900,8 @@ class LagReportModal extends React.Component {
this.renderPngTab = this.renderPngTab.bind(this);
this.renderCsvTab = this.renderCsvTab.bind(this);
this.renderReportFilesTab = this.renderReportFilesTab.bind(this);
+ this.handleCsnPointClick = this.handleCsnPointClick.bind(this);
+ this.handleCloseCsnDetails = this.handleCloseCsnDetails.bind(this);
this._activeLoadToken = 0;
this._isMounted = false;
@@ -1543,10 +1921,26 @@ class LagReportModal extends React.Component {
summary: null,
suffixStats: {},
clientSamplingNotice: null,
+ selectedCsnId: null,
...overrides
};
}
+ handleCsnPointClick(datum) {
+ if (datum && datum.csnId) {
+ const { jsonData } = this.state;
+ if (jsonData && jsonData.csnDetails && jsonData.csnDetails[datum.csnId]) {
+ this.setState({ selectedCsnId: datum.csnId });
+ } else {
+ console.warn("CSN details not available for:", datum.csnId);
+ }
+ }
+ }
+
+ handleCloseCsnDetails() {
+ this.setState({ selectedCsnId: null });
+ }
+
componentDidMount() {
this._isMounted = true;
this.loadData();
@@ -2117,7 +2511,7 @@ class LagReportModal extends React.Component {
renderChartsTab() {
const { reportUrls } = this.props;
- const { loadingJson, jsonData, error, clientSamplingNotice } = this.state;
+ const { loadingJson, jsonData, error, clientSamplingNotice, selectedCsnId } = this.state;
if (loadingJson) {
return (
@@ -2170,6 +2564,13 @@ class LagReportModal extends React.Component {
jsonData.hopLags.series &&
jsonData.hopLags.series.length > 0;
+ const hasCsnDetails = jsonData && jsonData.csnDetails &&
+ Object.keys(jsonData.csnDetails).length > 0;
+
+ const selectedCsnData = selectedCsnId && hasCsnDetails
+ ? jsonData.csnDetails[selectedCsnId]
+ : null;
+
if (!jsonData || (!hasReplicationLags && !hasHopLags)) {
return (
<EmptyState>
@@ -2201,6 +2602,15 @@ class LagReportModal extends React.Component {
{clientSamplingNotice}
</Alert>
)}
+ {hasCsnDetails && (
+ <Text
+ component={TextVariants.small}
+ className="ds-margin-bottom"
+ style={{ color: 'var(--pf-v5-global--Color--200)', fontStyle: 'italic' }}
+ >
+ {_("Tip: Click on any chart point to view detailed CSN propagation path.")}
+ </Text>
+ )}
{hasReplicationLags && (
<div className="ds-margin-bottom">
<Title headingLevel="h3">
@@ -2231,6 +2641,7 @@ class LagReportModal extends React.Component {
xAxisLabel={(jsonData.replicationLags.xAxisLabel || "").replace(/\s*Time\s*/g, "")}
yAxisLabel={jsonData.replicationLags.yAxisLabel || _("Lag Time (seconds)")}
defaultShowLegend={true}
+ onPointClick={hasCsnDetails ? this.handleCsnPointClick : undefined}
/>
</div>
)}
@@ -2250,9 +2661,17 @@ class LagReportModal extends React.Component {
xAxisLabel={(jsonData.hopLags.xAxisLabel || "").replace(/\s*Time\s*/g, "")}
yAxisLabel={jsonData.hopLags.yAxisLabel || _("Hop Lag Time (seconds)")}
defaultShowLegend={false}
+ onPointClick={hasCsnDetails ? this.handleCsnPointClick : undefined}
/>
</div>
)}
+
+ {/* CSN Detail Panel - shown when a point is clicked */}
+ <CSNDetailModal
+ csnData={selectedCsnData}
+ onClose={this.handleCloseCsnDetails}
+ />
+
<div className="ds-margin-top">
<Button
variant="secondary"
@@ -2876,6 +3295,28 @@ class ChooseLagReportModal extends React.Component {
}
// Prototypes and defaultProps
+ScatterLineChart.propTypes = {
+ chartData: PropTypes.object,
+ title: PropTypes.string,
+ yAxisLabel: PropTypes.string,
+ xAxisLabel: PropTypes.string,
+ minY: PropTypes.number,
+ maxY: PropTypes.number,
+ defaultShowLegend: PropTypes.bool,
+ onPointClick: PropTypes.func
+};
+
+ScatterLineChart.defaultProps = {
+ chartData: null,
+ title: "",
+ yAxisLabel: "Value",
+ xAxisLabel: "",
+ minY: null,
+ maxY: null,
+ defaultShowLegend: true,
+ onPointClick: null
+};
+
AgmtDetailsModal.propTypes = {
showModal: PropTypes.bool,
closeHandler: PropTypes.func,
@@ -3044,6 +3485,7 @@ export {
ReportLoginModal,
FullReportContent,
LagReportModal,
- ChooseLagReportModal
+ ChooseLagReportModal,
+ CSNDetailModal
};
diff --git a/src/lib389/lib389/repltools.py b/src/lib389/lib389/repltools.py
index 9d1aa4058..5ab8b3187 100644
--- a/src/lib389/lib389/repltools.py
+++ b/src/lib389/lib389/repltools.py
@@ -683,6 +683,7 @@ class ChartData(NamedTuple):
lags: List[float]
durations: List[float]
hover: List[str]
+ csn_ids: List[str]
class VisualizationHelper:
"""Helper class for visualization-related functionality."""
@@ -730,7 +731,7 @@ class VisualizationHelper:
tz: tzinfo = timezone.utc) -> Dict[Tuple[str, str], ChartData]:
"""Prepare data for visualization with timezone-aware timestamps."""
chart_data = defaultdict(lambda: {
- 'times': [], 'lags': [], 'durations': [], 'hover': []
+ 'times': [], 'lags': [], 'durations': [], 'hover': [], 'csn_ids': []
})
for csn, server_map in csns.items():
@@ -766,6 +767,7 @@ class VisualizationHelper:
data_slot['times'].append(ts_dt)
data_slot['lags'].append(lag_val) # The same global-lag for all servers
data_slot['durations'].append(duration_val)
+ data_slot['csn_ids'].append(csn)
# Format timestamp for hover display in the specified timezone
timestamp_str = ts_dt.strftime('%Y-%m-%d %H:%M:%S')
data_slot['hover'].append(
@@ -784,7 +786,8 @@ class VisualizationHelper:
times=value['times'],
lags=value['lags'],
durations=value['durations'],
- hover=value['hover']
+ hover=value['hover'],
+ csn_ids=value['csn_ids']
)
for key, value in chart_data.items()
}
@@ -803,6 +806,14 @@ class ReplicationLogAnalyzer:
AUTO_SAMPLING_THRESHOLD = 4000 # Trigger auto sampling above this many CSN points
HOP_SERIES_BUDGET_RATIO = 0.25 # Allocate max 25% of chart points to hop lag series
MIN_POINTS_PER_SERIES = 2 # Minimum points to preserve series shape after sampling
+ MAX_CSN_DETAILS = 10000 # Maximum CSN details to include (prevents memory issues)
+
+ # CSN format: TTTTTTTTSSSSRRRRNNNN (20 hex chars)
+ # T=timestamp(8), S=sequence(4), R=replicaID(4), N=subseq(4)
+ CSN_TIMESTAMP_START = 0
+ CSN_TIMESTAMP_END = 8
+ CSN_REPLICA_ID_START = 12
+ CSN_REPLICA_ID_END = 16
# Precision preset configurations
PRECISION_PRESETS = {
@@ -867,6 +878,54 @@ class ReplicationLogAnalyzer:
# Threshold to trigger auto sampling if lots of CSNs
self._auto_sampling_csn_threshold = self.AUTO_SAMPLING_THRESHOLD
+ # Mapping of (replica ID, suffix) to server name
+ # Built during log parsing to correctly identify origin servers
+ # Keyed by (replica_id, suffix) because replica IDs are per-suffix in multi-suffix deployments
+ self._replica_id_to_server: Dict[Tuple[str, str], str] = {}
+
+ @staticmethod
+ def _extract_replica_id_from_csn(csn: str) -> Optional[str]:
+ """Extract the replica ID from a CSN string.
+
+ CSN format: TTTTTTTTSSSSRRRRNNNN (20 hex characters)
+ - T = timestamp (8 chars)
+ - S = sequence (4 chars)
+ - R = replica ID (4 chars)
+ - N = subseq (4 chars)
+
+ :param csn: The CSN string
+ :returns: The replica ID as a string (4 hex chars), or None if invalid
+ """
+ if not csn or not isinstance(csn, str) or len(csn) < 16:
+ return None
+ try:
+ # Extract and validate replica ID is valid hex
+ replica_id = csn[ReplicationLogAnalyzer.CSN_REPLICA_ID_START:
+ ReplicationLogAnalyzer.CSN_REPLICA_ID_END]
+ int(replica_id, 16) # Validate it's valid hex
+ return replica_id
+ except (ValueError, IndexError):
+ return None
+
+ @staticmethod
+ def _extract_timestamp_from_csn(csn: str) -> Optional[float]:
+ """Extract the timestamp (epoch seconds) from a CSN string.
+
+ CSN format: TTTTTTTTSSSSRRRRNNNN (20 hex characters)
+ - T = timestamp (8 chars, hex, seconds since epoch)
+
+ :param csn: The CSN string
+ :returns: Timestamp as float (epoch seconds), or None if invalid
+ """
+ if not csn or not isinstance(csn, str) or len(csn) < 8:
+ return None
+ try:
+ ts_hex = csn[ReplicationLogAnalyzer.CSN_TIMESTAMP_START:
+ ReplicationLogAnalyzer.CSN_TIMESTAMP_END]
+ return float(int(ts_hex, 16))
+ except (ValueError, IndexError):
+ return None
+
def _should_include_record(self, csn: str, server_map: Dict[Union[int, str], Dict[str, Any]]) -> bool:
"""Determine if a record should be included based on filtering criteria."""
total_servers = self._active_server_count or len(self.log_dirs)
@@ -990,6 +1049,148 @@ class ReplicationLogAnalyzer:
return hops
+ def _build_csn_details(self, csn_whitelist: Optional[set] = None) -> Dict[str, Dict[str, Any]]:
+ """Build detailed CSN propagation information for drill-down functionality.
+
+ Returns a dictionary keyed by CSN containing:
+ - csn: The CSN string
+ - targetDn: The target entry DN
+ - suffix: The replication suffix
+ - globalLag: Total propagation time (earliest to latest arrival)
+ - originServer: The server where the change originated (determined by CSN replica ID)
+ - originTime: ISO timestamp of origin
+ - arrivals: Ordered list of server arrivals with timing details
+ - hops: List of server-to-server hops with lag times
+ - totalHops: Number of hops in the propagation path
+ - replicatedToAll: Whether the change reached all servers
+
+ Note: Origin server is determined by the replica ID embedded in the CSN,
+ not by earliest log timestamp (which can be incorrect under clock skew).
+
+ :param csn_whitelist: Optional set of CSN IDs to include. If provided,
+ only these CSNs will have details generated.
+ This prevents memory bloat when chart data is sampled.
+ """
+ csn_details = {}
+ total_servers = self._active_server_count or len(self.log_dirs)
+
+ if csn_whitelist is not None:
+ csn_items = [(csn, sm) for csn, sm in self.csns.items() if csn in csn_whitelist]
+ else:
+ csn_items = list(self.csns.items())
+
+ if len(csn_items) > self.MAX_CSN_DETAILS:
+ self._logger.info(
+ f"CSN details limited to {self.MAX_CSN_DETAILS} entries "
+ f"(dataset has {len(csn_items)} CSNs). "
+ "Selecting CSNs with highest global lag for drill-down."
+ )
+ csn_lags = []
+ for csn, server_map in csn_items:
+ t_list = [
+ rec.get('logtime', 0)
+ for key, rec in server_map.items()
+ if isinstance(rec, dict) and key != '__hop_lags__' and 'logtime' in rec
+ ]
+ if t_list:
+ lag = max(t_list) - min(t_list)
+ csn_lags.append((csn, server_map, lag))
+ csn_lags.sort(key=lambda x: x[2], reverse=True)
+ csn_items = [(csn, sm) for csn, sm, _ in csn_lags[:self.MAX_CSN_DETAILS]]
+
+ for csn, server_map in csn_items:
+ valid_records = []
+ for key, data in server_map.items():
+ if isinstance(data, dict) and key != '__hop_lags__' and 'logtime' in data:
+ valid_records.append(data)
+
+ if not valid_records:
+ continue
+
+ valid_records.sort(key=lambda x: x['logtime'])
+
+ suffix = None
+ for rec in valid_records:
+ if rec.get('suffix'):
+ suffix = rec['suffix']
+ break
+
+ replica_id = self._extract_replica_id_from_csn(csn)
+ origin_server_name = None
+ origin_record = None
+
+ origin_in_arrivals = False
+ if replica_id and suffix:
+ map_key = (replica_id, suffix)
+ if map_key in self._replica_id_to_server:
+ origin_server_name = self._replica_id_to_server[map_key]
+ for rec in valid_records:
+ if rec.get('server_name') == origin_server_name:
+ origin_record = rec
+ origin_in_arrivals = True
+ break
+
+ if origin_record is None:
+ origin_record = valid_records[0]
+ if not origin_server_name:
+ origin_server_name = origin_record.get('server_name', 'unknown')
+
+ csn_ts = self._extract_timestamp_from_csn(csn)
+ origin_time = csn_ts if csn_ts is not None else origin_record['logtime']
+ earliest_time = valid_records[0]['logtime']
+ latest_time = valid_records[-1]['logtime']
+ global_lag = latest_time - earliest_time
+
+ arrivals = []
+ for idx, rec in enumerate(valid_records):
+ server_name = rec.get('server_name', 'unknown')
+ is_origin = (server_name == origin_server_name)
+
+ arrival_entry = {
+ 'server': server_name,
+ 'timestamp': datetime.fromtimestamp(rec['logtime'], tz=self.tz).isoformat(),
+ 'relativeDelay': rec['logtime'] - earliest_time,
+ 'duration': float(rec.get('duration', 0.0)),
+ 'etime': rec.get('etime')
+ }
+
+ if is_origin:
+ arrival_entry['isOrigin'] = True
+
+ if idx > 0:
+ prev_rec = valid_records[idx - 1]
+ arrival_entry['hopFrom'] = prev_rec.get('server_name', 'unknown')
+ arrival_entry['hopLag'] = rec['logtime'] - prev_rec['logtime']
+
+ arrivals.append(arrival_entry)
+
+ hops = []
+ for i in range(1, len(valid_records)):
+ prev_rec = valid_records[i - 1]
+ curr_rec = valid_records[i]
+ hops.append({
+ 'from': prev_rec.get('server_name', 'unknown'),
+ 'to': curr_rec.get('server_name', 'unknown'),
+ 'lag': curr_rec['logtime'] - prev_rec['logtime']
+ })
+
+ csn_details[csn] = {
+ 'csn': csn,
+ 'targetDn': origin_record.get('target_dn', 'unknown') or 'unknown',
+ 'suffix': origin_record.get('suffix', 'unknown') or 'unknown',
+ 'globalLag': global_lag,
+ 'originServer': origin_server_name,
+ 'originIncludedInArrivals': origin_in_arrivals,
+ 'originTime': datetime.fromtimestamp(origin_time, tz=self.tz).isoformat(),
+ 'arrivals': arrivals,
+ 'hops': hops,
+ 'totalHops': len(hops),
+ 'serverCount': len(valid_records),
+ 'replicatedToAll': len(valid_records) == total_servers
+ }
+
+ return csn_details
+
def parse_logs(self) -> None:
"""Parse logs from all directories. Each directory is treated as one server
unless anonymized, in which case we use 'server_{index}'.
@@ -1044,6 +1245,41 @@ class ReplicationLogAnalyzer:
'duration': record.get('duration', 0.0),
}
+ # Build (replica ID, suffix) to server mapping based on closest CSN timestamp
+ # Keyed by (replica_id, suffix) because replica IDs are per-suffix in multi-suffix deployments
+ # For each (replica ID, suffix) pair, the server whose logtime is closest to the CSN
+ # timestamp is the best origin candidate under clock skew.
+ replica_id_best: Dict[Tuple[str, str], Tuple[bool, float, float, str]] = {}
+ for csn, server_map in self.csns.items():
+ replica_id = self._extract_replica_id_from_csn(csn)
+ if not replica_id:
+ continue
+ csn_ts = self._extract_timestamp_from_csn(csn)
+ for key, record in server_map.items():
+ if not isinstance(record, dict) or key == '__hop_lags__':
+ continue
+ logtime = record.get('logtime')
+ server_name = record.get('server_name')
+ suffix = record.get('suffix')
+ if logtime is None or not server_name or not suffix:
+ continue
+ # Prefer candidates where we can compare against CSN timestamp
+ has_csn_ts = csn_ts is not None
+ score = abs(logtime - csn_ts) if has_csn_ts else logtime
+ map_key = (replica_id, suffix)
+ if map_key not in replica_id_best:
+ replica_id_best[map_key] = (has_csn_ts, score, logtime, server_name)
+ continue
+ prev_has_ts, prev_score, prev_logtime, _ = replica_id_best[map_key]
+ if has_csn_ts and not prev_has_ts:
+ replica_id_best[map_key] = (has_csn_ts, score, logtime, server_name)
+ elif has_csn_ts == prev_has_ts:
+ if score < prev_score or (score == prev_score and logtime < prev_logtime):
+ replica_id_best[map_key] = (has_csn_ts, score, logtime, server_name)
+
+ # Store the mapping ((replica ID, suffix) -> server name)
+ self._replica_id_to_server = {k: srv for k, (_, _, _, srv) in replica_id_best.items()}
+
# Apply filters after collecting all data
filtered_csns = {}
earliest_udt: Optional[float] = None
@@ -1577,6 +1813,17 @@ class ReplicationLogAnalyzer:
except Exception as e:
raise IOError(f"Failed to write JSON summary to {outfile}: {e}")
+ @staticmethod
+ def _collect_csn_ids(series_list: List[Dict[str, Any]]) -> set:
+ """Collect CSN IDs from chart series datapoints."""
+ csn_ids = set()
+ for series in series_list:
+ for dp in series.get("datapoints", []):
+ csn_id = dp.get("csnId")
+ if csn_id:
+ csn_ids.add(csn_id)
+ return csn_ids
+
def _generate_patternfly_json(self, results: Dict[str, Any], outfile: str) -> None:
"""Generate JSON specifically formatted for PatternFly 5 charts."""
chart_data = VisualizationHelper.prepare_chart_data(self.csns, self.tz)
@@ -1637,7 +1884,8 @@ class ReplicationLogAnalyzer:
"x": data.times[i].isoformat(),
"y": data.lags[i],
"duration": data.durations[i],
- "hoverInfo": data.hover[i]
+ "hoverInfo": data.hover[i],
+ "csnId": data.csn_ids[i]
} for i in indices]
series_data.append({
"datapoints": datapoints,
@@ -1645,7 +1893,7 @@ class ReplicationLogAnalyzer:
"color": color_palette[idx % len(color_palette)]
})
- hop_data: Dict[str, Dict[str, List[Any]]] = defaultdict(lambda: {"times": [], "lags": [], "hover": []})
+ hop_data: Dict[str, Dict[str, List[Any]]] = defaultdict(lambda: {"times": [], "lags": [], "hover": [], "csn_ids": []})
for csn, server_map in self.csns.items():
for hop in server_map.get('__hop_lags__', []):
source = hop.get('supplier', 'unknown')
@@ -1655,6 +1903,7 @@ class ReplicationLogAnalyzer:
ts = datetime.fromtimestamp(hop.get('arrival_consumer', 0.0), tz=self.tz)
entry["times"].append(ts)
entry["lags"].append(hop.get('hop_lag', 0.0))
+ entry["csn_ids"].append(csn)
ts_str = ts.strftime('%Y-%m-%d %H:%M:%S')
entry["hover"].append(
f"Timestamp: {ts_str}<br>"
@@ -1689,7 +1938,10 @@ class ReplicationLogAnalyzer:
"name": key,
"x": entry["times"][i].isoformat(),
"y": entry["lags"][i],
- "hoverInfo": entry["hover"][i].replace("Suffix: None", "Suffix: unknown").replace("Entry: None", "Entry: unknown")
+ "hoverInfo": (entry["hover"][i]
+ .replace("Suffix: None", "Suffix: unknown")
+ .replace("Entry: None", "Entry: unknown")),
+ "csnId": entry["csn_ids"][i]
} for i in indices]
hop_series.append({
"datapoints": datapoints,
@@ -1702,6 +1954,13 @@ class ReplicationLogAnalyzer:
reduced += sum(len(item["datapoints"]) for item in hop_series)
sampling_meta["reducedTotalPoints"] = reduced
+ sampled_csn_ids = None
+ if sampling_meta["applied"]:
+ sampled_csn_ids = self._collect_csn_ids(series_data)
+ sampled_csn_ids.update(self._collect_csn_ids(hop_series))
+
+ csn_details = self._build_csn_details(csn_whitelist=sampled_csn_ids)
+
pf_data = {
"replicationLags": {
"title": "Global Replication Lag Over Time",
@@ -1715,6 +1974,7 @@ class ReplicationLogAnalyzer:
"xAxisLabel": "Time",
"series": hop_series
},
+ "csnDetails": csn_details,
"metadata": {
"totalServers": self._active_server_count or len(self.log_dirs),
"configuredLogDirs": self.log_dirs,
--
2.52.0