@mongoosejs/studio 0.2.9 → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/actions/Model/executeDocumentScript.js +61 -0
- package/backend/actions/Model/index.js +2 -0
- package/backend/actions/Model/listModels.js +36 -1
- package/backend/actions/Model/streamDocumentChanges.js +123 -0
- package/backend/actions/Task/cancelTask.js +24 -0
- package/backend/actions/Task/createTask.js +33 -0
- package/backend/actions/Task/getTasks.js +62 -0
- package/backend/actions/Task/index.js +7 -0
- package/backend/actions/Task/rescheduleTask.js +39 -0
- package/backend/actions/Task/runTask.js +25 -0
- package/backend/actions/index.js +1 -0
- package/backend/authorize.js +2 -0
- package/backend/index.js +7 -1
- package/eslint.config.js +4 -1
- package/express.js +4 -2
- package/frontend/public/app.js +14590 -13420
- package/frontend/public/tw.css +357 -4
- package/frontend/src/api.js +100 -0
- package/frontend/src/dashboard-result/dashboard-document/dashboard-document.html +4 -5
- package/frontend/src/dashboard-result/dashboard-document/dashboard-document.js +13 -14
- package/frontend/src/document/document.html +80 -0
- package/frontend/src/document/document.js +206 -19
- package/frontend/src/document/execute-script/execute-script.css +35 -0
- package/frontend/src/document/execute-script/execute-script.html +67 -0
- package/frontend/src/document/execute-script/execute-script.js +142 -0
- package/frontend/src/index.js +48 -4
- package/frontend/src/navbar/navbar.html +15 -2
- package/frontend/src/navbar/navbar.js +11 -0
- package/frontend/src/routes.js +13 -5
- package/frontend/src/tasks/task-details/task-details.html +284 -0
- package/frontend/src/tasks/task-details/task-details.js +182 -0
- package/frontend/src/tasks/tasks.css +0 -0
- package/frontend/src/tasks/tasks.html +220 -0
- package/frontend/src/tasks/tasks.js +372 -0
- package/package.json +4 -1
package/frontend/src/api.js
CHANGED
|
@@ -91,6 +91,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
|
|
|
91
91
|
deleteDocuments(params) {
|
|
92
92
|
return client.post('', { action: 'Model.deleteDocuments', ...params }).then(res => res.data);
|
|
93
93
|
},
|
|
94
|
+
executeDocumentScript(params) {
|
|
95
|
+
return client.post('', { action: 'Model.executeDocumentScript', ...params }).then(res => res.data);
|
|
96
|
+
},
|
|
94
97
|
exportQueryResults(params) {
|
|
95
98
|
const accessToken = window.localStorage.getItem('_mongooseStudioAccessToken') || null;
|
|
96
99
|
|
|
@@ -132,6 +135,16 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
|
|
|
132
135
|
yield { document: doc };
|
|
133
136
|
}
|
|
134
137
|
},
|
|
138
|
+
streamDocumentChanges: async function* streamDocumentChanges(params, options = {}) {
|
|
139
|
+
const pollIntervalMs = 5000;
|
|
140
|
+
while (!options.signal?.aborted) {
|
|
141
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
142
|
+
if (options.signal?.aborted) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
yield { type: 'poll', model: params.model, documentId: params.documentId };
|
|
146
|
+
}
|
|
147
|
+
},
|
|
135
148
|
getCollectionInfo: function getCollectionInfo(params) {
|
|
136
149
|
return client.post('', { action: 'Model.getCollectionInfo', ...params }).then(res => res.data);
|
|
137
150
|
},
|
|
@@ -159,6 +172,23 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
|
|
|
159
172
|
return client.post('', { action: 'Model.updateDocuments', ...params }).then(res => res.data);
|
|
160
173
|
}
|
|
161
174
|
};
|
|
175
|
+
exports.Task = {
|
|
176
|
+
cancelTask: function cancelTask(params) {
|
|
177
|
+
return client.post('', { action: 'Task.cancelTask', ...params }).then(res => res.data);
|
|
178
|
+
},
|
|
179
|
+
createTask: function createTask(params) {
|
|
180
|
+
return client.post('', { action: 'Task.createTask', ...params }).then(res => res.data);
|
|
181
|
+
},
|
|
182
|
+
getTasks: function getTasks(params) {
|
|
183
|
+
return client.post('', { action: 'Task.getTasks', ...params }).then(res => res.data);
|
|
184
|
+
},
|
|
185
|
+
rescheduleTask: function rescheduleTask(params) {
|
|
186
|
+
return client.post('', { action: 'Task.rescheduleTask', ...params }).then(res => res.data);
|
|
187
|
+
},
|
|
188
|
+
runTask: function runTask(params) {
|
|
189
|
+
return client.post('', { action: 'Task.runTask', ...params }).then(res => res.data);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
162
192
|
} else {
|
|
163
193
|
exports.status = function status() {
|
|
164
194
|
return client.get('/status').then(res => res.data);
|
|
@@ -270,6 +300,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
|
|
|
270
300
|
deleteDocuments: function(params) {
|
|
271
301
|
return client.post('/Model/deleteDocuments', params).then(res => res.data);
|
|
272
302
|
},
|
|
303
|
+
executeDocumentScript: function(params) {
|
|
304
|
+
return client.post('/Model/executeDocumentScript', params).then(res => res.data);
|
|
305
|
+
},
|
|
273
306
|
exportQueryResults(params) {
|
|
274
307
|
const accessToken = window.localStorage.getItem('_mongooseStudioAccessToken') || null;
|
|
275
308
|
|
|
@@ -352,6 +385,56 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
|
|
|
352
385
|
}
|
|
353
386
|
}
|
|
354
387
|
},
|
|
388
|
+
streamDocumentChanges: async function* streamDocumentChanges(params, options = {}) {
|
|
389
|
+
const accessToken = window.localStorage.getItem('_mongooseStudioAccessToken') || null;
|
|
390
|
+
const url = window.MONGOOSE_STUDIO_CONFIG.baseURL + '/Model/streamDocumentChanges?' + new URLSearchParams(params).toString();
|
|
391
|
+
|
|
392
|
+
const response = await fetch(url, {
|
|
393
|
+
method: 'GET',
|
|
394
|
+
headers: {
|
|
395
|
+
Authorization: `${accessToken}`,
|
|
396
|
+
Accept: 'text/event-stream'
|
|
397
|
+
},
|
|
398
|
+
signal: options.signal
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
if (!response.ok) {
|
|
402
|
+
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const reader = response.body.getReader();
|
|
406
|
+
const decoder = new TextDecoder('utf-8');
|
|
407
|
+
let buffer = '';
|
|
408
|
+
|
|
409
|
+
while (true) {
|
|
410
|
+
const { done, value } = await reader.read();
|
|
411
|
+
if (done) break;
|
|
412
|
+
buffer += decoder.decode(value, { stream: true });
|
|
413
|
+
|
|
414
|
+
let eventEnd;
|
|
415
|
+
while ((eventEnd = buffer.indexOf('\n\n')) !== -1) {
|
|
416
|
+
const eventStr = buffer.slice(0, eventEnd);
|
|
417
|
+
buffer = buffer.slice(eventEnd + 2);
|
|
418
|
+
|
|
419
|
+
// Parse SSE event
|
|
420
|
+
const lines = eventStr.split('\n');
|
|
421
|
+
let data = '';
|
|
422
|
+
for (const line of lines) {
|
|
423
|
+
if (line.startsWith('data:')) {
|
|
424
|
+
data += line.slice(5).trim();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (data) {
|
|
428
|
+
try {
|
|
429
|
+
yield JSON.parse(data);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
// If not JSON, yield as string
|
|
432
|
+
yield data;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
},
|
|
355
438
|
getCollectionInfo: function getCollectionInfo(params) {
|
|
356
439
|
return client.post('/Model/getCollectionInfo', params).then(res => res.data);
|
|
357
440
|
},
|
|
@@ -420,4 +503,21 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
|
|
|
420
503
|
return client.post('/Model/updateDocuments', params).then(res => res.data);
|
|
421
504
|
}
|
|
422
505
|
};
|
|
506
|
+
exports.Task = {
|
|
507
|
+
cancelTask: function cancelTask(params) {
|
|
508
|
+
return client.post('/Task/cancelTask', params).then(res => res.data);
|
|
509
|
+
},
|
|
510
|
+
createTask: function createTask(params) {
|
|
511
|
+
return client.post('/Task/createTask', params).then(res => res.data);
|
|
512
|
+
},
|
|
513
|
+
getTasks: function getTasks(params) {
|
|
514
|
+
return client.post('/Task/getTasks', params).then(res => res.data);
|
|
515
|
+
},
|
|
516
|
+
rescheduleTask: function rescheduleTask(params) {
|
|
517
|
+
return client.post('/Task/rescheduleTask', params).then(res => res.data);
|
|
518
|
+
},
|
|
519
|
+
runTask: function runTask(params) {
|
|
520
|
+
return client.post('/Task/runTask', params).then(res => res.data);
|
|
521
|
+
}
|
|
522
|
+
};
|
|
423
523
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
<div class="py-2">
|
|
2
|
-
<div v-if="header" class="border-b border-gray-100 px-2 pb-2 text-xl font-bold">
|
|
3
|
-
{{header}}
|
|
4
|
-
</div>
|
|
5
2
|
<div class="text-xl pb-2">
|
|
6
|
-
<
|
|
3
|
+
<list-json
|
|
4
|
+
:value="value.$document.document"
|
|
5
|
+
:references="references" />
|
|
7
6
|
</div>
|
|
8
|
-
</div>
|
|
7
|
+
</div>
|
|
@@ -7,23 +7,22 @@ const template = require('./dashboard-document.html');
|
|
|
7
7
|
module.exports = app => app.component('dashboard-document', {
|
|
8
8
|
template: template,
|
|
9
9
|
props: ['value'],
|
|
10
|
+
inject: ['state'],
|
|
10
11
|
computed: {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
references() {
|
|
13
|
+
const model = this.value.$document?.model;
|
|
14
|
+
if (typeof model === 'string' && this.state.modelSchemaPaths?.[model]) {
|
|
15
|
+
const map = {};
|
|
16
|
+
for (const path of Object.keys(this.state.modelSchemaPaths[model])) {
|
|
17
|
+
const definition = this.state.modelSchemaPaths[model][path];
|
|
18
|
+
if (definition?.ref) {
|
|
19
|
+
map[path] = definition.ref;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return map;
|
|
14
23
|
}
|
|
24
|
+
|
|
15
25
|
return null;
|
|
16
|
-
},
|
|
17
|
-
schemaPaths() {
|
|
18
|
-
return Object.keys(this.value?.$document?.schemaPaths || {}).sort((k1, k2) => {
|
|
19
|
-
if (k1 === '_id' && k2 !== '_id') {
|
|
20
|
-
return -1;
|
|
21
|
-
}
|
|
22
|
-
if (k1 !== '_id' && k2 === '_id') {
|
|
23
|
-
return 1;
|
|
24
|
-
}
|
|
25
|
-
return 0;
|
|
26
|
-
}).map(key => this.value?.$document.schemaPaths[key]);
|
|
27
26
|
}
|
|
28
27
|
}
|
|
29
28
|
});
|
|
@@ -29,6 +29,18 @@
|
|
|
29
29
|
</button>
|
|
30
30
|
</div>
|
|
31
31
|
|
|
32
|
+
<div class="hidden md:flex items-center gap-3 text-sm text-slate-600">
|
|
33
|
+
<div class="text-right leading-tight">
|
|
34
|
+
<div class="text-xs uppercase tracking-wide text-slate-400 flex items-center gap-2 justify-end">
|
|
35
|
+
<span>Loaded at</span>
|
|
36
|
+
<span class="inline-flex items-center gap-1 text-[10px] font-semibold" :class="autoRefreshEnabled ? 'text-forest-green-600' : 'text-slate-400'">
|
|
37
|
+
<span class="inline-block h-1.5 w-1.5 rounded-full" :class="autoRefreshEnabled ? 'bg-forest-green-500' : 'bg-slate-300'"></span>
|
|
38
|
+
</span>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="font-medium text-slate-700">{{lastUpdatedLabel}}</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
32
44
|
<div class="gap-2 hidden md:flex items-center">
|
|
33
45
|
<button
|
|
34
46
|
v-if="!editting"
|
|
@@ -77,6 +89,23 @@
|
|
|
77
89
|
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50"
|
|
78
90
|
>
|
|
79
91
|
<div class="py-1 flex flex-col">
|
|
92
|
+
<button
|
|
93
|
+
@click="refreshDocument({ force: true, source: 'manual' }); desktopMenuOpen = false"
|
|
94
|
+
:disabled="isRefreshing"
|
|
95
|
+
:class="['flex items-center px-4 py-2 text-sm text-gray-700', isRefreshing ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-100']"
|
|
96
|
+
type="button"
|
|
97
|
+
>
|
|
98
|
+
{{isRefreshing ? 'Refreshing...' : 'Refresh'}}
|
|
99
|
+
</button>
|
|
100
|
+
<button
|
|
101
|
+
@click="toggleAutoRefresh(); desktopMenuOpen = false"
|
|
102
|
+
:disabled="isLambda"
|
|
103
|
+
type="button"
|
|
104
|
+
:class="['flex items-center px-4 py-2 text-sm', isLambda ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700 hover:bg-slate-100']"
|
|
105
|
+
:title="isLambda ? 'Auto-refresh only available on Express deployments' : ''"
|
|
106
|
+
>
|
|
107
|
+
{{autoRefreshEnabled ? 'Disable Auto-Refresh' : 'Enable Auto-Refresh'}}
|
|
108
|
+
</button>
|
|
80
109
|
<button
|
|
81
110
|
@click="addField(); desktopMenuOpen = false"
|
|
82
111
|
type="button"
|
|
@@ -84,6 +113,20 @@
|
|
|
84
113
|
>
|
|
85
114
|
Add Field
|
|
86
115
|
</button>
|
|
116
|
+
<button
|
|
117
|
+
@click="copyDocument(); desktopMenuOpen = false"
|
|
118
|
+
type="button"
|
|
119
|
+
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-blue-100"
|
|
120
|
+
>
|
|
121
|
+
Copy Document
|
|
122
|
+
</button>
|
|
123
|
+
<button
|
|
124
|
+
@click="openScriptDrawer()"
|
|
125
|
+
type="button"
|
|
126
|
+
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-indigo-100"
|
|
127
|
+
>
|
|
128
|
+
Run Script
|
|
129
|
+
</button>
|
|
87
130
|
<button
|
|
88
131
|
@click="shouldShowDeleteModal=true; desktopMenuOpen = false"
|
|
89
132
|
:disabled="!canManipulate"
|
|
@@ -150,6 +193,21 @@
|
|
|
150
193
|
>
|
|
151
194
|
Save
|
|
152
195
|
</button>
|
|
196
|
+
<button
|
|
197
|
+
@click="refreshDocument({ force: true, source: 'manual' }); mobileMenuOpen = false"
|
|
198
|
+
:disabled="isRefreshing"
|
|
199
|
+
:class="['flex items-center px-4 py-2 text-sm text-gray-700', isRefreshing ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-100']"
|
|
200
|
+
type="button"
|
|
201
|
+
>
|
|
202
|
+
{{isRefreshing ? 'Refreshing...' : 'Refresh'}}
|
|
203
|
+
</button>
|
|
204
|
+
<button
|
|
205
|
+
@click="toggleAutoRefresh(); mobileMenuOpen = false"
|
|
206
|
+
type="button"
|
|
207
|
+
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-slate-100"
|
|
208
|
+
>
|
|
209
|
+
Auto-Refresh {{autoRefreshEnabled ? 'ON' : 'OFF'}}
|
|
210
|
+
</button>
|
|
153
211
|
<button
|
|
154
212
|
@click="addField(); mobileMenuOpen = false"
|
|
155
213
|
type="button"
|
|
@@ -157,6 +215,20 @@
|
|
|
157
215
|
>
|
|
158
216
|
Add Field
|
|
159
217
|
</button>
|
|
218
|
+
<button
|
|
219
|
+
@click="copyDocument(); mobileMenuOpen = false"
|
|
220
|
+
type="button"
|
|
221
|
+
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-blue-100"
|
|
222
|
+
>
|
|
223
|
+
Copy Document
|
|
224
|
+
</button>
|
|
225
|
+
<button
|
|
226
|
+
@click="openScriptDrawer()"
|
|
227
|
+
type="button"
|
|
228
|
+
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-indigo-100"
|
|
229
|
+
>
|
|
230
|
+
Run Script
|
|
231
|
+
</button>
|
|
160
232
|
<button
|
|
161
233
|
@click="shouldShowDeleteModal=true; mobileMenuOpen = false"
|
|
162
234
|
:disabled="!canManipulate"
|
|
@@ -209,5 +281,13 @@
|
|
|
209
281
|
</template>
|
|
210
282
|
</modal>
|
|
211
283
|
</div>
|
|
284
|
+
<execute-script
|
|
285
|
+
:visible="scriptDrawerOpen"
|
|
286
|
+
:model="model"
|
|
287
|
+
:document-id="documentId"
|
|
288
|
+
:editting="editting"
|
|
289
|
+
@close="closeScriptDrawer"
|
|
290
|
+
@refresh="handleScriptRefresh"
|
|
291
|
+
></execute-script>
|
|
212
292
|
</div>
|
|
213
293
|
</div>
|
|
@@ -26,30 +26,26 @@ module.exports = app => app.component('document', {
|
|
|
26
26
|
shouldShowConfirmModal: false,
|
|
27
27
|
shouldShowDeleteModal: false,
|
|
28
28
|
shouldShowCloneModal: false,
|
|
29
|
-
previousQuery: null
|
|
29
|
+
previousQuery: null,
|
|
30
|
+
lastUpdatedAt: null,
|
|
31
|
+
isRefreshing: false,
|
|
32
|
+
autoRefreshEnabled: false,
|
|
33
|
+
autoRefreshConnecting: false,
|
|
34
|
+
autoRefreshLoopRunning: false,
|
|
35
|
+
autoRefreshError: null,
|
|
36
|
+
autoRefreshAbortController: null,
|
|
37
|
+
autoRefreshRetryTimer: null,
|
|
38
|
+
pendingRefresh: false,
|
|
39
|
+
scriptDrawerOpen: false
|
|
30
40
|
}),
|
|
31
41
|
async mounted() {
|
|
32
42
|
window.pageState = this;
|
|
33
43
|
// Store query parameters from the route (preserved from models page)
|
|
34
44
|
this.previousQuery = Object.assign({}, this.$route.query);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
this.schemaPaths = Object.keys(schemaPaths).sort((k1, k2) => {
|
|
40
|
-
if (k1 === '_id' && k2 !== '_id') {
|
|
41
|
-
return -1;
|
|
42
|
-
}
|
|
43
|
-
if (k1 !== '_id' && k2 === '_id') {
|
|
44
|
-
return 1;
|
|
45
|
-
}
|
|
46
|
-
return 0;
|
|
47
|
-
}).map(key => schemaPaths[key]);
|
|
48
|
-
this.virtualPaths = virtualPaths || [];
|
|
49
|
-
this.status = 'loaded';
|
|
50
|
-
} finally {
|
|
51
|
-
this.status = 'loaded';
|
|
52
|
-
}
|
|
45
|
+
await this.refreshDocument({ force: true, source: 'initial' });
|
|
46
|
+
},
|
|
47
|
+
beforeDestroy() {
|
|
48
|
+
this.stopAutoRefresh();
|
|
53
49
|
},
|
|
54
50
|
computed: {
|
|
55
51
|
canManipulate() {
|
|
@@ -61,8 +57,32 @@ module.exports = app => app.component('document', {
|
|
|
61
57
|
}
|
|
62
58
|
return !this.roles.includes('readonly');
|
|
63
59
|
},
|
|
60
|
+
lastUpdatedLabel() {
|
|
61
|
+
if (!this.lastUpdatedAt) {
|
|
62
|
+
return '—';
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return new Date(this.lastUpdatedAt).toLocaleTimeString([], {
|
|
66
|
+
hour: '2-digit',
|
|
67
|
+
minute: '2-digit',
|
|
68
|
+
second: '2-digit'
|
|
69
|
+
});
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return '—';
|
|
72
|
+
}
|
|
73
|
+
},
|
|
64
74
|
canEdit() {
|
|
65
75
|
return this.canManipulate && this.viewMode === 'fields';
|
|
76
|
+
},
|
|
77
|
+
isLambda() {
|
|
78
|
+
return !!window?.MONGOOSE_STUDIO_CONFIG?.isLambda;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
watch: {
|
|
82
|
+
editting(nextValue) {
|
|
83
|
+
if (!nextValue && this.pendingRefresh) {
|
|
84
|
+
this.refreshDocument({ source: 'pending' });
|
|
85
|
+
}
|
|
66
86
|
}
|
|
67
87
|
},
|
|
68
88
|
methods: {
|
|
@@ -70,6 +90,125 @@ module.exports = app => app.component('document', {
|
|
|
70
90
|
this.changes = {};
|
|
71
91
|
this.editting = false;
|
|
72
92
|
},
|
|
93
|
+
async refreshDocument(options = {}) {
|
|
94
|
+
const { force = false, source = 'manual' } = options;
|
|
95
|
+
if (this.editting && !force) {
|
|
96
|
+
this.pendingRefresh = true;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (this.isRefreshing) {
|
|
100
|
+
this.pendingRefresh = true;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const isInitial = this.status === 'init';
|
|
105
|
+
if (isInitial) {
|
|
106
|
+
this.status = 'loading';
|
|
107
|
+
}
|
|
108
|
+
this.isRefreshing = true;
|
|
109
|
+
this.autoRefreshError = null;
|
|
110
|
+
try {
|
|
111
|
+
const { doc, schemaPaths, virtualPaths } = await api.Model.getDocument({
|
|
112
|
+
model: this.model,
|
|
113
|
+
documentId: this.documentId
|
|
114
|
+
});
|
|
115
|
+
window.doc = doc;
|
|
116
|
+
this.document = doc;
|
|
117
|
+
this.schemaPaths = Object.keys(schemaPaths).sort((k1, k2) => {
|
|
118
|
+
if (k1 === '_id' && k2 !== '_id') {
|
|
119
|
+
return -1;
|
|
120
|
+
}
|
|
121
|
+
if (k1 !== '_id' && k2 === '_id') {
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
return 0;
|
|
125
|
+
}).map(key => schemaPaths[key]);
|
|
126
|
+
this.virtualPaths = virtualPaths || [];
|
|
127
|
+
this.lastUpdatedAt = new Date();
|
|
128
|
+
this.pendingRefresh = false;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error('Error refreshing document:', err);
|
|
131
|
+
if (this.$toast && source !== 'initial') {
|
|
132
|
+
this.$toast.error('Failed to refresh document');
|
|
133
|
+
}
|
|
134
|
+
} finally {
|
|
135
|
+
this.status = 'loaded';
|
|
136
|
+
this.isRefreshing = false;
|
|
137
|
+
if (this.pendingRefresh && !this.editting) {
|
|
138
|
+
this.pendingRefresh = false;
|
|
139
|
+
this.refreshDocument({ source: 'pending' });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
toggleAutoRefresh() {
|
|
144
|
+
if (this.autoRefreshEnabled) {
|
|
145
|
+
this.stopAutoRefresh();
|
|
146
|
+
} else {
|
|
147
|
+
this.startAutoRefresh();
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
startAutoRefresh() {
|
|
151
|
+
if (this.autoRefreshEnabled) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.autoRefreshEnabled = true;
|
|
155
|
+
this.autoRefreshError = null;
|
|
156
|
+
this.runAutoRefreshLoop();
|
|
157
|
+
},
|
|
158
|
+
stopAutoRefresh() {
|
|
159
|
+
this.autoRefreshEnabled = false;
|
|
160
|
+
this.autoRefreshConnecting = false;
|
|
161
|
+
if (this.autoRefreshAbortController) {
|
|
162
|
+
this.autoRefreshAbortController.abort();
|
|
163
|
+
this.autoRefreshAbortController = null;
|
|
164
|
+
}
|
|
165
|
+
if (this.autoRefreshRetryTimer) {
|
|
166
|
+
clearTimeout(this.autoRefreshRetryTimer);
|
|
167
|
+
this.autoRefreshRetryTimer = null;
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
async runAutoRefreshLoop() {
|
|
171
|
+
if (this.autoRefreshLoopRunning) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
this.autoRefreshLoopRunning = true;
|
|
175
|
+
this.autoRefreshAbortController = new AbortController();
|
|
176
|
+
let retryDelay = 1500;
|
|
177
|
+
|
|
178
|
+
while (this.autoRefreshEnabled && !this.autoRefreshAbortController.signal.aborted) {
|
|
179
|
+
try {
|
|
180
|
+
this.autoRefreshConnecting = true;
|
|
181
|
+
for await (const event of api.Model.streamDocumentChanges({
|
|
182
|
+
model: this.model,
|
|
183
|
+
documentId: this.documentId
|
|
184
|
+
}, { signal: this.autoRefreshAbortController.signal })) {
|
|
185
|
+
this.autoRefreshConnecting = false;
|
|
186
|
+
if (!event || event.type === 'heartbeat') {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
await this.refreshDocument({ source: 'auto' });
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (this.autoRefreshAbortController.signal.aborted) {
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
this.autoRefreshError = err?.message || String(err);
|
|
196
|
+
} finally {
|
|
197
|
+
this.autoRefreshConnecting = false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!this.autoRefreshEnabled || this.autoRefreshAbortController.signal.aborted) {
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await new Promise(resolve => {
|
|
205
|
+
this.autoRefreshRetryTimer = setTimeout(resolve, retryDelay);
|
|
206
|
+
});
|
|
207
|
+
retryDelay = Math.min(retryDelay * 2, 15000);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.autoRefreshLoopRunning = false;
|
|
211
|
+
},
|
|
73
212
|
async save() {
|
|
74
213
|
if (Object.keys(this.invalid).length > 0) {
|
|
75
214
|
throw new Error('Invalid paths: ' + Object.keys(this.invalid).join(', '));
|
|
@@ -137,12 +276,60 @@ module.exports = app => app.component('document', {
|
|
|
137
276
|
this.changes = {};
|
|
138
277
|
}
|
|
139
278
|
},
|
|
279
|
+
copyDocument() {
|
|
280
|
+
if (!this.document) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const textToCopy = JSON.stringify(this.document, null, 2);
|
|
285
|
+
const fallbackCopy = () => {
|
|
286
|
+
if (typeof document === 'undefined') {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const textArea = document.createElement('textarea');
|
|
290
|
+
textArea.value = textToCopy;
|
|
291
|
+
textArea.setAttribute('readonly', '');
|
|
292
|
+
textArea.style.position = 'absolute';
|
|
293
|
+
textArea.style.left = '-9999px';
|
|
294
|
+
document.body.appendChild(textArea);
|
|
295
|
+
textArea.select();
|
|
296
|
+
try {
|
|
297
|
+
document.execCommand('copy');
|
|
298
|
+
} finally {
|
|
299
|
+
document.body.removeChild(textArea);
|
|
300
|
+
}
|
|
301
|
+
this.$toast.success('Document copied!');
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
|
|
305
|
+
navigator.clipboard.writeText(textToCopy)
|
|
306
|
+
.then(() => {
|
|
307
|
+
this.$toast.success('Document copied!');
|
|
308
|
+
})
|
|
309
|
+
.catch(() => {
|
|
310
|
+
fallbackCopy();
|
|
311
|
+
});
|
|
312
|
+
} else {
|
|
313
|
+
fallbackCopy();
|
|
314
|
+
}
|
|
315
|
+
},
|
|
140
316
|
goBack() {
|
|
141
317
|
// Preserve query parameters when going back to models page
|
|
142
318
|
this.$router.push({
|
|
143
319
|
path: '/model/' + this.model,
|
|
144
320
|
query: this.previousQuery || {}
|
|
145
321
|
});
|
|
322
|
+
},
|
|
323
|
+
openScriptDrawer() {
|
|
324
|
+
this.scriptDrawerOpen = true;
|
|
325
|
+
this.desktopMenuOpen = false;
|
|
326
|
+
this.mobileMenuOpen = false;
|
|
327
|
+
},
|
|
328
|
+
closeScriptDrawer() {
|
|
329
|
+
this.scriptDrawerOpen = false;
|
|
330
|
+
},
|
|
331
|
+
handleScriptRefresh() {
|
|
332
|
+
this.refreshDocument({ force: true, source: 'script' });
|
|
146
333
|
}
|
|
147
334
|
}
|
|
148
335
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.execute-script-backdrop {
|
|
2
|
+
animation: execute-script-fade-in 200ms ease forwards;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.execute-script-panel {
|
|
6
|
+
animation: execute-script-slide-in 200ms ease forwards;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.execute-script-drawer.is-closing .execute-script-backdrop {
|
|
10
|
+
animation: execute-script-fade-out 200ms ease forwards;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.execute-script-drawer.is-closing .execute-script-panel {
|
|
14
|
+
animation: execute-script-slide-out 200ms ease forwards;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@keyframes execute-script-fade-in {
|
|
18
|
+
from { opacity: 0; }
|
|
19
|
+
to { opacity: 1; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@keyframes execute-script-fade-out {
|
|
23
|
+
from { opacity: 1; }
|
|
24
|
+
to { opacity: 0; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@keyframes execute-script-slide-in {
|
|
28
|
+
from { transform: translateX(100%); }
|
|
29
|
+
to { transform: translateX(0); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@keyframes execute-script-slide-out {
|
|
33
|
+
from { transform: translateX(0); }
|
|
34
|
+
to { transform: translateX(100%); }
|
|
35
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<div v-if="isOpen" class="fixed inset-0 z-50 execute-script-drawer" :class="{ 'is-closing': isClosing }">
|
|
2
|
+
<div class="execute-script-backdrop absolute inset-0 bg-black/40" @click="close"></div>
|
|
3
|
+
<div class="execute-script-panel absolute right-0 top-0 h-full w-full max-w-xl bg-white shadow-xl flex flex-col">
|
|
4
|
+
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
|
5
|
+
<div>
|
|
6
|
+
<h2 class="text-lg font-semibold text-gray-900">Run Script</h2>
|
|
7
|
+
<p class="text-xs text-gray-500">Use <code class="bg-gray-100 px-1 rounded">doc</code> for this document.</p>
|
|
8
|
+
</div>
|
|
9
|
+
<button
|
|
10
|
+
type="button"
|
|
11
|
+
@click="close"
|
|
12
|
+
class="text-gray-400 hover:text-gray-600 text-2xl leading-none"
|
|
13
|
+
aria-label="Close drawer"
|
|
14
|
+
>
|
|
15
|
+
×
|
|
16
|
+
</button>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="flex-1 overflow-auto p-5 space-y-4">
|
|
19
|
+
<div class="space-y-2">
|
|
20
|
+
<label class="text-sm font-medium text-gray-700">Script</label>
|
|
21
|
+
<textarea
|
|
22
|
+
ref="scriptEditor"
|
|
23
|
+
:value="scriptText"
|
|
24
|
+
@input="updateScriptText"
|
|
25
|
+
rows="8"
|
|
26
|
+
placeholder="await doc.updateName('new name') return doc"
|
|
27
|
+
class="w-full rounded-md border border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
|
28
|
+
></textarea>
|
|
29
|
+
<p class="text-xs text-gray-500">Return a value to see it in the output panel.</p>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="flex items-center gap-3">
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
@click="runDocumentScript"
|
|
35
|
+
:disabled="scriptRunning"
|
|
36
|
+
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
37
|
+
>
|
|
38
|
+
<span v-if="scriptRunning">Running...</span>
|
|
39
|
+
<span v-else>Run script</span>
|
|
40
|
+
</button>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
@click="clearScript"
|
|
44
|
+
class="text-sm text-gray-500 hover:text-gray-700"
|
|
45
|
+
>
|
|
46
|
+
Clear
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="space-y-3">
|
|
50
|
+
<div v-if="scriptError" class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
|
51
|
+
{{scriptError}}
|
|
52
|
+
</div>
|
|
53
|
+
<div v-if="scriptHasRun" class="rounded-md border border-gray-200 bg-gray-50 p-3">
|
|
54
|
+
<div class="text-xs uppercase tracking-wide text-gray-500 mb-2">Output</div>
|
|
55
|
+
<pre class="whitespace-pre-wrap text-sm text-gray-800">{{formatScriptOutput(scriptResult)}}</pre>
|
|
56
|
+
</div>
|
|
57
|
+
<div v-if="scriptLogs" class="rounded-md border border-gray-200 bg-white p-3">
|
|
58
|
+
<div class="text-xs uppercase tracking-wide text-gray-500 mb-2">Logs</div>
|
|
59
|
+
<pre class="whitespace-pre-wrap text-xs text-gray-700">{{scriptLogs}}</pre>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="border-t border-gray-200 px-5 py-3 text-xs text-gray-500">
|
|
64
|
+
Scripts run on the server with access to the Mongoose document instance.
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|