diff --git a/package-lock.json b/package-lock.json
index dc6193af5f0..c82e60d1a72 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -66,6 +66,7 @@
"moment": "2.30.1",
"moment-duration-format": "2.3.2",
"moment-timezone": "0.5.41",
+ "nano": "10.1.4",
"npm-run-all2": "6.1.2",
"nyc": "15.1.0",
"painterro": "1.2.87",
@@ -2463,6 +2464,12 @@
"@mdn/browser-compat-data": "^5.2.34"
}
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true
+ },
"node_modules/axe-core": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.8.4.tgz",
@@ -2472,6 +2479,17 @@
"node": ">=4"
}
},
+ "node_modules/axios": {
+ "version": "1.7.7",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
+ "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
+ "dev": true,
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.0",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
"node_modules/babel-loader": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.1.0.tgz",
@@ -3012,6 +3030,18 @@
"node": ">=0.1.90"
}
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/comma-separated-values": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/comma-separated-values/-/comma-separated-values-3.6.4.tgz",
@@ -4102,6 +4132,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -5755,6 +5794,20 @@
"node": ">=8.0.0"
}
},
+ "node_modules/form-data": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+ "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+ "dev": true,
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -7896,6 +7949,35 @@
"multicast-dns": "cli.js"
}
},
+ "node_modules/nano": {
+ "version": "10.1.4",
+ "resolved": "https://registry.npmjs.org/nano/-/nano-10.1.4.tgz",
+ "integrity": "sha512-bJOFIPLExIbF6mljnfExXX9Cub4W0puhDjVMp+qV40xl/DBvgKao7St4+6/GB6EoHZap7eFnrnx4mnp5KYgwJA==",
+ "dev": true,
+ "dependencies": {
+ "axios": "^1.7.4",
+ "node-abort-controller": "^3.1.1",
+ "qs": "^6.13.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/nano/node_modules/qs": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
+ "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+ "dev": true,
+ "dependencies": {
+ "side-channel": "^1.0.6"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
@@ -7935,6 +8017,12 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true
},
+ "node_modules/node-abort-controller": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
+ "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
+ "dev": true
+ },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -9185,6 +9273,12 @@
"node": ">= 0.10"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "dev": true
+ },
"node_modules/pump": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
diff --git a/package.json b/package.json
index 35863a7efc0..b52c369f190 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
"moment": "2.30.1",
"moment-duration-format": "2.3.2",
"moment-timezone": "0.5.41",
+ "nano": "10.1.4",
"npm-run-all2": "6.1.2",
"nyc": "15.1.0",
"painterro": "1.2.87",
@@ -156,4 +157,4 @@
"keywords": [
"nasa"
]
-}
\ No newline at end of file
+}
diff --git a/src/plugins/duplicate/DuplicateAction.js b/src/plugins/duplicate/DuplicateAction.js
index 0f26dabe63b..6e905f42776 100644
--- a/src/plugins/duplicate/DuplicateAction.js
+++ b/src/plugins/duplicate/DuplicateAction.js
@@ -109,8 +109,9 @@ class DuplicateAction {
let currentParentKeystring = this.openmct.objects.makeKeyString(currentParent.identifier);
let parentCandidateKeystring = this.openmct.objects.makeKeyString(parentCandidate.identifier);
let objectKeystring = this.openmct.objects.makeKeyString(this.object.identifier);
+ const isLocked = parentCandidate.locked === true;
- if (!this.openmct.objects.isPersistable(parentCandidate.identifier)) {
+ if (isLocked || !this.openmct.objects.isPersistable(parentCandidate.identifier)) {
return false;
}
@@ -139,10 +140,9 @@ class DuplicateAction {
const parentType = parent && this.openmct.types.get(parent.type);
const child = objectPath[0];
const childType = child && this.openmct.types.get(child.type);
- const locked = child.locked ? child.locked : parent && parent.locked;
const isPersistable = this.openmct.objects.isPersistable(child.identifier);
- if (locked || !isPersistable) {
+ if (!isPersistable) {
return false;
}
diff --git a/src/plugins/formActions/EditPropertiesAction.js b/src/plugins/formActions/EditPropertiesAction.js
index 713e800c768..dc3d3ee0bd4 100644
--- a/src/plugins/formActions/EditPropertiesAction.js
+++ b/src/plugins/formActions/EditPropertiesAction.js
@@ -45,7 +45,7 @@ class EditPropertiesAction extends PropertiesAction {
const definition = this._getTypeDefinition(object.type);
const persistable = this.openmct.objects.isPersistable(object.identifier);
- return persistable && definition && definition.creatable;
+ return persistable && definition && definition.creatable && !object.locked;
}
invoke(objectPath) {
diff --git a/src/plugins/inspectorViews/properties/PropertiesComponent.vue b/src/plugins/inspectorViews/properties/PropertiesComponent.vue
index 77e3c837e78..8773fd82a61 100644
--- a/src/plugins/inspectorViews/properties/PropertiesComponent.vue
+++ b/src/plugins/inspectorViews/properties/PropertiesComponent.vue
@@ -96,6 +96,8 @@ export default {
const createdTimestamp = this.domainObject.created;
const createdBy = this.domainObject.createdBy ? this.domainObject.createdBy : UNKNOWN_USER;
const modifiedBy = this.domainObject.modifiedBy ? this.domainObject.modifiedBy : UNKNOWN_USER;
+ const locked = this.domainObject.locked;
+ const lockedBy = this.domainObject.lockedBy ?? UNKNOWN_USER;
const modifiedTimestamp = this.domainObject.modified
? this.domainObject.modified
: this.domainObject.created;
@@ -148,6 +150,13 @@ export default {
});
}
+ if (locked === true) {
+ details.push({
+ name: 'Locked By',
+ value: lockedBy
+ });
+ }
+
if (version) {
details.push({
name: 'Version',
diff --git a/src/plugins/persistence/couch/scripts/lockObjects.mjs b/src/plugins/persistence/couch/scripts/lockObjects.mjs
new file mode 100644
index 00000000000..f7edf69459d
--- /dev/null
+++ b/src/plugins/persistence/couch/scripts/lockObjects.mjs
@@ -0,0 +1,191 @@
+import http from 'http';
+import nano from 'nano';
+import { parseArgs } from 'util';
+
+const COUCH_URL = process.env.OPENMCT_COUCH_URL || 'http://127.0.0.1:5984';
+const COUCH_DB_NAME = process.env.OPENMCT_DATABASE_NAME || 'openmct';
+
+const {
+ values: { couchUrl, database, lock, unlock, startObjectKeystring, user, pass }
+} = parseArgs({
+ options: {
+ couchUrl: {
+ type: 'string',
+ default: COUCH_URL
+ },
+ database: {
+ type: 'string',
+ short: 'd',
+ default: COUCH_DB_NAME
+ },
+ lock: {
+ type: 'boolean',
+ short: 'l'
+ },
+ unlock: {
+ type: 'boolean',
+ short: 'u'
+ },
+ startObjectKeystring: {
+ type: 'string',
+ short: 'o',
+ default: 'mine'
+ },
+ user: {
+ type: 'string'
+ },
+ pass: {
+ type: 'string'
+ }
+ }
+});
+
+const BATCH_SIZE = 100;
+const SOCKET_POOL_SIZE = 100;
+
+const locked = lock === true;
+console.info(`Connecting to ${couchUrl}/${database}`);
+console.info(`${locked ? 'Locking' : 'Unlocking'} all children of ${startObjectKeystring}`);
+
+const poolingAgent = new http.Agent({
+ keepAlive: true,
+ maxSockets: SOCKET_POOL_SIZE
+});
+
+const db = nano({
+ url: couchUrl,
+ requestDefaults: {
+ agent: poolingAgent
+ }
+}).use(database);
+db.auth(user, pass);
+
+if (!unlock && !lock) {
+ throw new Error('Either -l or -u option is required');
+}
+
+const startObjectIdentifier = keystringToIdentifier(startObjectKeystring);
+const documentBatch = [];
+const alreadySeen = new Set();
+let updatedDocumentCount = 0;
+
+await processObjectTreeFrom(startObjectIdentifier);
+//Persist final batch
+await persistBatch();
+console.log(`Processed ${updatedDocumentCount} documents`);
+
+function processObjectTreeFrom(parentObjectIdentifier) {
+ //1. Fetch document for identifier;
+ return fetchDocument(parentObjectIdentifier)
+ .then(async (document) => {
+ if (document !== undefined) {
+ if (!alreadySeen.has(document._id)) {
+ alreadySeen.add(document._id);
+ //2. Lock or unlock object
+ document.model.locked = locked;
+ document.model.disallowUnlock = locked;
+
+ if (locked) {
+ document.model.lockedBy = 'script';
+ } else {
+ delete document.model.lockedBy;
+ }
+ //3. Push document to a batch
+ documentBatch.push(document);
+ //4. Persist batch if necessary, reporting failures
+ await persistBatchIfNeeded();
+ //5. Repeat for each composee
+ const composition = document.model.composition || [];
+ return Promise.all(
+ composition.map((composee) => {
+ return processObjectTreeFrom(composee);
+ })
+ );
+ }
+ }
+ })
+ .catch((error) => {
+ console.log(`Error ${error}`);
+ });
+}
+
+async function fetchDocument(identifierOrKeystring) {
+ let keystring;
+ if (typeof identifierOrKeystring === 'object') {
+ keystring = identifierToKeystring(identifierOrKeystring);
+ } else {
+ keystring = identifierOrKeystring;
+ }
+
+ try {
+ const document = await db.get(keystring);
+
+ return document;
+ } catch (error) {
+ return undefined;
+ }
+}
+
+function persistBatchIfNeeded() {
+ if (documentBatch.length >= BATCH_SIZE) {
+ return persistBatch();
+ } else {
+ //Noop - batch is not big enough yet
+ return;
+ }
+}
+
+async function persistBatch() {
+ try {
+ const localBatch = [].concat(documentBatch);
+
+ //Immediately clear the shared batch array. This asynchronous process is non-blocking, and
+ //we don't want to try and persist the same batch multiple times while we are waiting for
+ //the subsequent bulk operation to complete.
+ updatedDocumentCount += documentBatch.length;
+
+ documentBatch.splice(0, documentBatch.length);
+ const response = await db.bulk({ docs: localBatch });
+
+ if (response instanceof Array) {
+ response.forEach((r) => {
+ console.info(JSON.stringify(r));
+ });
+ } else {
+ console.info(JSON.stringify(response));
+ }
+ } catch (error) {
+ if (error instanceof Array) {
+ error.forEach((e) => console.error(JSON.stringify(e)));
+ } else {
+ console.error(`${error.statusCode} - ${error.reason}`);
+ }
+ }
+}
+
+function keystringToIdentifier(keystring) {
+ const tokens = keystring.split(':');
+ if (tokens.length === 2) {
+ return {
+ namespace: tokens[0],
+ key: tokens[1]
+ };
+ } else {
+ return {
+ namespace: '',
+ key: tokens[0]
+ };
+ }
+}
+
+function identifierToKeystring(identifier) {
+ if (typeof identifier === 'string') {
+ return identifier;
+ } else if (typeof identifier === 'object') {
+ if (identifier.namespace) {
+ return `${identifier.namespace}:${identifier.key}`;
+ } else {
+ return identifier.key;
+ }
+ }
+}
diff --git a/src/plugins/persistence/couch/setup-couchdb.sh b/src/plugins/persistence/couch/setup-couchdb.sh
index 18d81768830..88ae9f5323a 100755
--- a/src/plugins/persistence/couch/setup-couchdb.sh
+++ b/src/plugins/persistence/couch/setup-couchdb.sh
@@ -160,6 +160,24 @@ add_index_and_views() {
echo "Unable to create annotation_keystring_index"
echo $response
fi
+
+ # Add auth database for locked objects
+ response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request PUT "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_design/auth \
+ --header 'Content-Type: application/json' \
+ --data '{
+ "_id": "_design/auth",
+ "language": "javascript",
+ "validate_doc_update": "function (newDoc, oldDoc, userCtx) { if (userCtx.roles.indexOf('\''_admin'\'') !== -1) { return; } else if (oldDoc === null) { return; } else if (oldDoc.model.type === '\''timer'\'' || oldDoc.model.type === '\''notebook'\'' || oldDoc.model.type === '\''restricted-notebook'\'') { if (oldDoc.model.name !== newDoc.model.name) { throw ({ forbidden: '\''Read-only object'\'' }); } else { return; } } else if (oldDoc.model.locked === true && oldDoc.model.disallowUnlock === true) { throw ({ forbidden: '\''Read-only object'\'' }); } else { return; }}"
+ }')
+
+ if [[ $response =~ "\"ok\":true" ]]; then
+ echo "Successfully created _design/auth design document for locked objects"
+ elif [[ $response =~ "\"error\":\"conflict\"" ]]; then
+ echo "_design/auth already exists, skipping creation"
+ else
+ echo "Unable to create _design/auth"
+ echo $response
+ fi
}
# Main script execution
diff --git a/src/ui/layout/BrowseBar.vue b/src/ui/layout/BrowseBar.vue
index 013682a0f77..f9ea2147325 100644
--- a/src/ui/layout/BrowseBar.vue
+++ b/src/ui/layout/BrowseBar.vue
@@ -36,7 +36,7 @@
ref="objectName"
class="l-browse-bar__object-name c-object-label__name"
:class="{ 'c-input-inline': isPersistable }"
- :contenteditable="isPersistable"
+ :contenteditable="isNameEditable"
@blur="updateName"
@keydown.enter.prevent
@keyup.enter.prevent="updateNameOnEnterKeyPress"
@@ -78,7 +78,7 @@
>
+
+