@openmdm/core 0.2.0 → 0.3.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/dist/index.d.ts +105 -3
- package/dist/index.js +1553 -41
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +9 -0
- package/dist/schema.js +259 -0
- package/dist/schema.js.map +1 -1
- package/dist/types.d.ts +591 -1
- package/dist/types.js +21 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/audit.ts +317 -0
- package/src/authorization.ts +418 -0
- package/src/dashboard.ts +327 -0
- package/src/index.ts +222 -0
- package/src/plugin-storage.ts +128 -0
- package/src/queue.ts +161 -0
- package/src/schedule.ts +325 -0
- package/src/schema.ts +277 -0
- package/src/tenant.ts +237 -0
- package/src/types.ts +708 -0
package/src/dashboard.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenMDM Dashboard Manager
|
|
3
|
+
*
|
|
4
|
+
* Provides analytics and statistics for the MDM dashboard.
|
|
5
|
+
* Aggregates data from devices, commands, and applications.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
DashboardManager,
|
|
10
|
+
DashboardStats,
|
|
11
|
+
DeviceStatusBreakdown,
|
|
12
|
+
EnrollmentTrendPoint,
|
|
13
|
+
CommandSuccessRates,
|
|
14
|
+
AppInstallationSummary,
|
|
15
|
+
DatabaseAdapter,
|
|
16
|
+
DeviceStatus,
|
|
17
|
+
} from './types';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a DashboardManager instance
|
|
21
|
+
*/
|
|
22
|
+
export function createDashboardManager(db: DatabaseAdapter): DashboardManager {
|
|
23
|
+
return {
|
|
24
|
+
async getStats(_tenantId?: string): Promise<DashboardStats> {
|
|
25
|
+
// Use database method if available
|
|
26
|
+
if (db.getDashboardStats) {
|
|
27
|
+
return db.getDashboardStats(_tenantId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Fallback: compute from individual queries
|
|
31
|
+
const devices = await db.listDevices({
|
|
32
|
+
limit: 10000, // Get all for counting
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const deviceStats = {
|
|
36
|
+
total: devices.total,
|
|
37
|
+
enrolled: devices.devices.filter((d) => d.status === 'enrolled').length,
|
|
38
|
+
active: devices.devices.filter((d) => d.status === 'enrolled').length, // 'active' = 'enrolled' for dashboard
|
|
39
|
+
blocked: devices.devices.filter((d) => d.status === 'blocked').length,
|
|
40
|
+
pending: devices.devices.filter((d) => d.status === 'pending').length,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const allPolicies = await db.listPolicies();
|
|
44
|
+
const policyStats = {
|
|
45
|
+
total: allPolicies.length,
|
|
46
|
+
deployed: allPolicies.filter((p) => p.isDefault).length,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const allApps = await db.listApplications();
|
|
50
|
+
const appStats = {
|
|
51
|
+
total: allApps.length,
|
|
52
|
+
deployed: allApps.length, // All apps in db are considered deployed
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Command stats - get recent commands
|
|
56
|
+
const now = new Date();
|
|
57
|
+
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
58
|
+
const allCommands = await db.listCommands({ limit: 10000 });
|
|
59
|
+
|
|
60
|
+
const pendingCommands = allCommands.filter((c) => c.status === 'pending');
|
|
61
|
+
const last24hCommands = allCommands.filter(
|
|
62
|
+
(c) => new Date(c.createdAt) >= yesterday
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const commandStats = {
|
|
66
|
+
pendingCount: pendingCommands.length,
|
|
67
|
+
last24hTotal: last24hCommands.length,
|
|
68
|
+
last24hSuccess: last24hCommands.filter((c) => c.status === 'completed').length,
|
|
69
|
+
last24hFailed: last24hCommands.filter((c) => c.status === 'failed').length,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Group stats
|
|
73
|
+
const allGroups = await db.listGroups();
|
|
74
|
+
let groupsWithDevices = 0;
|
|
75
|
+
for (const group of allGroups) {
|
|
76
|
+
const groupDevices = await db.listDevicesInGroup(group.id);
|
|
77
|
+
if (groupDevices.length > 0) groupsWithDevices++;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
devices: deviceStats,
|
|
82
|
+
policies: policyStats,
|
|
83
|
+
applications: appStats,
|
|
84
|
+
commands: commandStats,
|
|
85
|
+
groups: {
|
|
86
|
+
total: allGroups.length,
|
|
87
|
+
withDevices: groupsWithDevices,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async getDeviceStatusBreakdown(_tenantId?: string): Promise<DeviceStatusBreakdown> {
|
|
93
|
+
if (db.getDeviceStatusBreakdown) {
|
|
94
|
+
return db.getDeviceStatusBreakdown(_tenantId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const devices = await db.listDevices({
|
|
98
|
+
limit: 10000,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const byStatus: Record<DeviceStatus, number> = {
|
|
102
|
+
pending: 0,
|
|
103
|
+
enrolled: 0,
|
|
104
|
+
blocked: 0,
|
|
105
|
+
unenrolled: 0,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const byOs: Record<string, number> = {};
|
|
109
|
+
const byManufacturer: Record<string, number> = {};
|
|
110
|
+
const byModel: Record<string, number> = {};
|
|
111
|
+
|
|
112
|
+
for (const device of devices.devices) {
|
|
113
|
+
// By status
|
|
114
|
+
byStatus[device.status]++;
|
|
115
|
+
|
|
116
|
+
// By OS version
|
|
117
|
+
const osKey = device.osVersion || 'Unknown';
|
|
118
|
+
byOs[osKey] = (byOs[osKey] || 0) + 1;
|
|
119
|
+
|
|
120
|
+
// By manufacturer
|
|
121
|
+
const mfr = device.manufacturer || 'Unknown';
|
|
122
|
+
byManufacturer[mfr] = (byManufacturer[mfr] || 0) + 1;
|
|
123
|
+
|
|
124
|
+
// By model
|
|
125
|
+
const model = device.model || 'Unknown';
|
|
126
|
+
byModel[model] = (byModel[model] || 0) + 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
byStatus,
|
|
131
|
+
byOs,
|
|
132
|
+
byManufacturer,
|
|
133
|
+
byModel,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
async getEnrollmentTrend(days: number, _tenantId?: string): Promise<EnrollmentTrendPoint[]> {
|
|
138
|
+
if (db.getEnrollmentTrend) {
|
|
139
|
+
return db.getEnrollmentTrend(days, _tenantId);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Generate trend data from event history
|
|
143
|
+
const now = new Date();
|
|
144
|
+
const startDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
|
145
|
+
|
|
146
|
+
// Get enrollment events
|
|
147
|
+
const events = await db.listEvents({
|
|
148
|
+
type: 'device.enrolled',
|
|
149
|
+
startDate,
|
|
150
|
+
limit: 10000,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const unenrollEvents = await db.listEvents({
|
|
154
|
+
type: 'device.unenrolled',
|
|
155
|
+
startDate,
|
|
156
|
+
limit: 10000,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Group by date
|
|
160
|
+
const trendByDate = new Map<string, { enrolled: number; unenrolled: number }>();
|
|
161
|
+
|
|
162
|
+
// Initialize all dates
|
|
163
|
+
for (let i = 0; i < days; i++) {
|
|
164
|
+
const date = new Date(startDate.getTime() + i * 24 * 60 * 60 * 1000);
|
|
165
|
+
const dateKey = date.toISOString().split('T')[0];
|
|
166
|
+
trendByDate.set(dateKey, { enrolled: 0, unenrolled: 0 });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Count events
|
|
170
|
+
for (const event of events) {
|
|
171
|
+
const dateKey = new Date(event.createdAt).toISOString().split('T')[0];
|
|
172
|
+
const entry = trendByDate.get(dateKey);
|
|
173
|
+
if (entry) {
|
|
174
|
+
entry.enrolled++;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const event of unenrollEvents) {
|
|
179
|
+
const dateKey = new Date(event.createdAt).toISOString().split('T')[0];
|
|
180
|
+
const entry = trendByDate.get(dateKey);
|
|
181
|
+
if (entry) {
|
|
182
|
+
entry.unenrolled++;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Get initial device count
|
|
187
|
+
const initialDevices = await db.listDevices({
|
|
188
|
+
limit: 10000,
|
|
189
|
+
});
|
|
190
|
+
let runningTotal = initialDevices.total;
|
|
191
|
+
|
|
192
|
+
// Build trend points
|
|
193
|
+
const result: EnrollmentTrendPoint[] = [];
|
|
194
|
+
const sortedDates = Array.from(trendByDate.keys()).sort();
|
|
195
|
+
|
|
196
|
+
for (const dateKey of sortedDates) {
|
|
197
|
+
const entry = trendByDate.get(dateKey)!;
|
|
198
|
+
const netChange = entry.enrolled - entry.unenrolled;
|
|
199
|
+
runningTotal += netChange;
|
|
200
|
+
|
|
201
|
+
result.push({
|
|
202
|
+
date: new Date(dateKey),
|
|
203
|
+
enrolled: entry.enrolled,
|
|
204
|
+
unenrolled: entry.unenrolled,
|
|
205
|
+
netChange,
|
|
206
|
+
totalDevices: runningTotal,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return result;
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
async getCommandSuccessRates(_tenantId?: string): Promise<CommandSuccessRates> {
|
|
214
|
+
if (db.getCommandSuccessRates) {
|
|
215
|
+
return db.getCommandSuccessRates(_tenantId);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const now = new Date();
|
|
219
|
+
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
220
|
+
|
|
221
|
+
const commands = await db.listCommands({ limit: 10000 });
|
|
222
|
+
|
|
223
|
+
// Overall stats
|
|
224
|
+
const completed = commands.filter((c) => c.status === 'completed').length;
|
|
225
|
+
const failed = commands.filter((c) => c.status === 'failed').length;
|
|
226
|
+
const total = commands.length;
|
|
227
|
+
|
|
228
|
+
// By type
|
|
229
|
+
const byType: CommandSuccessRates['byType'] = {};
|
|
230
|
+
for (const cmd of commands) {
|
|
231
|
+
if (!byType[cmd.type]) {
|
|
232
|
+
byType[cmd.type] = {
|
|
233
|
+
total: 0,
|
|
234
|
+
completed: 0,
|
|
235
|
+
failed: 0,
|
|
236
|
+
successRate: 0,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
byType[cmd.type].total++;
|
|
240
|
+
if (cmd.status === 'completed') byType[cmd.type].completed++;
|
|
241
|
+
if (cmd.status === 'failed') byType[cmd.type].failed++;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Calculate success rates
|
|
245
|
+
for (const type of Object.keys(byType)) {
|
|
246
|
+
const stats = byType[type];
|
|
247
|
+
const finishedCount = stats.completed + stats.failed;
|
|
248
|
+
stats.successRate = finishedCount > 0 ? (stats.completed / finishedCount) * 100 : 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Last 24h
|
|
252
|
+
const last24hCommands = commands.filter(
|
|
253
|
+
(c) => new Date(c.createdAt) >= yesterday
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
overall: {
|
|
258
|
+
total,
|
|
259
|
+
completed,
|
|
260
|
+
failed,
|
|
261
|
+
successRate:
|
|
262
|
+
completed + failed > 0 ? (completed / (completed + failed)) * 100 : 0,
|
|
263
|
+
},
|
|
264
|
+
byType,
|
|
265
|
+
last24h: {
|
|
266
|
+
total: last24hCommands.length,
|
|
267
|
+
completed: last24hCommands.filter((c) => c.status === 'completed').length,
|
|
268
|
+
failed: last24hCommands.filter((c) => c.status === 'failed').length,
|
|
269
|
+
pending: last24hCommands.filter((c) => c.status === 'pending').length,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
async getAppInstallationSummary(_tenantId?: string): Promise<AppInstallationSummary> {
|
|
275
|
+
if (db.getAppInstallationSummary) {
|
|
276
|
+
return db.getAppInstallationSummary(_tenantId);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Get all apps
|
|
280
|
+
const apps = await db.listApplications();
|
|
281
|
+
const appMap = new Map(apps.map((a) => [a.packageName, a]));
|
|
282
|
+
|
|
283
|
+
// Get installation statuses if available
|
|
284
|
+
const byStatus: Record<string, number> = {
|
|
285
|
+
installed: 0,
|
|
286
|
+
installing: 0,
|
|
287
|
+
failed: 0,
|
|
288
|
+
pending: 0,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// Count installed apps per device
|
|
292
|
+
const installCounts: Record<string, number> = {};
|
|
293
|
+
|
|
294
|
+
// Get devices to count installations
|
|
295
|
+
const devices = await db.listDevices({
|
|
296
|
+
limit: 10000,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
for (const device of devices.devices) {
|
|
300
|
+
if (device.installedApps) {
|
|
301
|
+
for (const app of device.installedApps) {
|
|
302
|
+
const key = app.packageName;
|
|
303
|
+
installCounts[key] = (installCounts[key] || 0) + 1;
|
|
304
|
+
byStatus['installed']++;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Top installed apps
|
|
310
|
+
const topInstalled = Object.entries(installCounts)
|
|
311
|
+
.sort(([, a], [, b]) => b - a)
|
|
312
|
+
.slice(0, 10)
|
|
313
|
+
.map(([packageName, count]) => ({
|
|
314
|
+
packageName,
|
|
315
|
+
name: appMap.get(packageName)?.name || packageName,
|
|
316
|
+
installedCount: count,
|
|
317
|
+
}));
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
total: Object.values(byStatus).reduce((a, b) => a + b, 0),
|
|
321
|
+
byStatus,
|
|
322
|
+
recentFailures: [], // Would need installation status tracking
|
|
323
|
+
topInstalled,
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -64,6 +64,15 @@ import type {
|
|
|
64
64
|
CommandResult,
|
|
65
65
|
InstalledApp,
|
|
66
66
|
WebhookManager,
|
|
67
|
+
TenantManager,
|
|
68
|
+
AuthorizationManager,
|
|
69
|
+
AuditManager,
|
|
70
|
+
ScheduleManager,
|
|
71
|
+
MessageQueueManager,
|
|
72
|
+
DashboardManager,
|
|
73
|
+
PluginStorageAdapter,
|
|
74
|
+
GroupTreeNode,
|
|
75
|
+
GroupHierarchyStats,
|
|
67
76
|
} from './types';
|
|
68
77
|
import {
|
|
69
78
|
DeviceNotFoundError,
|
|
@@ -71,6 +80,13 @@ import {
|
|
|
71
80
|
EnrollmentError,
|
|
72
81
|
} from './types';
|
|
73
82
|
import { createWebhookManager } from './webhooks';
|
|
83
|
+
import { createTenantManager } from './tenant';
|
|
84
|
+
import { createAuthorizationManager } from './authorization';
|
|
85
|
+
import { createAuditManager } from './audit';
|
|
86
|
+
import { createScheduleManager } from './schedule';
|
|
87
|
+
import { createMessageQueueManager } from './queue';
|
|
88
|
+
import { createDashboardManager } from './dashboard';
|
|
89
|
+
import { createPluginStorageAdapter, createMemoryPluginStorageAdapter } from './plugin-storage';
|
|
74
90
|
|
|
75
91
|
// Re-export all types
|
|
76
92
|
export * from './types';
|
|
@@ -78,6 +94,15 @@ export * from './schema';
|
|
|
78
94
|
export { createWebhookManager, verifyWebhookSignature } from './webhooks';
|
|
79
95
|
export type { WebhookPayload } from './webhooks';
|
|
80
96
|
|
|
97
|
+
// Re-export enterprise manager factories
|
|
98
|
+
export { createTenantManager } from './tenant';
|
|
99
|
+
export { createAuthorizationManager } from './authorization';
|
|
100
|
+
export { createAuditManager } from './audit';
|
|
101
|
+
export { createScheduleManager } from './schedule';
|
|
102
|
+
export { createMessageQueueManager } from './queue';
|
|
103
|
+
export { createDashboardManager } from './dashboard';
|
|
104
|
+
export { createPluginStorageAdapter, createMemoryPluginStorageAdapter, createPluginKey, parsePluginKey } from './plugin-storage';
|
|
105
|
+
|
|
81
106
|
/**
|
|
82
107
|
* Create an MDM instance with the given configuration.
|
|
83
108
|
*/
|
|
@@ -97,6 +122,46 @@ export function createMDM(config: MDMConfig): MDMInstance {
|
|
|
97
122
|
? createWebhookManager(webhooksConfig)
|
|
98
123
|
: undefined;
|
|
99
124
|
|
|
125
|
+
// ============================================
|
|
126
|
+
// Enterprise Managers (optional)
|
|
127
|
+
// ============================================
|
|
128
|
+
|
|
129
|
+
// Create tenant manager if multi-tenancy is enabled
|
|
130
|
+
const tenantManager: TenantManager | undefined = config.multiTenancy?.enabled
|
|
131
|
+
? createTenantManager(database)
|
|
132
|
+
: undefined;
|
|
133
|
+
|
|
134
|
+
// Create authorization manager if authorization is enabled
|
|
135
|
+
const authorizationManager: AuthorizationManager | undefined = config.authorization?.enabled
|
|
136
|
+
? createAuthorizationManager(database)
|
|
137
|
+
: undefined;
|
|
138
|
+
|
|
139
|
+
// Create audit manager if audit logging is enabled
|
|
140
|
+
const auditManager: AuditManager | undefined = config.audit?.enabled
|
|
141
|
+
? createAuditManager(database)
|
|
142
|
+
: undefined;
|
|
143
|
+
|
|
144
|
+
// Create schedule manager if scheduling is enabled
|
|
145
|
+
const scheduleManager: ScheduleManager | undefined = config.scheduling?.enabled
|
|
146
|
+
? createScheduleManager(database)
|
|
147
|
+
: undefined;
|
|
148
|
+
|
|
149
|
+
// Create message queue manager if the database supports it
|
|
150
|
+
const messageQueueManager: MessageQueueManager | undefined = database.enqueueMessage
|
|
151
|
+
? createMessageQueueManager(database)
|
|
152
|
+
: undefined;
|
|
153
|
+
|
|
154
|
+
// Create dashboard manager (always available, uses database fallbacks)
|
|
155
|
+
const dashboardManager: DashboardManager = createDashboardManager(database);
|
|
156
|
+
|
|
157
|
+
// Create plugin storage adapter
|
|
158
|
+
const pluginStorageAdapter: PluginStorageAdapter | undefined =
|
|
159
|
+
config.pluginStorage?.adapter === 'database'
|
|
160
|
+
? createPluginStorageAdapter(database)
|
|
161
|
+
: config.pluginStorage?.adapter === 'memory'
|
|
162
|
+
? createMemoryPluginStorageAdapter()
|
|
163
|
+
: undefined;
|
|
164
|
+
|
|
100
165
|
// Event subscription
|
|
101
166
|
const on = <T extends EventType>(
|
|
102
167
|
event: T,
|
|
@@ -656,6 +721,155 @@ export function createMDM(config: MDMConfig): MDMInstance {
|
|
|
656
721
|
const allGroups = await database.listGroups();
|
|
657
722
|
return allGroups.filter((g) => g.parentId === groupId);
|
|
658
723
|
},
|
|
724
|
+
|
|
725
|
+
async getTree(rootId?: string): Promise<GroupTreeNode[]> {
|
|
726
|
+
// Use database implementation if available
|
|
727
|
+
if (database.getGroupTree) {
|
|
728
|
+
return database.getGroupTree(rootId);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Fallback: Build tree from flat list
|
|
732
|
+
const allGroups = await database.listGroups();
|
|
733
|
+
const groupMap = new Map(allGroups.map((g) => [g.id, g]));
|
|
734
|
+
|
|
735
|
+
const buildNode = (group: Group, depth: number, path: string[]): GroupTreeNode => {
|
|
736
|
+
const children = allGroups
|
|
737
|
+
.filter((g) => g.parentId === group.id)
|
|
738
|
+
.map((child) => buildNode(child, depth + 1, [...path, group.id]));
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
...group,
|
|
742
|
+
children,
|
|
743
|
+
depth,
|
|
744
|
+
path,
|
|
745
|
+
effectivePolicyId: group.policyId,
|
|
746
|
+
};
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
// Find root groups (those with no parent or matching rootId)
|
|
750
|
+
const roots = allGroups.filter((g) =>
|
|
751
|
+
rootId ? g.id === rootId : !g.parentId
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
return roots.map((root) => buildNode(root, 0, []));
|
|
755
|
+
},
|
|
756
|
+
|
|
757
|
+
async getAncestors(groupId: string): Promise<Group[]> {
|
|
758
|
+
// Use database implementation if available
|
|
759
|
+
if (database.getGroupAncestors) {
|
|
760
|
+
return database.getGroupAncestors(groupId);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Fallback: Traverse up the tree
|
|
764
|
+
const ancestors: Group[] = [];
|
|
765
|
+
const allGroups = await database.listGroups();
|
|
766
|
+
const groupMap = new Map(allGroups.map((g) => [g.id, g]));
|
|
767
|
+
|
|
768
|
+
let current = groupMap.get(groupId);
|
|
769
|
+
while (current?.parentId) {
|
|
770
|
+
const parent = groupMap.get(current.parentId);
|
|
771
|
+
if (parent) {
|
|
772
|
+
ancestors.push(parent);
|
|
773
|
+
current = parent;
|
|
774
|
+
} else {
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return ancestors;
|
|
780
|
+
},
|
|
781
|
+
|
|
782
|
+
async getDescendants(groupId: string): Promise<Group[]> {
|
|
783
|
+
// Use database implementation if available
|
|
784
|
+
if (database.getGroupDescendants) {
|
|
785
|
+
return database.getGroupDescendants(groupId);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Fallback: Find all descendants recursively
|
|
789
|
+
const allGroups = await database.listGroups();
|
|
790
|
+
const descendants: Group[] = [];
|
|
791
|
+
|
|
792
|
+
const findDescendants = (parentId: string) => {
|
|
793
|
+
const children = allGroups.filter((g) => g.parentId === parentId);
|
|
794
|
+
for (const child of children) {
|
|
795
|
+
descendants.push(child);
|
|
796
|
+
findDescendants(child.id);
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
findDescendants(groupId);
|
|
801
|
+
return descendants;
|
|
802
|
+
},
|
|
803
|
+
|
|
804
|
+
async move(groupId: string, newParentId: string | null): Promise<Group> {
|
|
805
|
+
// Validate that we're not creating a cycle
|
|
806
|
+
if (newParentId) {
|
|
807
|
+
const ancestors = await this.getAncestors(newParentId);
|
|
808
|
+
if (ancestors.some((a) => a.id === groupId)) {
|
|
809
|
+
throw new Error('Cannot move group: would create circular reference');
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return database.updateGroup(groupId, { parentId: newParentId });
|
|
814
|
+
},
|
|
815
|
+
|
|
816
|
+
async getEffectivePolicy(groupId: string): Promise<Policy | null> {
|
|
817
|
+
// Use database implementation if available
|
|
818
|
+
if (database.getGroupEffectivePolicy) {
|
|
819
|
+
return database.getGroupEffectivePolicy(groupId);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Fallback: Walk up the tree to find first policy
|
|
823
|
+
const group = await database.findGroup(groupId);
|
|
824
|
+
if (!group) return null;
|
|
825
|
+
|
|
826
|
+
if (group.policyId) {
|
|
827
|
+
return database.findPolicy(group.policyId);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Check ancestors
|
|
831
|
+
const ancestors = await this.getAncestors(groupId);
|
|
832
|
+
for (const ancestor of ancestors) {
|
|
833
|
+
if (ancestor.policyId) {
|
|
834
|
+
return database.findPolicy(ancestor.policyId);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return null;
|
|
839
|
+
},
|
|
840
|
+
|
|
841
|
+
async getHierarchyStats(): Promise<GroupHierarchyStats> {
|
|
842
|
+
// Use database implementation if available
|
|
843
|
+
if (database.getGroupHierarchyStats) {
|
|
844
|
+
return database.getGroupHierarchyStats();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Fallback: Compute from flat list
|
|
848
|
+
const allGroups = await database.listGroups();
|
|
849
|
+
let maxDepth = 0;
|
|
850
|
+
let groupsWithDevices = 0;
|
|
851
|
+
let groupsWithPolicies = 0;
|
|
852
|
+
|
|
853
|
+
for (const group of allGroups) {
|
|
854
|
+
// Calculate depth
|
|
855
|
+
const ancestors = await this.getAncestors(group.id);
|
|
856
|
+
maxDepth = Math.max(maxDepth, ancestors.length);
|
|
857
|
+
|
|
858
|
+
// Check for devices
|
|
859
|
+
const devices = await database.listDevicesInGroup(group.id);
|
|
860
|
+
if (devices.length > 0) groupsWithDevices++;
|
|
861
|
+
|
|
862
|
+
// Check for policies
|
|
863
|
+
if (group.policyId) groupsWithPolicies++;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return {
|
|
867
|
+
totalGroups: allGroups.length,
|
|
868
|
+
maxDepth,
|
|
869
|
+
groupsWithDevices,
|
|
870
|
+
groupsWithPolicies,
|
|
871
|
+
};
|
|
872
|
+
},
|
|
659
873
|
};
|
|
660
874
|
|
|
661
875
|
// ============================================
|
|
@@ -969,6 +1183,14 @@ export function createMDM(config: MDMConfig): MDMInstance {
|
|
|
969
1183
|
verifyDeviceToken,
|
|
970
1184
|
getPlugins,
|
|
971
1185
|
getPlugin,
|
|
1186
|
+
// Enterprise managers (optional)
|
|
1187
|
+
tenants: tenantManager,
|
|
1188
|
+
authorization: authorizationManager,
|
|
1189
|
+
audit: auditManager,
|
|
1190
|
+
schedules: scheduleManager,
|
|
1191
|
+
messageQueue: messageQueueManager,
|
|
1192
|
+
dashboard: dashboardManager,
|
|
1193
|
+
pluginStorage: pluginStorageAdapter,
|
|
972
1194
|
};
|
|
973
1195
|
|
|
974
1196
|
// Initialize plugins
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenMDM Plugin Storage Manager
|
|
3
|
+
*
|
|
4
|
+
* Provides persistent storage for plugin state.
|
|
5
|
+
* Supports both database-backed and in-memory storage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PluginStorageAdapter, DatabaseAdapter } from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a PluginStorageAdapter backed by the database
|
|
12
|
+
*/
|
|
13
|
+
export function createPluginStorageAdapter(db: DatabaseAdapter): PluginStorageAdapter {
|
|
14
|
+
return {
|
|
15
|
+
async get<T>(pluginName: string, key: string): Promise<T | null> {
|
|
16
|
+
if (db.getPluginValue) {
|
|
17
|
+
const value = await db.getPluginValue(pluginName, key);
|
|
18
|
+
return value as T | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Fallback: not supported
|
|
22
|
+
console.warn('Plugin storage not supported by database adapter');
|
|
23
|
+
return null;
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async set<T>(pluginName: string, key: string, value: T): Promise<void> {
|
|
27
|
+
if (db.setPluginValue) {
|
|
28
|
+
await db.setPluginValue(pluginName, key, value);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.warn('Plugin storage not supported by database adapter');
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async delete(pluginName: string, key: string): Promise<void> {
|
|
36
|
+
if (db.deletePluginValue) {
|
|
37
|
+
await db.deletePluginValue(pluginName, key);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.warn('Plugin storage not supported by database adapter');
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async list(pluginName: string, prefix?: string): Promise<string[]> {
|
|
45
|
+
if (db.listPluginKeys) {
|
|
46
|
+
return db.listPluginKeys(pluginName, prefix);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.warn('Plugin storage not supported by database adapter');
|
|
50
|
+
return [];
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
async clear(pluginName: string): Promise<void> {
|
|
54
|
+
if (db.clearPluginData) {
|
|
55
|
+
await db.clearPluginData(pluginName);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.warn('Plugin storage not supported by database adapter');
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create an in-memory PluginStorageAdapter for testing
|
|
66
|
+
*/
|
|
67
|
+
export function createMemoryPluginStorageAdapter(): PluginStorageAdapter {
|
|
68
|
+
const store = new Map<string, Map<string, unknown>>();
|
|
69
|
+
|
|
70
|
+
function getPluginStore(pluginName: string): Map<string, unknown> {
|
|
71
|
+
if (!store.has(pluginName)) {
|
|
72
|
+
store.set(pluginName, new Map());
|
|
73
|
+
}
|
|
74
|
+
return store.get(pluginName)!;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
async get<T>(pluginName: string, key: string): Promise<T | null> {
|
|
79
|
+
const pluginStore = getPluginStore(pluginName);
|
|
80
|
+
const value = pluginStore.get(key);
|
|
81
|
+
return value === undefined ? null : (value as T);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async set<T>(pluginName: string, key: string, value: T): Promise<void> {
|
|
85
|
+
const pluginStore = getPluginStore(pluginName);
|
|
86
|
+
pluginStore.set(key, value);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async delete(pluginName: string, key: string): Promise<void> {
|
|
90
|
+
const pluginStore = getPluginStore(pluginName);
|
|
91
|
+
pluginStore.delete(key);
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
async list(pluginName: string, prefix?: string): Promise<string[]> {
|
|
95
|
+
const pluginStore = getPluginStore(pluginName);
|
|
96
|
+
const keys = Array.from(pluginStore.keys());
|
|
97
|
+
|
|
98
|
+
if (prefix) {
|
|
99
|
+
return keys.filter((k) => k.startsWith(prefix));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return keys;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async clear(pluginName: string): Promise<void> {
|
|
106
|
+
store.delete(pluginName);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Plugin storage utilities
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create a namespaced key for plugin storage
|
|
117
|
+
*/
|
|
118
|
+
export function createPluginKey(namespace: string, ...parts: string[]): string {
|
|
119
|
+
return [namespace, ...parts].join(':');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Parse a namespaced key
|
|
124
|
+
*/
|
|
125
|
+
export function parsePluginKey(key: string): { namespace: string; parts: string[] } {
|
|
126
|
+
const [namespace, ...parts] = key.split(':');
|
|
127
|
+
return { namespace, parts };
|
|
128
|
+
}
|