This is an automated email from the git hooks/post-receive script.
mreynolds pushed a commit to branch 389-ds-base-1.4.0 in repository 389-ds-base.
The following commit(s) were added to refs/heads/389-ds-base-1.4.0 by this push: new 4f710b3 Ticket 50327 - Add replication conflict support to UI 4f710b3 is described below
commit 4f710b35ca5e29ad32ef49f3faa3086a58ba6bf7 Author: Mark Reynolds mreynolds@redhat.com AuthorDate: Fri Apr 19 16:50:36 2019 -0400
Ticket 50327 - Add replication conflict support to UI
Description: Added a page under the monitor tab to view and management replication conflict and glue entries.
https://pagure.io/389-ds-base/issue/50327
Reviewed by: spichugi(Thanks!)
(cherry picked from commit 21e10bd59dc6c3094337c8340a28a588ddd7cfa4) --- src/cockpit/389-console/src/css/ds.css | 26 +- .../389-console/src/lib/database/chaining.jsx | 2 +- .../389-console/src/lib/monitor/monitorModals.jsx | 178 +++++++++ .../389-console/src/lib/monitor/monitorTables.jsx | 391 +++++++++++++++++++- .../389-console/src/lib/monitor/replMonitor.jsx | 406 ++++++++++++++++++++- src/cockpit/389-console/src/monitor.jsx | 50 +++ src/lib389/lib389/conflicts.py | 13 +- 7 files changed, 1047 insertions(+), 19 deletions(-)
diff --git a/src/cockpit/389-console/src/css/ds.css b/src/cockpit/389-console/src/css/ds.css index e0ceeb8..1ad8d5c 100644 --- a/src/cockpit/389-console/src/css/ds.css +++ b/src/cockpit/389-console/src/css/ds.css @@ -701,8 +701,9 @@ td { width: 450px; }
-.ds-modal-wide { - min-width: 850px; +.ds-modal-wide .modal-content{ + width: 850px !important; + min-width: 850px !important; vertical-align: middle; }
@@ -1025,8 +1026,9 @@ td { line-height: 0; }
-.ds-accordian-div { +.ds-modal-row { margin-left: 20px; + margin-right: 0px !important; }
.ds-chaining-list { @@ -1105,6 +1107,24 @@ textarea { overflow-y: scroll; }
+.ds-conflict { + margin-top: 5px; + padding-top: 5px; + vertical-align: top; + width: 375px; + height: 275px; + max-height: 350px !important; + white-space: pre; + line-height: 1.5; + font-family: monospace !important; + overflow-y: auto; + overflow-x: scroll; +} + +.ds-conflict-btn { + width: 110px; +} + option { color: #181818; } diff --git a/src/cockpit/389-console/src/lib/database/chaining.jsx b/src/cockpit/389-console/src/lib/database/chaining.jsx index f20fca6..3dd3ec4 100644 --- a/src/cockpit/389-console/src/lib/database/chaining.jsx +++ b/src/cockpit/389-console/src/lib/database/chaining.jsx @@ -1041,7 +1041,7 @@ export class ChainingConfig extends React.Component {
<CustomCollapse> <div className="ds-accordion-panel"> - <div className="ds-accordian-div"> + <div className="ds-margin-left"> <div className="ds-container"> <div className="ds-inline"> <div> diff --git a/src/cockpit/389-console/src/lib/monitor/monitorModals.jsx b/src/cockpit/389-console/src/lib/monitor/monitorModals.jsx index a63c67a..5593cb0 100644 --- a/src/cockpit/389-console/src/lib/monitor/monitorModals.jsx +++ b/src/cockpit/389-console/src/lib/monitor/monitorModals.jsx @@ -491,6 +491,165 @@ class WinsyncAgmtDetailsModal extends React.Component { } }
+class ConflictCompareModal extends React.Component { + render() { + const { + showModal, + conflictEntry, + validEntry, + swapFunc, + convertFunc, + deleteFunc, + handleConvertChange, + closeHandler, + } = this.props; + + let ignoreAttrs = ['createtimestamp', 'creatorsname', 'modifytimestamp', + 'modifiersname', 'entryid', 'entrydn', 'parentid', 'numsubordinates']; + let conflict = "dn: " + conflictEntry.dn + "\n"; + let valid = "dn: " + validEntry.dn + "\n"; + let conflictChildren = "0"; + let validChildren = "0"; + + for (const key in conflictEntry.attrs) { + if (key == "numsubordinates") { + conflictChildren = conflictEntry.attrs[key]; + } + if (!ignoreAttrs.includes(key)) { + for (let attr of conflictEntry.attrs[key]) { + conflict += key + ": " + attr + "\n"; + } + } + } + for (const key in validEntry.attrs) { + if (key == "numsubordinates") { + validChildren = <font color="red">{validEntry.attrs[key]}</font>; + } + if (!ignoreAttrs.includes(key)) { + for (let attr of validEntry.attrs[key]) { + valid += key + ": " + attr + "\n"; + } + } + } + + return ( + <Modal show={showModal} className="ds-modal-wide" onHide={closeHandler}> + <div className="ds-no-horizontal-scrollbar"> + <Modal.Header> + <button + className="close" + onClick={closeHandler} + aria-hidden="true" + aria-label="Close" + > + <Icon type="pf" name="close" /> + </button> + <Modal.Title> + Resolve Replication Conflicts + </Modal.Title> + </Modal.Header> + <Modal.Body> + <Form horizontal autoComplete="off"> + <div className="ds-modal-row"> + <Row> + <Col sm={5}> + <Row> + <h3>Conflict Entry</h3> + </Row> + <Row> + <textarea className="ds-conflict" value={conflict} readOnly /> + </Row> + <p /> + <Row> + <p>Child Entries: <b>{conflictChildren}</b></p> + </Row> + </Col> + <Col sm={1} /> + <Col sm={5}> + <Row> + <h3>Valid Entry</h3> + </Row> + <Row> + <textarea className="ds-conflict" value={valid} readOnly /> + </Row> + <p /> + <Row> + <p>Child Entries: <b>{validChildren}</b></p> + </Row> + </Col> + </Row> + <hr /> + <Row> + <h4>You can convert the <b>Conflict Entry</b> into a new valid entry by providing a new RDN value below, like "<i>cn=NEW_RDN</i>"</h4> + </Row> + <Row> + <Col sm={2}> + <Button + bsStyle="primary" + className="ds-conflict-btn" + onClick={() => { + convertFunc(conflictEntry.dn); + }} + > + Convert Conflict + </Button> + </Col> + <Col sm={4}> + <input onChange={handleConvertChange} type="text" placeholder="Enter new RDN here" size="30" /> + </Col> + </Row> + <p /> + <Row> + <h4>Or, you can replace, or swap, the <b>Valid Entry</b> (and its child entries) with the <b>Conflict Entry</b></h4> + </Row> + <Row> + <Col sm={3}> + <Button + bsStyle="primary" + className="ds-conflict-btn" + onClick={() => { + swapFunc(conflictEntry.dn); + }} + > + Swap Entries + </Button> + </Col> + </Row> + <p /> + <Row> + <h4>Or, you can delete the <b>Conflict Entry</b></h4> + </Row> + <Row> + <Col sm={3}> + <Button + bsStyle="primary" + className="ds-conflict-btn" + onClick={() => { + deleteFunc(conflictEntry.dn); + }} + > + Delete Conflict + </Button> + </Col> + </Row> + </div> + </Form> + </Modal.Body> + <Modal.Footer> + <Button + bsStyle="default" + className="btn-cancel" + onClick={closeHandler} + > + Close + </Button> + </Modal.Footer> + </div> + </Modal> + ); + } +} + // Prototypes and defaultProps AgmtDetailsModal.propTypes = { showModal: PropTypes.bool, @@ -550,10 +709,29 @@ ReplLoginModal.defaultProps = { error: {}, };
+ConflictCompareModal.propTypes = { + showModal: PropTypes.bool, + conflictEntry: PropTypes.object, + validEntry: PropTypes.object, + swapFunc: PropTypes.func, + convertFunc: PropTypes.func, + closeHandler: PropTypes.func, +}; + +ConflictCompareModal.defaultProps = { + showModal: false, + conflictEntry: {}, + validEntry: {}, + swapFunc: noop, + convertFunc: noop, + closeHandler: noop, +}; + export { TaskLogModal, AgmtDetailsModal, ReplLagReportModal, ReplLoginModal, WinsyncAgmtDetailsModal, + ConflictCompareModal, }; diff --git a/src/cockpit/389-console/src/lib/monitor/monitorTables.jsx b/src/cockpit/389-console/src/lib/monitor/monitorTables.jsx index 592e514..c401196 100644 --- a/src/cockpit/389-console/src/lib/monitor/monitorTables.jsx +++ b/src/cockpit/389-console/src/lib/monitor/monitorTables.jsx @@ -1218,6 +1218,372 @@ class LagReportTable extends React.Component { } }
+class GlueTable extends React.Component { + constructor(props) { + super(props); + + this.state = { + fieldsToSearch: ["dn", "created"], + rowKey: "dn", + columns: [ + { + property: "dn", + header: { + label: "Glue Entry", + props: { + index: 0, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [], + formatters: [], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 0 + }, + formatters: [tableCellFormatter] + } + }, + { + property: "desc", + header: { + label: "Conflict Description", + props: { + index: 1, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [], + formatters: [], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 1 + }, + formatters: [tableCellFormatter] + } + }, + { + property: "created", + header: { + label: "Created", + props: { + index: 2, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [], + formatters: [], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 2 + }, + formatters: [tableCellFormatter] + } + }, + + { + property: "actions", + header: { + props: { + index: 3, + rowSpan: 1, + colSpan: 1 + }, + formatters: [actionHeaderCellFormatter] + }, + cell: { + props: { + index: 3 + }, + formatters: [ + (value, { rowData }) => { + return [ + <td key={rowData.dn[0]}> + <DropdownButton id={rowData.dn[0]} + bsStyle="default" title="Actions"> + <MenuItem eventKey="1" onClick={() => { + this.props.convertGlue(rowData.dn[0]); + }}> + Convert Glue Entry + </MenuItem> + <MenuItem eventKey="2" onClick={() => { + this.props.deleteGlue(rowData.dn[0]); + }}> + Delete Glue Entry + </MenuItem> + </DropdownButton> + </td> + ]; + } + ] + } + } + ] + }; + this.getColumns = this.getColumns.bind(this); + this.getSingleColumn = this.getSingleColumn.bind(this); + } // Constructor + + getColumns() { + return this.state.columns; + } + + getSingleColumn () { + return [ + { + property: "msg", + header: { + label: "Replication Glue Entries", + props: { + index: 0, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [], + formatters: [], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 0 + }, + formatters: [tableCellFormatter] + } + }, + ]; + } + + render() { + let glueTable; + if (this.props.glues.length < 1) { + glueTable = + <DSTable + noSearchBar + getColumns={this.getSingleColumn} + rowKey={"msg"} + rows={[{msg: "No glue entries"}]} + />; + } else { + let rows = []; + for (let glue of this.props.glues) { + rows.push({ + dn: [glue.dn], + desc: glue.attrs.nsds5replconflict, + created: [get_date_string(glue.attrs.createtimestamp[0])], + }); + } + + glueTable = + <DSTable + getColumns={this.getColumns} + fieldsToSearch={this.state.fieldsToSearch} + toolBarSearchField={this.state.searchField} + rowKey={this.state.rowKey} + rows={rows} + disableLoadingSpinner + toolBarPagination={[6, 12, 24, 48, 96]} + toolBarPaginationPerPage={6} + />; + } + + return ( + <div className="ds-margin-top-xlg"> + {glueTable} + </div> + ); + } +} + +class ConflictTable extends React.Component { + constructor(props) { + super(props); + + this.state = { + fieldsToSearch: ["dn", "desc"], + rowKey: "dn", + columns: [ + { + property: "dn", + header: { + label: "Conflict DN", + props: { + index: 0, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [], + formatters: [], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 0 + }, + formatters: [tableCellFormatter] + } + }, + { + property: "desc", + header: { + label: "Conflict Description", + props: { + index: 1, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [], + formatters: [], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 1 + }, + formatters: [tableCellFormatter] + } + }, + { + property: "created", + header: { + label: "Created", + props: { + index: 2, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [], + formatters: [], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 2 + }, + formatters: [tableCellFormatter] + } + }, + + { + property: "actions", + header: { + props: { + index: 3, + rowSpan: 1, + colSpan: 1 + }, + formatters: [actionHeaderCellFormatter] + }, + cell: { + props: { + index: 3 + }, + formatters: [ + (value, { rowData }) => { + return [ + <td key={rowData.dn[0]}> + <Button + onClick={() => { + this.props.resolveConflict(rowData.dn[0]); + }} + > + Resolve + </Button> + </td> + ]; + } + ] + } + } + ] + }; + this.getColumns = this.getColumns.bind(this); + this.getSingleColumn = this.getSingleColumn.bind(this); + } // Constructor + + getColumns() { + return this.state.columns; + } + + getSingleColumn () { + return [ + { + property: "msg", + header: { + label: "Replication Conflict Entries", + props: { + index: 0, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [], + formatters: [], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 0 + }, + formatters: [tableCellFormatter] + } + }, + ]; + } + + render() { + let conflictTable; + if (this.props.conflicts.length < 1) { + conflictTable = + <DSTable + noSearchBar + getColumns={this.getSingleColumn} + rowKey={"msg"} + rows={[{msg: "No conflict entries"}]} + />; + } else { + let rows = []; + for (let conflict of this.props.conflicts) { + rows.push({ + dn: [conflict.dn], + desc: conflict.attrs.nsds5replconflict, + created: [get_date_string(conflict.attrs.createtimestamp[0])], + }); + } + + conflictTable = + <DSTable + getColumns={this.getColumns} + fieldsToSearch={this.state.fieldsToSearch} + toolBarSearchField={this.state.searchField} + rowKey={this.state.rowKey} + rows={rows} + disableLoadingSpinner + toolBarPagination={[6, 12, 24, 48, 96]} + toolBarPaginationPerPage={6} + />; + } + + return ( + <div className="ds-margin-top-xlg"> + {conflictTable} + </div> + ); + } +} + // Proptypes and defaults
LagReportTable.propTypes = { @@ -1244,7 +1610,6 @@ AgmtTable.defaultProps = { pokeAgmt: noop };
-// Proptyes and defaults WinsyncAgmtTable.propTypes = { agmts: PropTypes.array, viewAgmt: PropTypes.func, @@ -1285,6 +1650,28 @@ AbortCleanALLRUVTable.defaultProps = { viewLog: PropTypes.func, };
+ConflictTable.propTypes = { + conflicts: PropTypes.array, + resolveConflict: PropTypes.func, +}; + +ConflictTable.defaultProps = { + conflicts: [], + resolveConflict: noop, +}; + +GlueTable.propTypes = { + glues: PropTypes.array, + convertGlue: PropTypes.func, + deleteGlue: PropTypes.func, +}; + +GlueTable.defaultProps = { + glues: PropTypes.array, + convertGlue: noop, + deleteGlue: noop, +}; + export { ConnectionTable, AgmtTable, @@ -1292,4 +1679,6 @@ export { LagReportTable, CleanALLRUVTable, AbortCleanALLRUVTable, + ConflictTable, + GlueTable, }; diff --git a/src/cockpit/389-console/src/lib/monitor/replMonitor.jsx b/src/cockpit/389-console/src/lib/monitor/replMonitor.jsx index a742a0d..ca2e354 100644 --- a/src/cockpit/389-console/src/lib/monitor/replMonitor.jsx +++ b/src/cockpit/389-console/src/lib/monitor/replMonitor.jsx @@ -8,7 +8,9 @@ import { AgmtTable, WinsyncAgmtTable, CleanALLRUVTable, - AbortCleanALLRUVTable + AbortCleanALLRUVTable, + ConflictTable, + GlueTable, } from "./monitorTables.jsx"; import { TaskLogModal, @@ -16,6 +18,7 @@ import { WinsyncAgmtDetailsModal, ReplLagReportModal, ReplLoginModal, + ConflictCompareModal, } from "./monitorModals.jsx"; import { Nav, @@ -41,9 +44,18 @@ export class ReplMonitor extends React.Component { showInitConfirm: false, showLoginModal: false, showLagReport: false, + showCompareModal: false, + showConfirmDeleteGlue: false, + showConfirmConvertGlue: false, + showConfirmSwapConflict: false, + showConfirmConvertConflict: false, + showConfirmDeleteConflict: false, reportLoading: false, lagAgmts: [], agmt: "", + convertRDN: "", + glueEntry: "", + conflictEntry: "", binddn: "cn=Directory Manager", bindpw: "", errObj: {} @@ -70,6 +82,253 @@ export class ReplMonitor extends React.Component { this.closeLogModal = this.closeLogModal.bind(this); this.handleLoginModal = this.handleLoginModal.bind(this); this.closeLoginModal = this.closeLoginModal.bind(this); + // Conflict entry functions + this.convertConflict = this.convertConflict.bind(this); + this.swapConflict = this.swapConflict.bind(this); + this.deleteConflict = this.deleteConflict.bind(this); + this.resolveConflict = this.resolveConflict.bind(this); + this.convertGlue = this.convertGlue.bind(this); + this.deleteGlue = this.deleteGlue.bind(this); + this.closeCompareModal = this.closeCompareModal.bind(this); + this.confirmDeleteGlue = this.confirmDeleteGlue.bind(this); + this.confirmConvertGlue = this.confirmConvertGlue.bind(this); + this.closeConfirmDeleteGlue = this.closeConfirmDeleteGlue.bind(this); + this.closeConfirmConvertGlue = this.closeConfirmConvertGlue.bind(this); + this.handleConvertChange = this.handleConvertChange.bind(this); + + this.confirmDeleteConflict = this.confirmDeleteConflict.bind(this); + this.confirmConvertConflict = this.confirmConvertConflict.bind(this); + this.confirmSwapConflict = this.confirmSwapConflict.bind(this); + + this.closeConfirmDeleteConflict = this.closeConfirmDeleteConflict.bind(this); + this.closeConfirmConvertConflict = this.closeConfirmConvertConflict.bind(this); + this.closeConfirmSwapConflict = this.closeConfirmSwapConflict.bind(this); + } + + convertConflict (dn) { + let cmd = ["dsconf", "-j", "ldapi://%2fvar%2frun%2fslapd-" + this.props.serverId + ".socket", + "repl-conflict", "convert", dn, "--new-rdn=" + this.state.convertRDN]; + log_cmd("convertConflict", "convert conflict entry", cmd); + cockpit + .spawn(cmd, { superuser: true, err: "message" }) + .done(content => { + this.props.reloadConflicts(); + this.props.addNotification( + "success", + `Replication conflict entry was converted into a valid entry` + ); + this.setState({ + showCompareModal: false, + convertRDN: "" + }); + }) + .fail(err => { + let errMsg = JSON.parse(err); + this.props.addNotification( + "error", + `Failed to convert conflict entry entry: ${dn} - ${errMsg.desc}` + ); + }); + } + + swapConflict (dn) { + let cmd = ["dsconf", "-j", "ldapi://%2fvar%2frun%2fslapd-" + this.props.serverId + ".socket", + "repl-conflict", "swap", dn]; + log_cmd("swapConflict", "swap in conflict entry", cmd); + cockpit + .spawn(cmd, { superuser: true, err: "message" }) + .done(content => { + this.props.reloadConflicts(); + this.props.addNotification( + "success", + `Replication Conflict Entry is now the Valid Entry` + ); + this.setState({ + showCompareModal: false, + }); + }) + .fail(err => { + let errMsg = JSON.parse(err); + this.props.addNotification( + "error", + `Failed to swap in conflict entry: ${dn} - ${errMsg.desc}` + ); + }); + } + + deleteConflict (dn) { + let cmd = ["dsconf", "-j", "ldapi://%2fvar%2frun%2fslapd-" + this.props.serverId + ".socket", + "repl-conflict", "delete", dn]; + log_cmd("deleteConflict", "Delete conflict entry", cmd); + cockpit + .spawn(cmd, { superuser: true, err: "message" }) + .done(content => { + this.props.reloadConflicts(); + this.props.addNotification( + "success", + `Replication conflict entry was deleted` + ); + this.setState({ + showCompareModal: false, + }); + }) + .fail(err => { + let errMsg = JSON.parse(err); + this.props.addNotification( + "error", + `Failed to delete conflict entry: ${dn} - ${errMsg.desc}` + ); + }); + } + + resolveConflict (dn) { + let cmd = ["dsconf", "-j", "ldapi://%2fvar%2frun%2fslapd-" + this.props.serverId + ".socket", + "repl-conflict", "compare", dn]; + log_cmd("resolveConflict", "Compare conflict entry with valid entry", cmd); + cockpit + .spawn(cmd, { superuser: true, err: "message" }) + .done(content => { + let entries = JSON.parse(content); + this.setState({ + cmpConflictEntry: entries.items[0], + cmpValidEntry: entries.items[1], + showCompareModal: true, + }); + }) + .fail(err => { + let errMsg = JSON.parse(err); + this.props.addNotification( + "error", + `Failed to get conflict entries: ${dn} - ${errMsg.desc}` + ); + }); + } + + confirmConvertGlue (dn) { + this.setState({ + showConfirmConvertGlue: true, + glueEntry: dn + }); + } + + closeConfirmConvertGlue () { + this.setState({ + showConfirmConvertGlue: false, + glueEntry: "" + }); + } + + convertGlue (dn) { + let cmd = ["dsconf", "-j", "ldapi://%2fvar%2frun%2fslapd-" + this.props.serverId + ".socket", + "repl-conflict", "convert-glue", dn]; + log_cmd("convertGlue", "Convert glue entry to normal entry", cmd); + cockpit + .spawn(cmd, { superuser: true, err: "message" }) + .done(content => { + this.props.reloadConflicts(); + this.props.addNotification( + "success", + `Replication glue entry was converted` + ); + }) + .fail(err => { + let errMsg = JSON.parse(err); + this.props.addNotification( + "error", + `Failed to convert glue entry: ${dn} - ${errMsg.desc}` + ); + }); + } + + confirmDeleteGlue (dn) { + this.setState({ + showConfirmDeleteGlue: true, + glueEntry: dn + }); + } + + deleteGlue (dn) { + let cmd = ["dsconf", "-j", "ldapi://%2fvar%2frun%2fslapd-" + this.props.serverId + ".socket", + "repl-conflict", "delete-glue", dn]; + log_cmd("deleteGlue", "Delete glue entry", cmd); + cockpit + .spawn(cmd, { superuser: true, err: "message" }) + .done(content => { + this.props.reloadConflicts(); + this.props.addNotification( + "success", + `Replication glue entry was deleted` + ); + }) + .fail(err => { + let errMsg = JSON.parse(err); + this.props.addNotification( + "error", + `Failed to delete glue entry: ${dn} - ${errMsg.desc}` + ); + }); + } + + closeConfirmDeleteGlue () { + this.setState({ + showConfirmDeleteGlue: false, + glueEntry: "" + }); + } + + confirmConvertConflict (dn) { + if (this.state.convertRDN == "") { + this.props.addNotification( + "error", + `You must provide a RDN if you want to convert the Conflict Entry` + ); + return; + } + this.setState({ + showConfirmConvertConflict: true, + conflictEntry: dn + }); + } + + closeConfirmConvertConflict () { + this.setState({ + showConfirmConvertConflict: false, + conflictEntry: "" + }); + } + + confirmSwapConflict (dn) { + this.setState({ + showConfirmSwapConflict: true, + conflictEntry: dn + }); + } + + closeConfirmSwapConflict () { + this.setState({ + showConfirmSwapConflict: false, + conflictEntry: "" + }); + } + + confirmDeleteConflict (dn) { + this.setState({ + showConfirmDeleteConflict: true, + conflictEntry: dn + }); + } + + closeConfirmDeleteConflict () { + this.setState({ + showConfirmDeleteConflict: false, + conflictEntry: "" + }); + } + + closeCompareModal () { + this.setState({ + showCompareModal: false + }); }
handleNavSelect(key) { @@ -354,13 +613,23 @@ export class ReplMonitor extends React.Component { }); }
+ handleConvertChange(e) { + const value = e.target.value; + this.setState({ + convertRDN: value, + }); + } + render() { let replAgmts = this.props.data.replAgmts; let replWinsyncAgmts = this.props.data.replWinsyncAgmts; let cleanTasks = this.props.data.cleanTasks; let abortTasks = this.props.data.abortTasks; + let conflictEntries = this.props.data.conflicts; + let glueEntries = this.props.data.glues; let agmtDetailModal = ""; let winsyncAgmtDetailModal = ""; + let compareConflictModal = "";
if (this.state.showAgmtModal) { agmtDetailModal = @@ -381,6 +650,19 @@ export class ReplMonitor extends React.Component { initAgmt={this.confirmWinsyncInit} />; } + if (this.state.showCompareModal) { + compareConflictModal = + <ConflictCompareModal + showModal + conflictEntry={this.state.cmpConflictEntry} + validEntry={this.state.cmpValidEntry} + swapFunc={this.confirmSwapConflict} + convertFunc={this.confirmConvertConflict} + deleteFunc={this.confirmDeleteConflict} + handleConvertChange={this.handleConvertChange} + closeHandler={this.closeCompareModal} + />; + }
let cleanNavTitle = 'CleanAllRUV Tasks <font size="1">(' + cleanTasks.length + ')</font>'; let abortNavTitle = 'Abort CleanAllRUV Tasks <font size="1">(' + abortTasks.length + ')</font>'; @@ -414,9 +696,60 @@ export class ReplMonitor extends React.Component { </TabContent> </div>;
- let AgmtNavTitle = 'Agreements <font size="1">(' + replAgmts.length + ')</font>'; - let WinsyncAgmtNavTitle = 'Winsync Agreements <font size="1">(' + replWinsyncAgmts.length + ')</font>'; - let TasksNavTitle = 'Tasks <font size="1">(' + (cleanTasks.length + abortTasks.length) + ')</font>'; + let conflictNavTitle = 'Conflict Entries <font size="1">(' + conflictEntries.length + ')</font>'; + let glueNavTitle = 'Glue Entries <font size="1">(' + glueEntries.length + ')</font>'; + let conflictContent = + <div> + <Nav bsClass="nav nav-tabs nav-tabs-pf"> + <NavItem className="ds-nav-med" eventKey={1}> + <div dangerouslySetInnerHTML={{__html: conflictNavTitle}} /> + </NavItem> + <NavItem className="ds-nav-med" eventKey={2}> + <div dangerouslySetInnerHTML={{__html: glueNavTitle}} /> + </NavItem> + </Nav> + <TabContent> + <TabPane eventKey={1}> + <div className="ds-indent ds-margin-top-lg"> + <p> + Replication conflict entries occur when two entries are created with the same + DN on different servers. The automatic conflict resolution procedure renames + the last entry created to include the entry's unique identifier (nsuniqueid) + in the DN. There are several ways to resolve a conflict, but that is up to + you on which option to use. + </p> + <ConflictTable + conflicts={conflictEntries} + resolveConflict={this.resolveConflict} + key={conflictEntries} + /> + </div> + </TabPane> + <TabPane eventKey={2}> + <div className="ds-indent ds-margin-top-lg"> + <p> + When a <b>Delete</b> operation is replicated and the consumer server finds that the entry to be + deleted has child entries, the conflict resolution procedure creates a "<i>glue entry</i>" to + avoid having orphaned entries in the database. In the same way, when an <b>Add</b> operation is + replicated and the consumer server cannot find the parent entry, the conflict resolution + procedure creates a "<i>glue entry</i>" representing the parent so that the new entry is not an + orphan entry. + </p> + <GlueTable + glues={glueEntries} + convertGlue={this.confirmConvertGlue} + deleteGlue={this.confirmDeleteGlue} + key={glueEntries} + /> + </div> + </TabPane> + </TabContent> + </div>; + + let replAgmtNavTitle = 'Replication Agreements <font size="1">(' + replAgmts.length + ')</font>'; + let winsyncNavTitle = 'Winsync Agreements <font size="1">(' + replWinsyncAgmts.length + ')</font>'; + let tasksNavTitle = 'Tasks <font size="1">(' + (cleanTasks.length + abortTasks.length) + ')</font>'; + let conflictsNavTitle = 'Conflicts <font size="1">(' + (conflictEntries.length + glueEntries.length) + ')</font>';
return ( <div id="monitor-suffix-page" className="container-fluid ds-tab-table"> @@ -424,18 +757,21 @@ export class ReplMonitor extends React.Component { <div> <Nav bsClass="nav nav-tabs nav-tabs-pf"> <NavItem eventKey={1}> - <div dangerouslySetInnerHTML={{__html: AgmtNavTitle}} /> + <div dangerouslySetInnerHTML={{__html: replAgmtNavTitle}} /> </NavItem> <NavItem eventKey={2}> - <div dangerouslySetInnerHTML={{__html: WinsyncAgmtNavTitle}} /> + <div dangerouslySetInnerHTML={{__html: winsyncNavTitle}} /> </NavItem> <NavItem eventKey={3}> - <div dangerouslySetInnerHTML={{__html: TasksNavTitle}} /> + <div dangerouslySetInnerHTML={{__html: tasksNavTitle}} /> + </NavItem> + <NavItem eventKey={4}> + <div dangerouslySetInnerHTML={{__html: conflictsNavTitle}} /> </NavItem> </Nav> <TabContent> <TabPane eventKey={1}> - <div className="ds-margin-top-lg"> + <div className="ds-indent ds-tab-table"> <AgmtTable agmts={replAgmts} pokeAgmt={this.pokeAgmt} @@ -451,9 +787,8 @@ export class ReplMonitor extends React.Component { </Button> </div> </TabPane> - <TabPane eventKey={2}> - <div className="ds-margin-top-lg"> + <div className="dds-indent ds-tab-table"> <WinsyncAgmtTable agmts={replWinsyncAgmts} pokeAgmt={this.pokeWinsyncAgmt} @@ -461,7 +796,6 @@ export class ReplMonitor extends React.Component { /> </div> </TabPane> - <TabPane eventKey={3}> <div className="ds-indent ds-tab-table"> <TabContainer id="task-tabs" defaultActiveKey={1}> @@ -469,6 +803,13 @@ export class ReplMonitor extends React.Component { </TabContainer> </div> </TabPane> + <TabPane eventKey={4}> + <div className="ds-indent ds-tab-table"> + <TabContainer id="task-tabs" defaultActiveKey={1}> + {conflictContent} + </TabContainer> + </div> + </TabPane> </TabContent> </div> </TabContainer> @@ -508,8 +849,49 @@ export class ReplMonitor extends React.Component { pokeAgmt={this.pokeAgmt} viewAgmt={this.showAgmtModal} /> + <ConfirmPopup + showModal={this.state.showConfirmDeleteGlue} + closeHandler={this.closeConfirmDeleteGlue} + actionFunc={this.deleteGlue} + actionParam={this.state.glueEntry} + msg="Are you really sure you want to delete this glue entry and its child entries?" + msgContent={this.state.glueEntry} + /> + <ConfirmPopup + showModal={this.state.showConfirmConvertGlue} + closeHandler={this.closeConfirmConvertGlue} + actionFunc={this.convertGlue} + actionParam={this.state.glueEntry} + msg="Are you really sure you want to convert this glue entry to a regular entry?" + msgContent={this.state.glueEntry} + /> + <ConfirmPopup + showModal={this.state.showConfirmConvertConflict} + closeHandler={this.closeConfirmConvertConflict} + actionFunc={this.convertConflict} + actionParam={this.state.conflictEntry} + msg="Are you really sure you want to convert this conflict entry to a regular entry?" + msgContent={this.state.conflictEntry} + /> + <ConfirmPopup + showModal={this.state.showConfirmSwapConflict} + closeHandler={this.closeConfirmSwapConflict} + actionFunc={this.swapConflict} + actionParam={this.state.conflictEntry} + msg="Are you really sure you want to swap this conflict entry with the valid entry (this would remove the valid entry and any child entries it might have)?" + msgContent={this.state.conflictEntry} + /> + <ConfirmPopup + showModal={this.state.showConfirmDeleteConflict} + closeHandler={this.closeConfirmDeleteConflict} + actionFunc={this.deleteConflict} + actionParam={this.state.conflictEntry} + msg="Are you really sure you want to delete this conflict entry?" + msgContent={this.state.conflictEntry} + /> {agmtDetailModal} {winsyncAgmtDetailModal} + {compareConflictModal} </div> ); } @@ -524,6 +906,7 @@ ReplMonitor.propTypes = { addNotification: PropTypes.func, reloadAgmts: PropTypes.func, reloadWinsyncAgmts: PropTypes.func, + reloadConflicts: PropTypes.func, };
ReplMonitor.defaultProps = { @@ -533,6 +916,7 @@ ReplMonitor.defaultProps = { addNotification: noop, reloadAgmts: noop, reloadWinsyncAgmts: noop, + reloadConflicts: noop, };
export default ReplMonitor; diff --git a/src/cockpit/389-console/src/monitor.jsx b/src/cockpit/389-console/src/monitor.jsx index ae67f4a..89d47b0 100644 --- a/src/cockpit/389-console/src/monitor.jsx +++ b/src/cockpit/389-console/src/monitor.jsx @@ -131,6 +131,8 @@ export class Monitor extends React.Component { this.replSuffixChange = this.replSuffixChange.bind(this); this.reloadReplAgmts = this.reloadReplAgmts.bind(this); this.reloadReplWinsyncAgmts = this.reloadReplWinsyncAgmts.bind(this); + this.loadConflicts = this.loadConflicts.bind(this); + this.loadGlues = this.loadGlues.bind(this); // Logging this.loadMonitor = this.loadMonitor.bind(this); this.refreshAccessLog = this.refreshAccessLog.bind(this); @@ -661,6 +663,53 @@ export class Monitor extends React.Component { ...this.state[this.state.replSuffix], abortTasks: config.items, }, + }, this.loadConflicts()); + }) + .fail(() => { + // Notification of failure (could only be server down) + this.setState({ + replLoading: false, + }); + }); + } + + loadConflicts() { + let cmd = ["dsconf", "-j", "ldapi://%2fvar%2frun%2fslapd-" + this.props.serverId + ".socket", + "repl-conflict", "list", this.state.replSuffix]; + log_cmd("loadConflicts", "Load conflict entries", cmd); + cockpit + .spawn(cmd, { superuser: true, err: "message" }) + .done(content => { + let config = JSON.parse(content); + this.setState({ + [this.state.replSuffix]: { + ...this.state[this.state.replSuffix], + conflicts: config.items, + glues: [] + }, + }, this.loadGlues()); + }) + .fail(() => { + // Notification of failure (could only be server down) + this.setState({ + replLoading: false, + }); + }); + } + + loadGlues() { + let cmd = ["dsconf", "-j", "ldapi://%2fvar%2frun%2fslapd-" + this.props.serverId + ".socket", + "repl-conflict", "list-glue", this.state.replSuffix]; + log_cmd("loadGlues", "Load glue entries", cmd); + cockpit + .spawn(cmd, { superuser: true, err: "message" }) + .done(content => { + let config = JSON.parse(content); + this.setState({ + [this.state.replSuffix]: { + ...this.state[this.state.replSuffix], + glues: config.items, + }, }, this.setState( { replLoading: false, @@ -1089,6 +1138,7 @@ export class Monitor extends React.Component { addNotification={this.addNotification} reloadAgmts={this.reloadReplAgmts} reloadWinsyncAgmts={this.reloadReplWinsyncAgmts} + reloadConflicts={this.loadConflicts} key={this.state.replSuffix} /> </div> diff --git a/src/lib389/lib389/conflicts.py b/src/lib389/lib389/conflicts.py index b1f86e0..ec7b67f 100644 --- a/src/lib389/lib389/conflicts.py +++ b/src/lib389/lib389/conflicts.py @@ -9,6 +9,7 @@
import ldap from lib389._mapped_object import DSLdapObject, DSLdapObjects, _gen_filter +from lib389.utils import is_a_dn
class ConflictEntry(DSLdapObject): @@ -27,11 +28,14 @@ class ConflictEntry(DSLdapObject): self._object_filter = '(objectclass=ldapsubentry)'
def convert(self, new_rdn): - """Convert conflict entry to a vlid entry, but we need to + """Convert conflict entry to a valid entry, but we need to give the conflict entry a new rdn since we are not replacing the existing valid counterpart entry. """
+ if not is_a_dn(new_rdn): + raise ValueError("The new RDN (" + new_rdn + ") is not a valid DN") + # Get the conflict entry info conflict_value = self.get_attr_val_utf8('nsds5ReplConflict') entry_dn = conflict_value.split(' ', 3)[2] @@ -62,10 +66,13 @@ class ConflictEntry(DSLdapObject): new_rdn = "{}={}".format(rdn_attr, entry_rdn) tmp_rdn = new_rdn + 'tmp'
- # Delete valid entry (to be replaced by conflict entry) + # Delete valid entry and its children (to be replaced by conflict entry) original_entry = DSLdapObject(self._instance, dn=entry_dn) original_entry._protected = False - original_entry.delete() + filterstr = "(|(objectclass=*)(objectclass=ldapsubentry))" + ents = self._instance.search_s(original_entry._dn, ldap.SCOPE_SUBTREE, filterstr, escapehatch='i am sure') + for ent in sorted(ents, key=lambda e: len(e.dn), reverse=True): + self._instance.delete_ext_s(ent.dn, serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure')
# Rename conflict entry to tmp rdn so we can clean up the rdn attr self.rename(tmp_rdn, deloldrdn=False)
389-commits@lists.fedoraproject.org