@onmartech/metabase-ai-assistant 4.0.0
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/.env.example +38 -0
- package/LICENSE +201 -0
- package/README.md +364 -0
- package/README_MCP.md +279 -0
- package/package.json +99 -0
- package/src/ai/assistant.js +982 -0
- package/src/cli/interactive.js +500 -0
- package/src/database/connection-manager.js +350 -0
- package/src/database/direct-client.js +686 -0
- package/src/index.js +162 -0
- package/src/mcp/handlers/actions.js +213 -0
- package/src/mcp/handlers/ai.js +207 -0
- package/src/mcp/handlers/analytics.js +1647 -0
- package/src/mcp/handlers/cards.js +1544 -0
- package/src/mcp/handlers/collections.js +244 -0
- package/src/mcp/handlers/dashboard.js +207 -0
- package/src/mcp/handlers/dashboard_direct.js +292 -0
- package/src/mcp/handlers/database.js +322 -0
- package/src/mcp/handlers/docs.js +399 -0
- package/src/mcp/handlers/index.js +35 -0
- package/src/mcp/handlers/metadata.js +190 -0
- package/src/mcp/handlers/questions.js +134 -0
- package/src/mcp/handlers/schema.js +1699 -0
- package/src/mcp/handlers/sql.js +559 -0
- package/src/mcp/handlers/users.js +251 -0
- package/src/mcp/job-store.js +199 -0
- package/src/mcp/server.js +428 -0
- package/src/mcp/tool-registry.js +3244 -0
- package/src/mcp/tool-router.js +149 -0
- package/src/metabase/client.js +737 -0
- package/src/metabase/metadata-client.js +1852 -0
- package/src/utils/activity-logger.js +489 -0
- package/src/utils/cache.js +176 -0
- package/src/utils/config.js +131 -0
- package/src/utils/definition-tables.js +938 -0
- package/src/utils/file-operations.js +496 -0
- package/src/utils/logger.js +45 -0
- package/src/utils/parametric-questions.js +627 -0
- package/src/utils/response-optimizer.js +190 -0
- package/src/utils/sql-sanitizer.js +97 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { logger } from '../../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
export class CollectionsHandler {
|
|
5
|
+
constructor(metabaseClient) {
|
|
6
|
+
this.metabaseClient = metabaseClient;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
routes() {
|
|
10
|
+
return {
|
|
11
|
+
'mb_collection_create': (args) => this.handleCollectionCreate(args),
|
|
12
|
+
'mb_collection_list': (args) => this.handleCollectionList(args),
|
|
13
|
+
'mb_collection_move': (args) => this.handleCollectionMove(args),
|
|
14
|
+
'mb_collection_copy': (args) => this.handleCollectionCopy(args),
|
|
15
|
+
'mb_collection_permissions_get': (args) => this.handleCollectionPermissionsGet(args),
|
|
16
|
+
'mb_collection_permissions_update': (args) => this.handleCollectionPermissionsUpdate(args),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async handleCollectionCreate(args) {
|
|
21
|
+
try {
|
|
22
|
+
await this.ensureInitialized();
|
|
23
|
+
|
|
24
|
+
const collectionData = {
|
|
25
|
+
name: args.name,
|
|
26
|
+
description: args.description || '',
|
|
27
|
+
parent_id: args.parent_id || null,
|
|
28
|
+
color: args.color || '#509EE3'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const collection = await this.metabaseClient.request('POST', '/api/collection', collectionData);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
content: [{
|
|
35
|
+
type: 'text',
|
|
36
|
+
text: `✅ **Collection Created!**\\n\\n` +
|
|
37
|
+
`🆔 Collection ID: ${collection.id}\\n` +
|
|
38
|
+
`📁 Name: ${collection.name}\\n` +
|
|
39
|
+
`📝 Description: ${collection.description || 'None'}\\n` +
|
|
40
|
+
`🎨 Color: ${collection.color}\\n` +
|
|
41
|
+
`📂 Parent: ${args.parent_id || 'Root'}`
|
|
42
|
+
}]
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
} catch (error) {
|
|
46
|
+
// Better error messages for common issues
|
|
47
|
+
let userMessage = error.message;
|
|
48
|
+
|
|
49
|
+
if (error.message.includes('already exists') || error.response?.status === 409) {
|
|
50
|
+
userMessage = `Collection already exists with this name: "${args.name}"`;
|
|
51
|
+
} else if (error.message.includes('permission') || error.response?.status === 403) {
|
|
52
|
+
userMessage = `Permission denied. Contact admin for collection creation access.`;
|
|
53
|
+
} else if (error.message.includes('parent') || (error.message.includes('not found') && args.parent_id)) {
|
|
54
|
+
userMessage = `Parent collection not found: ID ${args.parent_id}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: 'text', text: `❌ Collection creation failed: ${userMessage}` }]
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async handleCollectionList(args) {
|
|
64
|
+
try {
|
|
65
|
+
await this.ensureInitialized();
|
|
66
|
+
|
|
67
|
+
let endpoint = '/api/collection';
|
|
68
|
+
if (args.parent_id) {
|
|
69
|
+
endpoint = `/api/collection/${args.parent_id}/items`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const collections = await this.metabaseClient.request('GET', '/api/collection');
|
|
73
|
+
|
|
74
|
+
let output = `📂 **Collections**\\n\\n`;
|
|
75
|
+
|
|
76
|
+
const rootCollections = collections.filter(c => !c.personal_owner_id);
|
|
77
|
+
rootCollections.slice(0, 20).forEach((col, i) => {
|
|
78
|
+
output += `${i + 1}. **${col.name}** (ID: ${col.id})\\n`;
|
|
79
|
+
if (col.description) output += ` ${col.description.substring(0, 50)}...\\n`;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
output += `\\n📊 Total Collections: ${collections.length}`;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
content: [{ type: 'text', text: output }]
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return {
|
|
90
|
+
content: [{ type: 'text', text: `❌ Collection list failed: ${error.message}` }]
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async handleCollectionMove(args) {
|
|
96
|
+
try {
|
|
97
|
+
await this.ensureInitialized();
|
|
98
|
+
|
|
99
|
+
let endpoint;
|
|
100
|
+
const updateData = { collection_id: args.target_collection_id };
|
|
101
|
+
|
|
102
|
+
switch (args.item_type) {
|
|
103
|
+
case 'card':
|
|
104
|
+
endpoint = `/api/card/${args.item_id}`;
|
|
105
|
+
break;
|
|
106
|
+
case 'dashboard':
|
|
107
|
+
endpoint = `/api/dashboard/${args.item_id}`;
|
|
108
|
+
break;
|
|
109
|
+
case 'collection':
|
|
110
|
+
endpoint = `/api/collection/${args.item_id}`;
|
|
111
|
+
updateData.parent_id = args.target_collection_id;
|
|
112
|
+
delete updateData.collection_id;
|
|
113
|
+
break;
|
|
114
|
+
default:
|
|
115
|
+
throw new Error(`Unknown item type: ${args.item_type}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await this.metabaseClient.request('PUT', endpoint, updateData);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
content: [{
|
|
122
|
+
type: 'text',
|
|
123
|
+
text: `✅ **Item Moved!**\\n\\n` +
|
|
124
|
+
`📦 Type: ${args.item_type}\\n` +
|
|
125
|
+
`🆔 Item ID: ${args.item_id}\\n` +
|
|
126
|
+
`📂 Target Collection: ${args.target_collection_id || 'Root'}`
|
|
127
|
+
}]
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return {
|
|
132
|
+
content: [{ type: 'text', text: `❌ Move failed: ${error.message}` }]
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async handleCollectionPermissionsGet(args) {
|
|
138
|
+
await this.ensureInitialized();
|
|
139
|
+
const { collection_id } = args;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const graph = await this.metabaseClient.request('GET', '/api/collection/graph');
|
|
143
|
+
const collectionPerms = graph.groups;
|
|
144
|
+
|
|
145
|
+
const permissions = [];
|
|
146
|
+
for (const [groupId, perms] of Object.entries(collectionPerms)) {
|
|
147
|
+
const collPerm = perms[collection_id];
|
|
148
|
+
if (collPerm) {
|
|
149
|
+
permissions.push({ group_id: groupId, permission: collPerm });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
content: [{
|
|
155
|
+
type: 'text',
|
|
156
|
+
text: `Collection ${collection_id} permissions:\n${permissions.map(p =>
|
|
157
|
+
` - Group ${p.group_id}: ${p.permission}`
|
|
158
|
+
).join('\n') || ' No specific permissions set'}`
|
|
159
|
+
}]
|
|
160
|
+
};
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return { content: [{ type: 'text', text: `❌ Collection permissions get error: ${error.message}` }] };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async handleCollectionPermissionsUpdate(args) {
|
|
167
|
+
await this.ensureInitialized();
|
|
168
|
+
const { collection_id, group_id, permission } = args;
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
// Get current graph
|
|
172
|
+
const graph = await this.metabaseClient.request('GET', '/api/collection/graph');
|
|
173
|
+
|
|
174
|
+
// Update the permission
|
|
175
|
+
if (!graph.groups[group_id]) {
|
|
176
|
+
graph.groups[group_id] = {};
|
|
177
|
+
}
|
|
178
|
+
graph.groups[group_id][collection_id] = permission;
|
|
179
|
+
|
|
180
|
+
// Save the updated graph
|
|
181
|
+
await this.metabaseClient.request('PUT', '/api/collection/graph', graph);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
content: [{
|
|
185
|
+
type: 'text',
|
|
186
|
+
text: `✅ Collection ${collection_id} permission updated: Group ${group_id} = ${permission}`
|
|
187
|
+
}]
|
|
188
|
+
};
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return { content: [{ type: 'text', text: `❌ Collection permissions update error: ${error.message}` }] };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async handleCollectionCopy(args) {
|
|
195
|
+
await this.ensureInitialized();
|
|
196
|
+
const { collection_id, destination_id, new_name } = args;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
// Get source collection
|
|
200
|
+
const sourceCollection = await this.metabaseClient.request('GET', `/api/collection/${collection_id}`);
|
|
201
|
+
|
|
202
|
+
// Create new collection
|
|
203
|
+
const newCollection = await this.metabaseClient.request('POST', '/api/collection', {
|
|
204
|
+
name: new_name || `Copy of ${sourceCollection.name}`,
|
|
205
|
+
description: sourceCollection.description,
|
|
206
|
+
parent_id: destination_id || sourceCollection.parent_id
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Get items in source collection
|
|
210
|
+
const items = await this.metabaseClient.request('GET', `/api/collection/${collection_id}/items`);
|
|
211
|
+
const allItems = items.data || items;
|
|
212
|
+
|
|
213
|
+
let copiedCards = 0;
|
|
214
|
+
let copiedDashboards = 0;
|
|
215
|
+
|
|
216
|
+
// Copy each item
|
|
217
|
+
for (const item of allItems) {
|
|
218
|
+
if (item.model === 'card') {
|
|
219
|
+
await this.handleCardCopy({
|
|
220
|
+
card_id: item.id,
|
|
221
|
+
collection_id: newCollection.id
|
|
222
|
+
});
|
|
223
|
+
copiedCards++;
|
|
224
|
+
} else if (item.model === 'dashboard') {
|
|
225
|
+
await this.handleDashboardCopy({
|
|
226
|
+
dashboard_id: item.id,
|
|
227
|
+
collection_id: newCollection.id,
|
|
228
|
+
deep_copy: false // Don't deep copy cards as they're already being copied
|
|
229
|
+
});
|
|
230
|
+
copiedDashboards++;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
content: [{
|
|
236
|
+
type: 'text',
|
|
237
|
+
text: `✅ Collection copied:\n New Collection ID: ${newCollection.id}\n Name: ${newCollection.name}\n Cards copied: ${copiedCards}\n Dashboards copied: ${copiedDashboards}`
|
|
238
|
+
}]
|
|
239
|
+
};
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return { content: [{ type: 'text', text: `❌ Collection copy error: ${error.message}` }] };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard Handler Module
|
|
3
|
+
* Handles dashboard creation, management, and visualization operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Handle create dashboard request
|
|
10
|
+
* @param {object} args
|
|
11
|
+
* @param {object} context
|
|
12
|
+
* @returns {Promise<object>}
|
|
13
|
+
*/
|
|
14
|
+
export async function handleCreateDashboard(args, context) {
|
|
15
|
+
const { metabaseClient } = context;
|
|
16
|
+
|
|
17
|
+
const dashboard = await metabaseClient.createDashboard({
|
|
18
|
+
name: args.name,
|
|
19
|
+
description: args.description,
|
|
20
|
+
collection_id: args.collection_id
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
content: [
|
|
25
|
+
{
|
|
26
|
+
type: 'text',
|
|
27
|
+
text: `✅ **Dashboard Created!**\\n\\n` +
|
|
28
|
+
`• Name: ${dashboard.name}\\n` +
|
|
29
|
+
`• ID: ${dashboard.id}\\n` +
|
|
30
|
+
`• Collection: ${args.collection_id || 'Root'}`,
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Handle get dashboards list request
|
|
38
|
+
* @param {object} context
|
|
39
|
+
* @returns {Promise<object>}
|
|
40
|
+
*/
|
|
41
|
+
export async function handleGetDashboards(context) {
|
|
42
|
+
const { metabaseClient } = context;
|
|
43
|
+
|
|
44
|
+
const dashboards = await metabaseClient.getDashboards();
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
type: 'text',
|
|
50
|
+
text: `📊 **Available Dashboards (${dashboards.length})**\\n\\n` +
|
|
51
|
+
dashboards.map(d =>
|
|
52
|
+
`• **${d.name}** (ID: ${d.id})\\n Collection: ${d.collection_id || 'Root'}`
|
|
53
|
+
).join('\\n'),
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Handle add card to dashboard request
|
|
61
|
+
* @param {object} args
|
|
62
|
+
* @param {object} context
|
|
63
|
+
* @returns {Promise<object>}
|
|
64
|
+
*/
|
|
65
|
+
export async function handleAddCardToDashboard(args, context) {
|
|
66
|
+
const { metabaseClient } = context;
|
|
67
|
+
|
|
68
|
+
const position = args.position || {};
|
|
69
|
+
|
|
70
|
+
await metabaseClient.addCardToDashboard(args.dashboard_id, args.question_id, {
|
|
71
|
+
row: position.row || 0,
|
|
72
|
+
col: position.col || 0,
|
|
73
|
+
sizeX: position.sizeX || 6,
|
|
74
|
+
sizeY: position.sizeY || 4
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
content: [
|
|
79
|
+
{
|
|
80
|
+
type: 'text',
|
|
81
|
+
text: `✅ **Card Added to Dashboard!**\\n\\n` +
|
|
82
|
+
`• Dashboard ID: ${args.dashboard_id}\\n` +
|
|
83
|
+
`• Question ID: ${args.question_id}\\n` +
|
|
84
|
+
`• Position: (${position.row || 0}, ${position.col || 0})\\n` +
|
|
85
|
+
`• Size: ${position.sizeX || 6}x${position.sizeY || 4}`,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Handle add dashboard filter request
|
|
93
|
+
* @param {object} args
|
|
94
|
+
* @param {object} context
|
|
95
|
+
* @returns {Promise<object>}
|
|
96
|
+
*/
|
|
97
|
+
export async function handleAddDashboardFilter(args, context) {
|
|
98
|
+
const { metabaseClient } = context;
|
|
99
|
+
|
|
100
|
+
await metabaseClient.addDashboardFilter(args.dashboard_id, {
|
|
101
|
+
name: args.name,
|
|
102
|
+
type: args.type,
|
|
103
|
+
field_id: args.field_id,
|
|
104
|
+
default_value: args.default_value
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
content: [
|
|
109
|
+
{
|
|
110
|
+
type: 'text',
|
|
111
|
+
text: `✅ **Filter Added!**\\n\\n` +
|
|
112
|
+
`• Dashboard ID: ${args.dashboard_id}\\n` +
|
|
113
|
+
`• Filter Name: ${args.name}\\n` +
|
|
114
|
+
`• Type: ${args.type}`,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Handle optimize dashboard layout request
|
|
122
|
+
* @param {object} args
|
|
123
|
+
* @param {object} context
|
|
124
|
+
* @returns {Promise<object>}
|
|
125
|
+
*/
|
|
126
|
+
export async function handleOptimizeDashboardLayout(args, context) {
|
|
127
|
+
const { metabaseClient } = context;
|
|
128
|
+
|
|
129
|
+
const dashboard = await metabaseClient.getDashboard(args.dashboard_id);
|
|
130
|
+
const cards = dashboard.ordered_cards || [];
|
|
131
|
+
|
|
132
|
+
const layoutStyle = args.layout_style || 'executive';
|
|
133
|
+
const gridWidth = args.grid_width || 12;
|
|
134
|
+
|
|
135
|
+
// Calculate optimized positions
|
|
136
|
+
const optimizedCards = cards.map((card, index) => {
|
|
137
|
+
const row = Math.floor(index / 2) * 4;
|
|
138
|
+
const col = (index % 2) * 6;
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
...card,
|
|
142
|
+
row,
|
|
143
|
+
col,
|
|
144
|
+
sizeX: 6,
|
|
145
|
+
sizeY: 4
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Update dashboard
|
|
150
|
+
await metabaseClient.updateDashboard(args.dashboard_id, {
|
|
151
|
+
ordered_cards: optimizedCards
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
content: [
|
|
156
|
+
{
|
|
157
|
+
type: 'text',
|
|
158
|
+
text: `✅ **Dashboard Layout Optimized!**\\n\\n` +
|
|
159
|
+
`• Dashboard ID: ${args.dashboard_id}\\n` +
|
|
160
|
+
`• Style: ${layoutStyle}\\n` +
|
|
161
|
+
`• Cards Reorganized: ${cards.length}`,
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Handle create executive dashboard request
|
|
169
|
+
* @param {object} args
|
|
170
|
+
* @param {object} context
|
|
171
|
+
* @returns {Promise<object>}
|
|
172
|
+
*/
|
|
173
|
+
export async function handleCreateExecutiveDashboard(args, context) {
|
|
174
|
+
const { metabaseClient } = context;
|
|
175
|
+
|
|
176
|
+
// Create the dashboard first
|
|
177
|
+
const dashboard = await metabaseClient.createDashboard({
|
|
178
|
+
name: args.name,
|
|
179
|
+
description: `Executive dashboard for ${args.business_domain || 'general'} metrics`,
|
|
180
|
+
collection_id: args.collection_id
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
content: [
|
|
185
|
+
{
|
|
186
|
+
type: 'text',
|
|
187
|
+
text: `✅ **Executive Dashboard Created!**\\n\\n` +
|
|
188
|
+
`• Dashboard ID: ${dashboard.id}\\n` +
|
|
189
|
+
`• Name: ${dashboard.name}\\n` +
|
|
190
|
+
`• Business Domain: ${args.business_domain || 'general'}\\n` +
|
|
191
|
+
`• Time Period: ${args.time_period || 'last_30_days'}\\n\\n` +
|
|
192
|
+
`📝 Next Steps:\\n` +
|
|
193
|
+
`• Add questions to this dashboard\\n` +
|
|
194
|
+
`• Configure filters for interactive analysis`,
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export default {
|
|
201
|
+
handleCreateDashboard,
|
|
202
|
+
handleGetDashboards,
|
|
203
|
+
handleAddCardToDashboard,
|
|
204
|
+
handleAddDashboardFilter,
|
|
205
|
+
handleOptimizeDashboardLayout,
|
|
206
|
+
handleCreateExecutiveDashboard,
|
|
207
|
+
};
|