@fleetbase/ember-core 0.3.10 → 0.3.12

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.
@@ -19,6 +19,7 @@ export const hostServices = [
19
19
  'sidebar',
20
20
  'dashboard',
21
21
  'universe',
22
+ 'events',
22
23
  'intl',
23
24
  'abilities',
24
25
  'language',
@@ -21,6 +21,7 @@ export const services = [
21
21
  'sidebar',
22
22
  'dashboard',
23
23
  'universe',
24
+ 'events',
24
25
  'intl',
25
26
  'abilities',
26
27
  'language',
@@ -13,7 +13,8 @@ export default class AppCacheService extends Service {
13
13
 
14
14
  get cachePrefix() {
15
15
  const userId = this.currentUser.id ?? 'anon';
16
- return `${userId}:${this.currentUser.companyId}:`;
16
+ const companyId = this.currentUser.companyId ?? 'no-org';
17
+ return `${userId}:${companyId}:`;
17
18
  }
18
19
 
19
20
  @action setEmberData(key, value, except = []) {
@@ -17,6 +17,8 @@ export default class CrudService extends Service {
17
17
  @service notifications;
18
18
  @service store;
19
19
  @service currentUser;
20
+ @service universe;
21
+ @service events;
20
22
 
21
23
  /**
22
24
  * Generic deletion modal with options
@@ -43,6 +45,10 @@ export default class CrudService extends Service {
43
45
  try {
44
46
  const response = await model.destroyRecord();
45
47
  this.notifications.success(successNotification);
48
+
49
+ // Track deletion event
50
+ this.events.trackResourceDeleted(model);
51
+
46
52
  if (typeof options.onSuccess === 'function') {
47
53
  options.onSuccess(model);
48
54
  }
@@ -161,6 +167,10 @@ export default class CrudService extends Service {
161
167
  );
162
168
 
163
169
  this.notifications.success(response.message ?? successMessage);
170
+
171
+ // Track bulk action event
172
+ this.events.trackBulkAction(verb, selected);
173
+
164
174
  if (typeof options.onSuccess === 'function') {
165
175
  options.onSuccess(selected);
166
176
  }
@@ -224,6 +234,9 @@ export default class CrudService extends Service {
224
234
  }
225
235
  )
226
236
  .then(() => {
237
+ // Track export event
238
+ this.events.trackResourceExported(modelName, format, exportParams);
239
+
227
240
  later(
228
241
  this,
229
242
  () => {
@@ -248,6 +261,7 @@ export default class CrudService extends Service {
248
261
  * @param {Object} [options={}]
249
262
  * @memberof CrudService
250
263
  */
264
+
251
265
  @action import(modelName, options = {}) {
252
266
  // always lowercase modelname
253
267
  modelName = modelName.toLowerCase();
@@ -337,6 +351,11 @@ export default class CrudService extends Service {
337
351
 
338
352
  try {
339
353
  const response = await this.fetch.post(importEndpoint, { files }, fetchOptions);
354
+
355
+ // Track import event
356
+ const importCount = response?.imported?.length || response?.count || files.length;
357
+ this.events.trackResourceImported(modelName, importCount);
358
+
340
359
  if (typeof options.onImportCompleted === 'function') {
341
360
  options.onImportCompleted(response, files);
342
361
  }
@@ -8,6 +8,7 @@ import { isBlank } from '@ember/utils';
8
8
  import { alias } from '@ember/object/computed';
9
9
  import { storageFor } from 'ember-local-storage';
10
10
  import { debug } from '@ember/debug';
11
+ import lookupUserIp from '../utils/lookup-user-ip';
11
12
 
12
13
  export default class CurrentUserService extends Service.extend(Evented) {
13
14
  @service session;
@@ -16,6 +17,7 @@ export default class CurrentUserService extends Service.extend(Evented) {
16
17
  @service theme;
17
18
  @service notifications;
18
19
  @service intl;
20
+ @service events;
19
21
 
20
22
  @tracked user = { id: 'anon' };
21
23
  @tracked userSnapshot = { id: 'anon' };
@@ -65,14 +67,9 @@ export default class CurrentUserService extends Service.extend(Evented) {
65
67
  async load() {
66
68
  if (this.session.isAuthenticated) {
67
69
  const user = await this.store.findRecord('user', 'me');
68
- const snapshot = await this.getUserSnapshot(user);
69
70
 
70
- this.set('user', user);
71
- this.set('userSnapshot', snapshot);
72
- this.trigger('user.loaded', user);
73
-
74
- // Set permissions
75
- this.permissions = this.getUserPermissions(user);
71
+ // set user
72
+ this.setUser(user);
76
73
 
77
74
  // Load preferences
78
75
  await this.loadPreferences();
@@ -91,25 +88,9 @@ export default class CurrentUserService extends Service.extend(Evented) {
91
88
 
92
89
  try {
93
90
  const user = await this.store.queryRecord('user', { me: true });
94
- const snapshot = await this.getUserSnapshot(user);
95
-
96
- // Set current user
97
- this.set('user', user);
98
- this.set('userSnapshot', snapshot);
99
- this.trigger('user.loaded', user);
100
-
101
- // Set permissions
102
- this.permissions = this.getUserPermissions(user);
103
91
 
104
- // Set environment from user option
105
- this.theme.setEnvironment();
106
-
107
- // Set locale
108
- if (user.locale) {
109
- this.setLocale(user.locale);
110
- } else {
111
- await this.loadLocale();
112
- }
92
+ // set user
93
+ this.setUser(user);
113
94
 
114
95
  // Load user whois data
115
96
  await this.loadWhois();
@@ -159,23 +140,34 @@ export default class CurrentUserService extends Service.extend(Evented) {
159
140
  }
160
141
 
161
142
  async loadWhois() {
162
- this.fetch.shouldResetCache();
163
-
164
143
  try {
165
- const whois = await this.fetch.cachedGet(
166
- 'lookup/whois',
167
- {},
168
- {
169
- expirationInterval: 60,
170
- expirationIntervalUnit: 'minutes',
171
- }
172
- );
144
+ // Use frontend IP lookup to get accurate user location
145
+ // This avoids the issue of server-side lookup returning server IP instead of user IP
146
+ const whois = await lookupUserIp({
147
+ timeout: 5000,
148
+ cache: true,
149
+ });
150
+
173
151
  this.setOption('whois', whois);
174
152
  this.whoisData = whois;
175
153
 
176
154
  return whois;
177
155
  } catch (error) {
178
- this.notifications.serverError(error);
156
+ console.error('[currentUser] Failed to load whois:', error);
157
+ this.notifications.warning('Unable to detect your location. Some features may use default settings.');
158
+
159
+ // Return fallback data with browser timezone
160
+ const fallback = {
161
+ city: null,
162
+ country_code: null,
163
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
164
+ _source: 'fallback',
165
+ };
166
+
167
+ this.setOption('whois', fallback);
168
+ this.whoisData = fallback;
169
+
170
+ return fallback;
179
171
  }
180
172
  }
181
173
 
@@ -309,4 +301,26 @@ export default class CurrentUserService extends Service.extend(Evented) {
309
301
 
310
302
  return defaultValue;
311
303
  }
304
+
305
+ async setUser(user) {
306
+ const snapshot = await this.getUserSnapshot(user);
307
+
308
+ // Set current user
309
+ this.set('user', user);
310
+ this.set('userSnapshot', snapshot);
311
+ this.trigger('user.loaded', user);
312
+
313
+ // Set permissions
314
+ this.permissions = this.getUserPermissions(user);
315
+
316
+ // Set environment from user option
317
+ this.theme.setEnvironment();
318
+
319
+ // Set locale
320
+ if (user.locale) {
321
+ this.setLocale(user.locale);
322
+ } else {
323
+ await this.loadLocale();
324
+ }
325
+ }
312
326
  }
@@ -0,0 +1,322 @@
1
+ import Service, { inject as service } from '@ember/service';
2
+ import Evented from '@ember/object/evented';
3
+ import config from 'ember-get-config';
4
+
5
+ /**
6
+ * Events Service
7
+ *
8
+ * Provides a centralized event tracking system for Fleetbase.
9
+ * This service emits standardized events on both its own event bus and the universe service,
10
+ * allowing components, services, and engines to subscribe and react to application events.
11
+ *
12
+ * @class EventsService
13
+ * @extends Service
14
+ */
15
+ export default class EventsService extends Service.extend(Evented) {
16
+ @service universe;
17
+ @service currentUser;
18
+
19
+ /**
20
+ * Tracks the creation of a resource
21
+ *
22
+ * @param {Object} resource - The created resource/model
23
+ * @param {Object} [props={}] - Additional properties to include
24
+ */
25
+ trackResourceCreated(resource, props = {}) {
26
+ const events = this.#getResourceEvents(resource, 'created');
27
+ const properties = this.#enrichProperties({
28
+ ...this.#getSafeProperties(resource),
29
+ ...props,
30
+ });
31
+
32
+ events.forEach((eventName) => {
33
+ this.#trigger(eventName, resource, properties);
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Tracks the update of a resource
39
+ *
40
+ * @param {Object} resource - The updated resource/model
41
+ * @param {Object} [props={}] - Additional properties to include
42
+ */
43
+ trackResourceUpdated(resource, props = {}) {
44
+ const events = this.#getResourceEvents(resource, 'updated');
45
+ const properties = this.#enrichProperties({
46
+ ...this.#getSafeProperties(resource),
47
+ ...props,
48
+ });
49
+
50
+ events.forEach((eventName) => {
51
+ this.#trigger(eventName, resource, properties);
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Tracks the deletion of a resource
57
+ *
58
+ * @param {Object} resource - The deleted resource/model
59
+ * @param {Object} [props={}] - Additional properties to include
60
+ */
61
+ trackResourceDeleted(resource, props = {}) {
62
+ const events = this.#getResourceEvents(resource, 'deleted');
63
+ const properties = this.#enrichProperties({
64
+ ...this.#getSafeProperties(resource),
65
+ ...props,
66
+ });
67
+
68
+ events.forEach((eventName) => {
69
+ this.#trigger(eventName, resource, properties);
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Tracks a bulk import of resources
75
+ *
76
+ * @param {String} modelName - The name of the model being imported
77
+ * @param {Number} count - Number of resources imported
78
+ * @param {Object} [props={}] - Additional properties to include
79
+ */
80
+ trackResourceImported(modelName, count, props = {}) {
81
+ const properties = this.#enrichProperties({
82
+ model_name: modelName,
83
+ count: count,
84
+ ...props,
85
+ });
86
+
87
+ this.#trigger('resource.imported', modelName, count, properties);
88
+ }
89
+
90
+ /**
91
+ * Tracks a resource export
92
+ *
93
+ * @param {String} modelName - The name of the model being exported
94
+ * @param {String} format - Export format (csv, xlsx, etc.)
95
+ * @param {Object} [params={}] - Export parameters/filters
96
+ * @param {Object} [props={}] - Additional properties to include
97
+ */
98
+ trackResourceExported(modelName, format, params = {}, props = {}) {
99
+ const properties = this.#enrichProperties({
100
+ model_name: modelName,
101
+ export_format: format,
102
+ has_filters: !!(params && Object.keys(params).length > 0),
103
+ ...props,
104
+ });
105
+
106
+ this.#trigger('resource.exported', modelName, format, params, properties);
107
+
108
+ // Also trigger model-specific export event
109
+ const specificEvent = `${modelName}.exported`;
110
+ this.#trigger(specificEvent, modelName, format, params, properties);
111
+ }
112
+
113
+ /**
114
+ * Tracks a bulk action on multiple resources
115
+ *
116
+ * @param {String} verb - The action verb (delete, archive, etc.)
117
+ * @param {Array} resources - Array of selected resources
118
+ * @param {Object} [props={}] - Additional properties to include
119
+ */
120
+ trackBulkAction(verb, resources, props = {}) {
121
+ const firstResource = resources && resources.length > 0 ? resources[0] : null;
122
+ const modelName = this.#getModelName(firstResource);
123
+
124
+ const properties = this.#enrichProperties({
125
+ action: verb,
126
+ count: resources?.length || 0,
127
+ model_name: modelName,
128
+ ...props,
129
+ });
130
+
131
+ this.#trigger('resource.bulk_action', verb, resources, firstResource, properties);
132
+ }
133
+
134
+ /**
135
+ * Tracks when the current user is loaded (session initialized)
136
+ *
137
+ * @param {Object} user - The user object
138
+ * @param {Object} organization - The organization object
139
+ * @param {Object} [props={}] - Additional properties to include
140
+ */
141
+ trackUserLoaded(user, organization, props = {}) {
142
+ const properties = this.#enrichProperties({
143
+ user_id: user?.id,
144
+ organization_id: organization?.id,
145
+ organization_name: organization?.name,
146
+ ...props,
147
+ });
148
+
149
+ this.#trigger('user.loaded', user, organization, properties);
150
+ }
151
+
152
+ /**
153
+ * Tracks when a user session is terminated
154
+ *
155
+ * @param {Number} duration - Session duration in seconds
156
+ * @param {Object} [props={}] - Additional properties to include
157
+ */
158
+ trackSessionTerminated(duration, props = {}) {
159
+ const properties = this.#enrichProperties({
160
+ session_duration: duration,
161
+ ...props,
162
+ });
163
+
164
+ this.#trigger('session.terminated', duration, properties);
165
+ }
166
+
167
+ /**
168
+ * Tracks a generic custom event
169
+ *
170
+ * @param {String} eventName - The event name (dot notation)
171
+ * @param {Object} [props={}] - Event properties
172
+ */
173
+ trackEvent(eventName, props = {}) {
174
+ const properties = this.#enrichProperties(props);
175
+ this.#trigger(eventName, properties);
176
+ }
177
+
178
+ /**
179
+ * Checks if event tracking is enabled
180
+ *
181
+ * @returns {Boolean}
182
+ */
183
+ isEnabled() {
184
+ const eventsConfig = config?.events || {};
185
+ return eventsConfig.enabled !== false; // Enabled by default
186
+ }
187
+
188
+ // =========================================================================
189
+ // Private Methods (using # syntax)
190
+ // =========================================================================
191
+
192
+ /**
193
+ * Triggers an event on both the events service and universe service
194
+ *
195
+ * This dual event system allows listeners to subscribe to events on either:
196
+ * - this.events.on('event.name', handler) - Local listeners
197
+ * - this.universe.on('event.name', handler) - Cross-engine listeners
198
+ *
199
+ * @private
200
+ * @param {String} eventName - The event name
201
+ * @param {...*} args - Arguments to pass to event listeners
202
+ */
203
+ #trigger(eventName, ...args) {
204
+ if (!this.isEnabled()) {
205
+ return;
206
+ }
207
+
208
+ // Debug logging if enabled
209
+ if (config?.events?.debug) {
210
+ console.log(`[Events] ${eventName}`, args);
211
+ }
212
+
213
+ // Trigger on events service (local listeners)
214
+ this.trigger(eventName, ...args);
215
+
216
+ // Trigger on universe service (cross-engine listeners)
217
+ if (this.universe) {
218
+ this.universe.trigger(eventName, ...args);
219
+ } else {
220
+ console.warn('[Events] Universe service not available');
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Generates both generic and specific event names for a resource action
226
+ *
227
+ * @private
228
+ * @param {Object} resource - The resource/model
229
+ * @param {String} action - The action (created, updated, deleted)
230
+ * @returns {Array<String>} Array of event names
231
+ */
232
+ #getResourceEvents(resource, action) {
233
+ const modelName = this.#getModelName(resource);
234
+ return [`resource.${action}`, `${modelName}.${action}`];
235
+ }
236
+
237
+ /**
238
+ * Extracts safe, serializable properties from a resource
239
+ *
240
+ * @private
241
+ * @param {Object} resource - The resource/model
242
+ * @returns {Object} Safe properties object
243
+ */
244
+ #getSafeProperties(resource) {
245
+ if (!resource) {
246
+ return {};
247
+ }
248
+
249
+ const props = {
250
+ id: resource.id,
251
+ model_name: this.#getModelName(resource),
252
+ };
253
+
254
+ // Add common properties if available
255
+ const commonProps = ['name', 'status', 'type', 'slug', 'public_id'];
256
+ commonProps.forEach((prop) => {
257
+ if (resource[prop] !== undefined && resource[prop] !== null) {
258
+ props[prop] = resource[prop];
259
+ }
260
+ });
261
+
262
+ return props;
263
+ }
264
+
265
+ /**
266
+ * Enriches properties with user, organization, and timestamp context
267
+ *
268
+ * @private
269
+ * @param {Object} props - Base properties
270
+ * @returns {Object} Enriched properties
271
+ */
272
+ #enrichProperties(props = {}) {
273
+ const eventsConfig = config?.events || {};
274
+ const enrichConfig = eventsConfig.enrich || {};
275
+ const enriched = { ...props };
276
+
277
+ // Add user context if enabled
278
+ if (enrichConfig.user !== false && this.currentUser?.user) {
279
+ enriched.user_id = this.currentUser.user.id;
280
+ }
281
+
282
+ // Add organization context if enabled
283
+ if (enrichConfig.organization !== false && this.currentUser?.organization) {
284
+ enriched.organization_id = this.currentUser.organization.id;
285
+ }
286
+
287
+ // Add timestamp if enabled
288
+ if (enrichConfig.timestamp !== false) {
289
+ enriched.timestamp = new Date().toISOString();
290
+ }
291
+
292
+ return enriched;
293
+ }
294
+
295
+ /**
296
+ * Safely extracts the model name from a resource
297
+ *
298
+ * @private
299
+ * @param {Object} resource - The resource/model
300
+ * @returns {String} Model name or 'unknown'
301
+ */
302
+ #getModelName(resource) {
303
+ if (!resource) {
304
+ return 'unknown';
305
+ }
306
+
307
+ // Try multiple ways to get model name
308
+ if (resource.constructor?.modelName) {
309
+ return resource.constructor.modelName;
310
+ }
311
+
312
+ if (resource._internalModel?.modelName) {
313
+ return resource._internalModel.modelName;
314
+ }
315
+
316
+ if (resource.modelName) {
317
+ return resource.modelName;
318
+ }
319
+
320
+ return 'unknown';
321
+ }
322
+ }
@@ -29,6 +29,8 @@ export default class ResourceActionService extends Service {
29
29
  @service abilities;
30
30
  @service tableContext;
31
31
  @service resourceContextPanel;
32
+ @service universe;
33
+ @service events;
32
34
 
33
35
  /**
34
36
  * Getter for router, attempt to use hostRouter if from engine
@@ -299,6 +301,9 @@ export default class ResourceActionService extends Service {
299
301
  })
300
302
  );
301
303
 
304
+ // Track creation event
305
+ this.events.trackResourceCreated(record);
306
+
302
307
  if (options.refresh) {
303
308
  this.refresh();
304
309
  }
@@ -329,6 +334,9 @@ export default class ResourceActionService extends Service {
329
334
  })
330
335
  );
331
336
 
337
+ // Track update event
338
+ this.events.trackResourceUpdated(record);
339
+
332
340
  if (options.refresh) {
333
341
  this.refresh();
334
342
  }
@@ -362,6 +370,13 @@ export default class ResourceActionService extends Service {
362
370
  })
363
371
  );
364
372
 
373
+ // Track save event (create or update)
374
+ if (isNew) {
375
+ this.events.trackResourceCreated(record);
376
+ } else {
377
+ this.events.trackResourceUpdated(record);
378
+ }
379
+
365
380
  if (options.refresh) {
366
381
  this.refresh();
367
382
  }
@@ -409,6 +424,9 @@ export default class ResourceActionService extends Service {
409
424
  })
410
425
  );
411
426
 
427
+ // Track deletion event
428
+ this.events.trackResourceDeleted(record);
429
+
412
430
  if (options.refresh) {
413
431
  this.refresh();
414
432
  }
@@ -33,7 +33,6 @@ export default class UniverseService extends Service.extend(Evented) {
33
33
  @service router;
34
34
  @service intl;
35
35
  @service urlSearchParams;
36
-
37
36
  @tracked applicationInstance;
38
37
  @tracked initialLocation = { ...window.location };
39
38
  @tracked bootCallbacks = A([]);