@fleetbase/ember-core 0.3.10 → 0.3.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.
@@ -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
  }
@@ -16,6 +16,7 @@ export default class CurrentUserService extends Service.extend(Evented) {
16
16
  @service theme;
17
17
  @service notifications;
18
18
  @service intl;
19
+ @service events;
19
20
 
20
21
  @tracked user = { id: 'anon' };
21
22
  @tracked userSnapshot = { id: 'anon' };
@@ -65,14 +66,9 @@ export default class CurrentUserService extends Service.extend(Evented) {
65
66
  async load() {
66
67
  if (this.session.isAuthenticated) {
67
68
  const user = await this.store.findRecord('user', 'me');
68
- const snapshot = await this.getUserSnapshot(user);
69
69
 
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);
70
+ // set user
71
+ this.setUser(user);
76
72
 
77
73
  // Load preferences
78
74
  await this.loadPreferences();
@@ -91,25 +87,9 @@ export default class CurrentUserService extends Service.extend(Evented) {
91
87
 
92
88
  try {
93
89
  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
90
 
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
- }
91
+ // set user
92
+ this.setUser(user);
113
93
 
114
94
  // Load user whois data
115
95
  await this.loadWhois();
@@ -309,4 +289,26 @@ export default class CurrentUserService extends Service.extend(Evented) {
309
289
 
310
290
  return defaultValue;
311
291
  }
292
+
293
+ async setUser(user) {
294
+ const snapshot = await this.getUserSnapshot(user);
295
+
296
+ // Set current user
297
+ this.set('user', user);
298
+ this.set('userSnapshot', snapshot);
299
+ this.trigger('user.loaded', user);
300
+
301
+ // Set permissions
302
+ this.permissions = this.getUserPermissions(user);
303
+
304
+ // Set environment from user option
305
+ this.theme.setEnvironment();
306
+
307
+ // Set locale
308
+ if (user.locale) {
309
+ this.setLocale(user.locale);
310
+ } else {
311
+ await this.loadLocale();
312
+ }
313
+ }
312
314
  }
@@ -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
  }
@@ -0,0 +1 @@
1
+ export { default } from '@fleetbase/ember-core/services/events';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetbase/ember-core",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "Provides all the core services, decorators and utilities for building a Fleetbase extension for the Console.",
5
5
  "keywords": [
6
6
  "fleetbase-core",
@@ -1,294 +0,0 @@
1
- # Boot Sequence Refactor Guide
2
-
3
- ## Overview
4
-
5
- This guide provides the steps to refactor the application boot sequence to enable true lazy loading and move away from the old `bootEngines` mechanism that loads all extensions at startup.
6
-
7
- ## Understanding the Extension Loading Flow
8
-
9
- The Fleetbase application has a three-tier extension loading system:
10
-
11
- 1. **pnpm Installation**: All extensions are installed via pnpm, making them available to the application
12
- 2. **System Configuration**: Extensions defined in `fleetbase.config.js` or `EXTENSIONS` environment variable are loaded globally
13
- 3. **User Permissions**: Individual users can install/uninstall extensions, which affects what loads for them specifically
14
-
15
- Only extensions that are both installed AND enabled (via config or user permissions) will be initialized.
16
-
17
- ## The Goal
18
-
19
- Stop loading all extension code at boot time. Instead:
20
- - Load only the `extension.js` files (metadata registration)
21
- - Keep engine bundles lazy-loaded (loaded on-demand when routes are visited)
22
- - Preserve the `engines` property required by ember-engines for lazy loading
23
-
24
- ## Key Changes
25
-
26
- 1. **Keep `app.engines` property**: Required by ember-engines for lazy loading
27
- 2. **Create new `initialize-universe` instance initializer**: Loads `extension.js` files and registers metadata
28
- 3. **Remove `bootEngines` calls**: No more manual engine booting at startup
29
-
30
- ## Step-by-Step Guide
31
-
32
- ### Step 1: Update `app.js` to Preserve Engines Property
33
-
34
- The `engines` property is **required** by ember-engines to enable lazy loading. Keep the existing structure but remove any `bootEngines` calls.
35
-
36
- **Current `app.js` (fleetbase/console/app/app.js):**
37
-
38
- ```javascript
39
- import Application from '@ember/application';
40
- import Resolver from 'ember-resolver';
41
- import loadInitializers from 'ember-load-initializers';
42
- import config from '@fleetbase/console/config/environment';
43
- import loadExtensions from '@fleetbase/ember-core/utils/load-extensions';
44
- import mapEngines from '@fleetbase/ember-core/utils/map-engines';
45
- import loadRuntimeConfig from '@fleetbase/console/utils/runtime-config';
46
- import applyRouterFix from './utils/router-refresh-patch';
47
-
48
- export default class App extends Application {
49
- modulePrefix = config.modulePrefix;
50
- podModulePrefix = config.podModulePrefix;
51
- Resolver = Resolver;
52
- extensions = [];
53
- engines = {}; // ← KEEP THIS! Required by ember-engines
54
-
55
- async ready() {
56
- applyRouterFix(this);
57
- const extensions = await loadExtensions();
58
-
59
- this.extensions = extensions;
60
- this.engines = mapEngines(extensions); // ← KEEP THIS! Maps extensions to engines
61
- }
62
- }
63
-
64
- document.addEventListener('DOMContentLoaded', async () => {
65
- await loadRuntimeConfig();
66
- loadInitializers(App, config.modulePrefix);
67
-
68
- let fleetbase = App.create();
69
- fleetbase.deferReadiness();
70
- fleetbase.boot();
71
- });
72
- ```
73
-
74
- **What to Keep:**
75
- - ✅ `extensions` property - tracks which extensions are enabled
76
- - ✅ `engines` property - required by ember-engines for lazy loading
77
- - ✅ `loadExtensions()` - determines which extensions to load based on config + user permissions
78
- - ✅ `mapEngines()` - creates the engines object required by ember-engines
79
-
80
- **What Changes:**
81
- - ❌ Remove any `bootEngines()` calls (if present in instance initializers)
82
- - ❌ Remove `initialize-widgets.js` instance initializer (logic moves to `extension.js`)
83
-
84
- ### Step 2: Remove Old Instance Initializers
85
-
86
- Delete the following instance initializers that perform eager engine loading:
87
-
88
- **Files to Delete:**
89
- - `app/instance-initializers/load-extensions.js` (if it calls `bootEngines`)
90
- - `app/instance-initializers/initialize-widgets.js` (widgets now registered via `extension.js`)
91
-
92
- ### Step 3: Create New `initialize-universe` Initializer
93
-
94
- Create a new instance initializer at `app/instance-initializers/initialize-universe.js`:
95
-
96
- ```javascript
97
- import { getOwner } from '@ember/application';
98
- import { scheduleOnce } from '@ember/runloop';
99
-
100
- /**
101
- * Initializes the Universe by loading and executing extension.js files
102
- * from all enabled extensions. This replaces the old bootEngines mechanism.
103
- *
104
- * Key differences from old approach:
105
- * - Only loads extension.js files (small, metadata only)
106
- * - Does NOT load engine bundles (those lazy-load when routes are visited)
107
- * - Respects both system config and user permissions
108
- *
109
- * @param {ApplicationInstance} appInstance The application instance
110
- */
111
- export function initialize(appInstance) {
112
- const universe = appInstance.lookup('service:universe');
113
- const owner = getOwner(appInstance);
114
- const app = owner.application;
115
-
116
- // Set application instance on universe
117
- universe.applicationInstance = appInstance;
118
-
119
- // Get the list of enabled extensions from the app
120
- // This list already respects config + user permissions via loadExtensions()
121
- const extensions = app.extensions || [];
122
-
123
- // Load and execute extension.js from each enabled extension
124
- extensions.forEach(extensionName => {
125
- try {
126
- // Dynamically require the extension.js file
127
- // This is a small file with only metadata, not the full engine bundle
128
- const setupExtension = require(`${extensionName}/extension`).default;
129
-
130
- if (typeof setupExtension === 'function') {
131
- // Execute the extension setup function
132
- // This registers menus, widgets, hooks, etc. as metadata
133
- setupExtension(appInstance, universe);
134
- }
135
- } catch (error) {
136
- // Silently fail if extension.js doesn't exist
137
- // Extensions can migrate gradually to the new pattern
138
- // console.warn(`Could not load extension.js for ${extensionName}:`, error);
139
- }
140
- });
141
-
142
- // Execute any boot callbacks
143
- scheduleOnce('afterRender', universe, 'executeBootCallbacks');
144
- }
145
-
146
- export default {
147
- name: 'initialize-universe',
148
- initialize
149
- };
150
- ```
151
-
152
- ### Step 4: Verify `router.js` Engine Mounting
153
-
154
- Your `prebuild.js` script already handles mounting engines in `router.js`. Verify that engines are mounted like this:
155
-
156
- ```javascript
157
- // This is generated by prebuild.js
158
- this.mount('@fleetbase/fleetops-engine', { as: 'console.fleet-ops' });
159
- this.mount('@fleetbase/customer-portal-engine', { as: 'console.customer-portal' });
160
- ```
161
-
162
- **Important**: The `this.mount()` calls are what enable ember-engines lazy loading. When a user navigates to a route, ember-engines automatically loads the engine bundle on-demand.
163
-
164
- ### Step 5: Migrate Extensions to `extension.js` Pattern
165
-
166
- For each extension, create an `addon/extension.js` file that registers metadata without importing components:
167
-
168
- **Example: FleetOps `addon/extension.js`**
169
-
170
- ```javascript
171
- import { MenuItem, MenuPanel, Widget, ExtensionComponent } from '@fleetbase/ember-core/contracts';
172
-
173
- export default function (app, universe) {
174
- // Register admin menu panel
175
- universe.registerAdminMenuPanel(
176
- 'Fleet-Ops',
177
- new MenuPanel({
178
- title: 'Fleet-Ops',
179
- icon: 'route',
180
- items: [
181
- new MenuItem({
182
- title: 'Navigator App',
183
- icon: 'location-arrow',
184
- component: new ExtensionComponent('@fleetbase/fleetops-engine', 'components/admin/navigator-app')
185
- }),
186
- new MenuItem({
187
- title: 'Avatar Management',
188
- icon: 'images',
189
- component: new ExtensionComponent('@fleetbase/fleetops-engine', 'components/admin/avatar-management')
190
- })
191
- ]
192
- })
193
- );
194
-
195
- // Register widgets
196
- universe.registerDefaultWidget(
197
- new Widget({
198
- widgetId: 'fleet-ops-metrics',
199
- name: 'Fleet-Ops Metrics',
200
- description: 'Key metrics from Fleet-Ops',
201
- icon: 'truck',
202
- component: new ExtensionComponent('@fleetbase/fleetops-engine', 'components/widget/metrics'),
203
- grid_options: { w: 12, h: 12, minW: 8, minH: 12 }
204
- })
205
- );
206
-
207
- // Register hooks
208
- universe.registerHook(
209
- new Hook({
210
- name: 'application:before-model',
211
- handler: (session, router) => {
212
- // Custom logic here
213
- },
214
- priority: 10
215
- })
216
- );
217
- }
218
- ```
219
-
220
- **Key Points:**
221
- - ❌ NO `import MyComponent from './components/my-component'` - this would load the engine!
222
- - ✅ Use `ExtensionComponent` with engine name + path for lazy loading
223
- - ✅ Use contract classes (`MenuItem`, `Widget`, `Hook`) for type safety
224
-
225
- See [UNIVERSE_REFACTOR_MIGRATION_GUIDE.md](./UNIVERSE_REFACTOR_MIGRATION_GUIDE.md) for detailed migration examples.
226
-
227
- ## How Lazy Loading Works with This Approach
228
-
229
- 1. **App Boot**: Application boots with `app.engines` property set
230
- 2. **`initialize-universe`**: Loads small `extension.js` files via `require()`
231
- 3. **Metadata Registration**: Extensions register menus, widgets, hooks (no component code loaded)
232
- 4. **User Navigation**: User navigates to `/console/fleet-ops`
233
- 5. **Ember-Engines**: Detects route is in a mounted engine, lazy-loads the engine bundle
234
- 6. **Component Resolution**: `<LazyEngineComponent>` resolves components from loaded engine
235
-
236
- ## Performance Impact
237
-
238
- | Metric | Before (bootEngines) | After (Lazy Loading) |
239
- |--------|---------------------|---------------------|
240
- | Initial Load Time | 10-40 seconds | <1 second |
241
- | Initial Bundle Size | Core + All Engines | Core + extension.js files |
242
- | Engine Loading | All at boot | On-demand when route visited |
243
- | Memory Usage | All engines in memory | Only visited engines in memory |
244
-
245
- ## Ember-Engines Requirements
246
-
247
- According to [ember-engines documentation](https://github.com/ember-engines/ember-engines):
248
-
249
- > **Lazy loading** - An engine can allow its parent to boot with only its routing map loaded. The rest of the engine can be loaded only as required (i.e. when a route in an engine is visited). This allows applications to boot faster and limit their memory consumption.
250
-
251
- **Required for lazy loading:**
252
- 1. ✅ `app.engines` property must be set (maps extension names to engine modules)
253
- 2. ✅ Engines must be mounted in `router.js` via `this.mount()`
254
- 3. ✅ Engine's `index.js` must have `lazyLoading: true` (default)
255
-
256
- **What breaks lazy loading:**
257
- 1. ❌ Calling `owner.lookup('engine:my-engine')` at boot time
258
- 2. ❌ Importing components from engines in `extension.js`
259
- 3. ❌ Manual `bootEngines()` calls
260
-
261
- ## Troubleshooting
262
-
263
- ### Extension not loading
264
- - Check that extension is in `app.extensions` array
265
- - Verify `extension.js` file exists and exports a function
266
- - Check browser console for errors
267
-
268
- ### Components not rendering
269
- - Ensure `ExtensionComponent` has correct engine name and path
270
- - Verify engine is mounted in `router.js`
271
- - Check that `<LazyEngineComponent>` is used in templates
272
-
273
- ### Engines loading at boot
274
- - Remove any `owner.lookup('engine:...')` calls from initializers
275
- - Remove component imports from `extension.js`
276
- - Verify no `bootEngines()` calls remain
277
-
278
- ## Migration Checklist
279
-
280
- - [ ] Update `app.js` to keep `engines` property
281
- - [ ] Remove old instance initializers (`load-extensions.js`, `initialize-widgets.js`)
282
- - [ ] Create new `initialize-universe.js` instance initializer
283
- - [ ] Verify `router.js` has `this.mount()` calls for all engines
284
- - [ ] Create `extension.js` for each extension
285
- - [ ] Replace component imports with `ExtensionComponent` definitions
286
- - [ ] Test lazy loading in browser dev tools (Network tab)
287
- - [ ] Verify initial bundle size reduction
288
- - [ ] Test all extension functionality still works
289
-
290
- ## References
291
-
292
- - [Ember Engines Guide](https://guides.emberjs.com/v5.6.0/applications/ember-engines/)
293
- - [ember-engines GitHub](https://github.com/ember-engines/ember-engines)
294
- - [Ember Engines RFC](https://github.com/emberjs/rfcs/blob/master/text/0010-engines.md)
@@ -1,318 +0,0 @@
1
- # UniverseService Refactor Migration Guide
2
-
3
- ## Overview
4
-
5
- The UniverseService has been completely refactored to improve performance, maintainability, and developer experience. This guide will help you migrate your extensions to the new architecture.
6
-
7
- ## What Changed?
8
-
9
- ### 1. Service Decomposition
10
-
11
- The monolithic `UniverseService` has been split into specialized services:
12
-
13
- - **ExtensionManager**: Manages lazy loading of engines
14
- - **RegistryService**: Manages all registries using Ember's container
15
- - **MenuService**: Manages menu items and panels
16
- - **WidgetService**: Manages dashboard widgets
17
- - **HookService**: Manages application hooks
18
-
19
- The original `UniverseService` now acts as a facade, delegating to these services while maintaining backward compatibility.
20
-
21
- ### 2. Contract System
22
-
23
- New contract classes provide a fluent, type-safe API:
24
-
25
- - `ExtensionComponent`: Lazy-loadable component definitions
26
- - `MenuItem`: Menu item definitions
27
- - `MenuPanel`: Menu panel definitions
28
- - `Hook`: Hook definitions
29
- - `Widget`: Widget definitions
30
- - `Registry`: Registry namespace definitions
31
-
32
- ### 3. Lazy Loading Architecture
33
-
34
- The old `bootEngines` mechanism has been replaced with on-demand lazy loading:
35
-
36
- - Engines are no longer loaded at boot time
37
- - Components are loaded only when needed
38
- - The `<LazyEngineComponent>` wrapper handles lazy loading automatically
39
-
40
- ## Migration Steps
41
-
42
- ### Step 1: Create `extension.js` File
43
-
44
- Each engine should create a new `addon/extension.js` file to replace the `setupExtension` method in `engine.js`.
45
-
46
- **Before (`addon/engine.js`):**
47
-
48
- ```javascript
49
- import NavigatorAppComponent from './components/admin/navigator-app';
50
-
51
- export default class FleetOpsEngine extends Engine {
52
- setupExtension = function (app, engine, universe) {
53
- universe.registerHeaderMenuItem('Fleet-Ops', 'console.fleet-ops', {
54
- icon: 'route',
55
- priority: 0
56
- });
57
-
58
- universe.registerAdminMenuPanel('Fleet-Ops Config', [
59
- {
60
- title: 'Navigator App',
61
- component: NavigatorAppComponent
62
- }
63
- ]);
64
- };
65
- }
66
- ```
67
-
68
- **After (`addon/extension.js`):**
69
-
70
- ```javascript
71
- import { MenuItem, MenuPanel, ExtensionComponent } from '@fleetbase/ember-core/contracts';
72
-
73
- export default function (app, universe) {
74
- // Register header menu item
75
- universe.registerHeaderMenuItem(
76
- new MenuItem('Fleet-Ops', 'console.fleet-ops')
77
- .withIcon('route')
78
- .withPriority(0)
79
- );
80
-
81
- // Register admin panel with lazy component
82
- universe.registerAdminMenuPanel(
83
- new MenuPanel('Fleet-Ops Config')
84
- .addItem(
85
- new MenuItem('Navigator App')
86
- .withIcon('location-arrow')
87
- .withComponent(
88
- new ExtensionComponent('@fleetbase/fleetops-engine', 'components/admin/navigator-app')
89
- )
90
- )
91
- );
92
- }
93
- ```
94
-
95
- **After (`addon/engine.js`):**
96
-
97
- ```javascript
98
- // Remove the setupExtension method entirely
99
- export default class FleetOpsEngine extends Engine {
100
- // ... other engine configuration
101
- }
102
- ```
103
-
104
- ### Step 2: Use Contract Classes
105
-
106
- Instead of plain objects, use the new contract classes for better type safety and developer experience.
107
-
108
- **Before:**
109
-
110
- ```javascript
111
- universe.registerWidget({
112
- widgetId: 'fleet-ops-metrics',
113
- name: 'Fleet-Ops Metrics',
114
- icon: 'truck',
115
- component: WidgetComponent,
116
- grid_options: { w: 12, h: 12 }
117
- });
118
- ```
119
-
120
- **After:**
121
-
122
- ```javascript
123
- import { Widget, ExtensionComponent } from '@fleetbase/ember-core/contracts';
124
-
125
- universe.registerDashboardWidgets([
126
- new Widget('fleet-ops-metrics')
127
- .withName('Fleet-Ops Metrics')
128
- .withIcon('truck')
129
- .withComponent(
130
- new ExtensionComponent('@fleetbase/fleetops-engine', 'components/widget/fleet-ops-key-metrics')
131
- )
132
- .withGridOptions({ w: 12, h: 12 })
133
- ]);
134
- ```
135
-
136
- ### Step 3: Update Component References
137
-
138
- Replace direct component imports with lazy component definitions.
139
-
140
- **Before:**
141
-
142
- ```javascript
143
- import MyComponent from './components/my-component';
144
-
145
- universe.registerMenuItem('my-registry', 'My Item', {
146
- component: MyComponent
147
- });
148
- ```
149
-
150
- **After:**
151
-
152
- ```javascript
153
- import { MenuItem, ExtensionComponent } from '@fleetbase/ember-core/contracts';
154
-
155
- universe.registerMenuItem(
156
- 'my-registry',
157
- new MenuItem('My Item')
158
- .withComponent(
159
- new ExtensionComponent('@fleetbase/my-engine', 'components/my-component')
160
- )
161
- );
162
- ```
163
-
164
- ### Step 4: Update Templates Using Registry Components
165
-
166
- Templates that render components from registries need to use the `<LazyEngineComponent>` wrapper.
167
-
168
- **Before:**
169
-
170
- ```handlebars
171
- {{#each this.menuItems as |item|}}
172
- {{component item.component model=@model}}
173
- {{/each}}
174
- ```
175
-
176
- **After:**
177
-
178
- ```handlebars
179
- {{#each this.menuItems as |item|}}
180
- <LazyEngineComponent @componentDef={{item.component}} @model={{@model}} />
181
- {{/each}}
182
- ```
183
-
184
- ### Step 5: Update Hook Registrations
185
-
186
- Use the new `Hook` contract for better hook management.
187
-
188
- **Before:**
189
-
190
- ```javascript
191
- universe.registerHook('application:before-model', (session, router) => {
192
- if (session.isCustomer) {
193
- router.transitionTo('customer-portal');
194
- }
195
- });
196
- ```
197
-
198
- **After:**
199
-
200
- ```javascript
201
- import { Hook } from '@fleetbase/ember-core/contracts';
202
-
203
- universe.registerHook(
204
- new Hook('application:before-model', (session, router) => {
205
- if (session.isCustomer) {
206
- router.transitionTo('customer-portal');
207
- }
208
- })
209
- .withPriority(10)
210
- .withId('customer-redirect')
211
- );
212
- ```
213
-
214
- ## Backward Compatibility
215
-
216
- The refactored `UniverseService` maintains backward compatibility with the old API. You can continue using the old syntax while migrating:
217
-
218
- ```javascript
219
- // Old syntax still works
220
- universe.registerHeaderMenuItem('My Item', 'my.route', { icon: 'star' });
221
-
222
- // New syntax is preferred
223
- universe.registerHeaderMenuItem(
224
- new MenuItem('My Item', 'my.route').withIcon('star')
225
- );
226
- ```
227
-
228
- ## Benefits of Migration
229
-
230
- 1. **Performance**: Sub-second boot times with lazy loading
231
- 2. **Type Safety**: Contract classes provide validation and IDE support
232
- 3. **Maintainability**: Specialized services are easier to understand and modify
233
- 4. **Developer Experience**: Fluent API with method chaining
234
- 5. **Extensibility**: Easy to add new features without breaking changes
235
-
236
- ## Common Patterns
237
-
238
- ### Menu Item with Click Handler
239
-
240
- ```javascript
241
- new MenuItem('Track Order')
242
- .withIcon('barcode')
243
- .withType('link')
244
- .withWrapperClass('btn-block py-1 border')
245
- .withComponent(
246
- new ExtensionComponent('@fleetbase/fleetops-engine', 'components/order-tracking-lookup')
247
- )
248
- .onClick((menuItem) => {
249
- universe.transitionMenuItem('virtual', menuItem);
250
- })
251
- ```
252
-
253
- ### Widget with Refresh Interval
254
-
255
- ```javascript
256
- new Widget('live-metrics')
257
- .withName('Live Metrics')
258
- .withComponent(
259
- new ExtensionComponent('@fleetbase/my-engine', 'components/widget/live-metrics')
260
- .withLoadingComponent('skeletons/widget')
261
- )
262
- .withRefreshInterval(5000)
263
- .asDefault()
264
- ```
265
-
266
- ### Hook with Priority and Once
267
-
268
- ```javascript
269
- new Hook('order:before-save')
270
- .withPriority(10)
271
- .once()
272
- .execute(async (order) => {
273
- await validateOrder(order);
274
- })
275
- ```
276
-
277
- ## Troubleshooting
278
-
279
- ### Component Not Found Error
280
-
281
- If you see "Component not found in engine" errors:
282
-
283
- 1. Check that the component path is correct
284
- 2. Ensure the engine name matches exactly
285
- 3. Verify the component exists in the engine
286
-
287
- ### Loading Spinner Not Showing
288
-
289
- If the loading spinner doesn't appear:
290
-
291
- 1. Check that you're using `<LazyEngineComponent>` in templates
292
- 2. Verify the `componentDef` is a lazy definition object
293
- 3. Ensure the loading component exists
294
-
295
- ### Hooks Not Executing
296
-
297
- If hooks aren't running:
298
-
299
- 1. Check the hook name matches exactly
300
- 2. Verify the hook is registered before it's needed
301
- 3. Use `universe.hookService.getHooks(hookName)` to debug
302
-
303
- ## Support
304
-
305
- For questions or issues with the migration, please:
306
-
307
- 1. Check the contract class documentation in `addon/contracts/`
308
- 2. Review the service documentation in `addon/services/universe/`
309
- 3. Open an issue on GitHub with details about your migration challenge
310
-
311
- ## Timeline
312
-
313
- - **Phase 1**: Refactored services are available, old API still works
314
- - **Phase 2**: Extensions migrate to new `extension.js` pattern
315
- - **Phase 3**: Deprecation warnings for old patterns
316
- - **Phase 4**: Old `setupExtension` pattern removed (future release)
317
-
318
- You can migrate at your own pace. The new architecture is fully backward compatible.
@@ -1,220 +0,0 @@
1
- # UniverseService Refactor
2
-
3
- ## Overview
4
-
5
- This refactor addresses critical performance and architectural issues in the UniverseService by decomposing it into specialized services, introducing a contract system, and implementing true lazy loading for engines.
6
-
7
- ## Problems Solved
8
-
9
- ### 1. Performance Bottleneck
10
-
11
- **Before**: 10-40 second initial load time due to sequential `bootEngines` process loading all extensions upfront.
12
-
13
- **After**: <1 second initial load time with on-demand lazy loading.
14
-
15
- ### 2. Monolithic Design
16
-
17
- **Before**: 1,978 lines handling 7+ distinct responsibilities in a single service.
18
-
19
- **After**: Specialized services with clear separation of concerns:
20
- - `ExtensionManager`: Engine lifecycle and lazy loading
21
- - `RegistryService`: Registry management using Ember's container
22
- - `MenuService`: Menu items and panels
23
- - `WidgetService`: Dashboard widgets
24
- - `HookService`: Application hooks
25
-
26
- ### 3. Inefficient Registry
27
-
28
- **Before**: Custom object-based registry with O(n) lookups.
29
-
30
- **After**: Ember container-based registry with O(1) lookups.
31
-
32
- ### 4. Broken Lazy Loading
33
-
34
- **Before**: `bootEngines` manually boots and initializes every engine, breaking lazy loading.
35
-
36
- **After**: Engines load on-demand when their components are actually needed.
37
-
38
- ## Architecture
39
-
40
- ```
41
- ┌─────────────────────────────────────────────────────────────┐
42
- │ UniverseService (Facade) │
43
- │ Maintains backward compatibility while delegating to: │
44
- └─────────────────────────────────────────────────────────────┘
45
-
46
- ┌───────────────────┼───────────────────┐
47
- │ │ │
48
- ▼ ▼ ▼
49
- ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
50
- │ Extension │ │ Registry │ │ Menu │
51
- │ Manager │ │ Service │ │ Service │
52
- └──────────────┘ └──────────────┘ └──────────────┘
53
- │ │ │
54
- ▼ ▼ ▼
55
- ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
56
- │ Widget │ │ Hook │ │ Contract │
57
- │ Service │ │ Service │ │ System │
58
- └──────────────┘ └──────────────┘ └──────────────┘
59
- ```
60
-
61
- ## Contract System
62
-
63
- New classes provide a fluent, type-safe API for extension definitions:
64
-
65
- ```javascript
66
- import { MenuItem, ExtensionComponent, Widget, Hook } from '@fleetbase/ember-core/contracts';
67
-
68
- // Menu item with lazy component
69
- new MenuItem('Fleet-Ops', 'console.fleet-ops')
70
- .withIcon('route')
71
- .withPriority(0)
72
- .withComponent(
73
- new ExtensionComponent('@fleetbase/fleetops-engine', 'components/admin/navigator-app')
74
- );
75
-
76
- // Widget with grid options
77
- new Widget('fleet-ops-metrics')
78
- .withName('Fleet-Ops Metrics')
79
- .withIcon('truck')
80
- .withComponent(
81
- new ExtensionComponent('@fleetbase/fleetops-engine', 'components/widget/metrics')
82
- )
83
- .withGridOptions({ w: 12, h: 12 })
84
- .asDefault();
85
-
86
- // Hook with priority
87
- new Hook('application:before-model', (session, router) => {
88
- if (session.isCustomer) {
89
- router.transitionTo('customer-portal');
90
- }
91
- })
92
- .withPriority(10)
93
- .once();
94
- ```
95
-
96
- ## Lazy Loading Flow
97
-
98
- 1. **Boot Time**: Only `extension.js` files are loaded (no engine code)
99
- 2. **Registration**: Metadata is registered (menus, widgets, hooks)
100
- 3. **Runtime**: When a component needs to render:
101
- - `<LazyEngineComponent>` triggers `extensionManager.ensureEngineLoaded()`
102
- - Engine bundle is fetched and loaded
103
- - Component is looked up from the engine
104
- - Component is rendered
105
-
106
- ## Extension Pattern
107
-
108
- ### Old Pattern (engine.js)
109
-
110
- ```javascript
111
- import MyComponent from './components/my-component';
112
-
113
- export default class MyEngine extends Engine {
114
- setupExtension = function (app, engine, universe) {
115
- universe.registerMenuItem('my-registry', 'My Item', {
116
- component: MyComponent // Loads entire engine!
117
- });
118
- };
119
- }
120
- ```
121
-
122
- ### New Pattern (extension.js)
123
-
124
- ```javascript
125
- import { MenuItem, ExtensionComponent } from '@fleetbase/ember-core/contracts';
126
-
127
- export default function (app, universe) {
128
- universe.registerMenuItem(
129
- 'my-registry',
130
- new MenuItem('My Item')
131
- .withComponent(
132
- new ExtensionComponent('@fleetbase/my-engine', 'components/my-component')
133
- )
134
- );
135
- }
136
- ```
137
-
138
- **Key Difference**: No component imports = no engine loading at boot time.
139
-
140
- ## Performance Improvements
141
-
142
- | Metric | Before | After | Improvement |
143
- |--------|--------|-------|-------------|
144
- | Initial Load Time | 10-40s | <1s | ~90% faster |
145
- | Bundle Size (initial) | Full app + all engines | Core app only | ~60% reduction |
146
- | Lookup Performance | O(n) | O(1) | 100x faster |
147
- | Timeout Errors | Frequent | None | 100% reduction |
148
-
149
- ## Backward Compatibility
150
-
151
- The refactor is **100% backward compatible**. The old API still works:
152
-
153
- ```javascript
154
- // Old syntax (still works)
155
- universe.registerHeaderMenuItem('My Item', 'my.route', { icon: 'star' });
156
-
157
- // New syntax (preferred)
158
- universe.registerHeaderMenuItem(
159
- new MenuItem('My Item', 'my.route').withIcon('star')
160
- );
161
- ```
162
-
163
- ## Migration
164
-
165
- See [UNIVERSE_REFACTOR_MIGRATION_GUIDE.md](./UNIVERSE_REFACTOR_MIGRATION_GUIDE.md) for detailed migration instructions.
166
-
167
- ## Files Changed
168
-
169
- ### New Files
170
-
171
- - `addon/contracts/` - Contract system classes
172
- - `base-contract.js`
173
- - `extension-component.js`
174
- - `menu-item.js`
175
- - `menu-panel.js`
176
- - `hook.js`
177
- - `widget.js`
178
- - `registry.js`
179
- - `index.js`
180
-
181
- - `addon/services/universe/` - Specialized services
182
- - `extension-manager.js`
183
- - `registry-service.js`
184
- - `menu-service.js`
185
- - `widget-service.js`
186
- - `hook-service.js`
187
-
188
- - `addon/components/` - Lazy loading component
189
- - `lazy-engine-component.js`
190
- - `lazy-engine-component.hbs`
191
-
192
- ### Modified Files
193
-
194
- - `addon/services/universe.js` - Refactored as facade
195
- - `addon/services/legacy-universe.js` - Original service (for reference)
196
-
197
- ## Testing
198
-
199
- The refactor includes:
200
-
201
- 1. **Unit tests** for each contract class
202
- 2. **Integration tests** for each service
203
- 3. **Acceptance tests** for lazy loading behavior
204
- 4. **Performance benchmarks** comparing old vs new
205
-
206
- ## Future Enhancements
207
-
208
- 1. **TypeScript definitions** for contract classes
209
- 2. **Extension manifest validation** at build time
210
- 3. **Preloading strategies** for critical engines
211
- 4. **Memory management** for long-running applications
212
- 5. **Developer tools** for debugging extension loading
213
-
214
- ## Credits
215
-
216
- Designed and implemented based on collaborative analysis with Ronald A Richardson, CTO of Fleetbase.
217
-
218
- ## License
219
-
220
- MIT License - Copyright (c) 2025 Fleetbase