Skip to content

Commit c44e1d3

Browse files
authored
Merge branch 'master' into johnWeak-master
2 parents 770b5be + c947b78 commit c44e1d3

File tree

3 files changed

+215
-14
lines changed

3 files changed

+215
-14
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ Fixes:
33
- [core] Fix user analytics widget chart
44
- [core] Fix mongo connection url parsing
55
- [crashes] Fix free session and free user calculation
6+
- [dashboards] Delete associated widgets and reports when a dashboard is removed
67

78
Enterprise Fixes:
89
- [crash_symbolication] Remove auto symbolication setting
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* Description : Remove orphan widget and long_tasks records left behind after a dashboard is deleted
3+
* Server : countly
4+
* Path : $(countly dir)/bin/scripts/fix-data
5+
* Command : node delete_widgets_of_deleted_dashboards.js [--dry-run]
6+
* Usage :
7+
* # Preview only
8+
* node delete_widgets_of_deleted_dashboards.js --dry-run
9+
* # Actual deletion
10+
* node delete_widgets_of_deleted_dashboards.js
11+
*/
12+
const DRY_RUN = process.argv.includes('--dry-run');
13+
14+
const BATCH_SIZE = 1000;
15+
const pluginManager = require('../../../plugins/pluginManager.js');
16+
17+
// Widget type configurations for long_tasks relationships
18+
const WIDGET_LONG_TASK_CONFIG = {
19+
'drill': {
20+
reportField: 'drill_report'
21+
},
22+
'users': {
23+
reportField: 'drill_report'
24+
},
25+
'formulas': {
26+
reportField: 'cmetrics'
27+
}
28+
};
29+
30+
/**
31+
* Deletes documents in batches to avoid oversized commands
32+
* @param {Object} db - MongoDB connection
33+
* @param {String} collection - Collection name
34+
* @param {Array} ids - List of document ids to delete
35+
*/
36+
async function deleteByChunks(db, collection, ids) {
37+
let bucket = [];
38+
39+
for (const id of ids) {
40+
bucket.push(id);
41+
42+
if (bucket.length === BATCH_SIZE) {
43+
await runDelete(bucket);
44+
bucket = [];
45+
}
46+
}
47+
48+
if (bucket.length) {
49+
await runDelete(bucket);
50+
}
51+
52+
/**
53+
* Executes the delete operation for a batch of ids
54+
* @param {Array} batch - Array of document ids to delete
55+
* @returns {Promise<void>}
56+
* */
57+
async function runDelete(batch) {
58+
if (DRY_RUN) {
59+
console.log(`[dry-run] ${collection}: would delete ${batch.length}`);
60+
}
61+
else {
62+
const res = await db.collection(collection).deleteMany({ _id: { $in: batch } });
63+
console.log(`[deleted] ${collection}: ${res.deletedCount}`);
64+
}
65+
}
66+
}
67+
68+
/**
69+
* Counts references to reports and returns only unreferenced ones
70+
* @param {Object} db - MongoDB connection
71+
* @param {Array} reportIds - Report IDs to be checked
72+
* @param {Array} excludeWidgetIds - Widget IDs to exclude from reference check
73+
* @returns {Array} Unreferenced report IDs
74+
*/
75+
async function getUnreferencedReports(db, reportIds, excludeWidgetIds) {
76+
if (!reportIds || !reportIds.length) {
77+
return [];
78+
}
79+
80+
let referencedReports = [];
81+
82+
// Check all widget types that can reference reports
83+
for (const [widgetType, config] of Object.entries(WIDGET_LONG_TASK_CONFIG)) {
84+
const query = {
85+
widget_type: widgetType,
86+
[config.reportField]: { $in: reportIds }
87+
};
88+
89+
// Exclude orphan widgets from reference check
90+
if (excludeWidgetIds.length) {
91+
query._id = { $nin: excludeWidgetIds };
92+
}
93+
94+
const widgets = await db.collection('widgets').find(query, { [config.reportField]: 1 }).toArray();
95+
96+
widgets.forEach(widget => {
97+
const reports = widget[config.reportField] || [];
98+
referencedReports.push(...reports.map(reportId => reportId.toString()));
99+
});
100+
}
101+
102+
// Return only those report IDs that are not referenced in any widget
103+
return reportIds.filter(reportId => !referencedReports.includes(reportId.toString()));
104+
}
105+
106+
/**
107+
* Collects all linked long_task IDs from a widget based on its type
108+
* @param {Object} widget - Widget document
109+
* @returns {Array} Array of long_task IDs
110+
*/
111+
function collectAllLinkedLongTasks(widget) {
112+
const config = WIDGET_LONG_TASK_CONFIG[widget.widget_type];
113+
if (!config) {
114+
return [];
115+
}
116+
117+
const reportField = config.reportField;
118+
return Array.isArray(widget[reportField]) ? widget[reportField] : [];
119+
}
120+
121+
122+
(async() => {
123+
const db = await pluginManager.dbConnection('countly');
124+
125+
try {
126+
const dashboardWidgets = [];
127+
128+
const dashCursor = db.collection('dashboards').find({widgets: {$exists: true, $not: {$size: 0}}}, {projection: {widgets: 1}});
129+
130+
while (await dashCursor.hasNext()) {
131+
const dash = await dashCursor.next();
132+
for (const w of dash.widgets) {
133+
const idStr = (w && w.$oid) ? w.$oid : (w + '');
134+
if (idStr && !dashboardWidgets.includes(idStr)) {
135+
dashboardWidgets.push(idStr);
136+
}
137+
}
138+
}
139+
140+
await dashCursor.close();
141+
142+
const orphanWidgetIds = [];
143+
const allLinkedLongTasks = [];
144+
145+
const widgetCursor = db.collection('widgets').find({});
146+
147+
while (await widgetCursor.hasNext()) {
148+
const w = await widgetCursor.next();
149+
if (!dashboardWidgets.includes(String(w._id))) {
150+
orphanWidgetIds.push(w._id);
151+
152+
// Find linked long_tasks based on widget type
153+
const linkedTasks = collectAllLinkedLongTasks(w);
154+
allLinkedLongTasks.push(...linkedTasks);
155+
}
156+
}
157+
await widgetCursor.close();
158+
159+
console.log(`Orphan widgets found: ${orphanWidgetIds.length}`);
160+
if (DRY_RUN && orphanWidgetIds.length) {
161+
console.log('Orphan widget IDs to be deleted:', orphanWidgetIds.map(id => id.toString()));
162+
}
163+
await deleteByChunks(db, 'widgets', orphanWidgetIds);
164+
165+
const unreferencedLongTasks = await getUnreferencedReports(db, allLinkedLongTasks, orphanWidgetIds);
166+
console.log(`Unreferenced long_tasks to delete: ${unreferencedLongTasks.length}`);
167+
if (DRY_RUN && unreferencedLongTasks.length) {
168+
console.log('Unreferenced long_task IDs to be deleted:', unreferencedLongTasks.map(id => id.toString()));
169+
}
170+
await deleteByChunks(db, 'long_tasks', unreferencedLongTasks);
171+
172+
console.log(DRY_RUN ? 'Dry-run finished' : 'Cleanup completed.');
173+
}
174+
catch (err) {
175+
console.error(err);
176+
}
177+
finally {
178+
db.close();
179+
}
180+
})();

plugins/dashboards/api/api.js

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,30 +1093,50 @@ plugins.setConfigs("dashboards", {
10931093
filterCond.owner_id = memberId;
10941094
}
10951095

1096-
common.db.collection("dashboards").findOne({_id: common.db.ObjectID(dashboardId)}, function(err, dashboard) {
1096+
common.db.collection("dashboards").findOne({_id: common.db.ObjectID(dashboardId)}, async function(err, dashboard) {
10971097
if (err || !dashboard) {
10981098
common.returnMessage(params, 400, "Dashboard with the given id doesn't exist");
10991099
}
11001100
else {
1101-
hasViewAccessToDashboard(params.member, dashboard, function(er, status) {
1101+
hasViewAccessToDashboard(params.member, dashboard, async function(er, status) {
11021102
if (er || !status) {
11031103
return common.returnOutput(params, {error: true, dashboard_access_denied: true});
11041104
}
11051105

1106-
common.db.collection("dashboards").remove(
1107-
filterCond,
1108-
function(error, result) {
1109-
if (!error && result) {
1110-
if (result && result.result && result.result.n === 1) {
1111-
plugins.dispatch("/systemlogs", {params: params, action: "dashboard_deleted", data: dashboard});
1112-
}
1113-
common.returnOutput(params, result);
1114-
}
1115-
else {
1116-
common.returnMessage(params, 500, "Failed to delete dashboard");
1106+
try {
1107+
const dashboardToDelete = await common.db.collection("dashboards").findOne(filterCond);
1108+
1109+
if (!dashboardToDelete) {
1110+
return common.returnMessage(params, 404, "Dashboard not found");
1111+
}
1112+
1113+
// Collect widget IDs from the dashboard
1114+
const widgetIds = (dashboardToDelete.widgets || []).map(w => common.db.ObjectID(w.$oid || w));
1115+
1116+
// Delete widgets and linked reports
1117+
for (const wid of widgetIds) {
1118+
const widget = await common.db.collection("widgets").findOneAndDelete({ _id: wid });
1119+
1120+
if (widget && widget.value) {
1121+
plugins.dispatch("/dashboard/widget/deleted", { params, widget: widget.value });
11171122
}
11181123
}
1119-
);
1124+
1125+
// Remove the dashboard
1126+
const result = await common.db.collection("dashboards").deleteOne(filterCond);
1127+
1128+
if (result && result.deletedCount) {
1129+
plugins.dispatch("/systemlogs", {params: params, action: "dashboard_deleted", data: dashboard});
1130+
common.returnOutput(params, result);
1131+
}
1132+
else {
1133+
common.returnMessage(params, 500, "Failed to delete dashboard");
1134+
}
1135+
}
1136+
catch (error) {
1137+
console.error("Error during dashboard deletion:", error);
1138+
common.returnMessage(params, 500, "An error occurred while deleting the dashboard");
1139+
}
11201140
});
11211141
}
11221142
});

0 commit comments

Comments
 (0)