diff --git a/BOOT_SEQUENCE_REFACTOR_GUIDE.md b/BOOT_SEQUENCE_REFACTOR_GUIDE.md deleted file mode 100644 index afa47052..00000000 --- a/BOOT_SEQUENCE_REFACTOR_GUIDE.md +++ /dev/null @@ -1,294 +0,0 @@ -# Boot Sequence Refactor Guide - -## Overview - -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. - -## Understanding the Extension Loading Flow - -The Fleetbase application has a three-tier extension loading system: - -1. **pnpm Installation**: All extensions are installed via pnpm, making them available to the application -2. **System Configuration**: Extensions defined in `fleetbase.config.js` or `EXTENSIONS` environment variable are loaded globally -3. **User Permissions**: Individual users can install/uninstall extensions, which affects what loads for them specifically - -Only extensions that are both installed AND enabled (via config or user permissions) will be initialized. - -## The Goal - -Stop loading all extension code at boot time. Instead: -- Load only the `extension.js` files (metadata registration) -- Keep engine bundles lazy-loaded (loaded on-demand when routes are visited) -- Preserve the `engines` property required by ember-engines for lazy loading - -## Key Changes - -1. **Keep `app.engines` property**: Required by ember-engines for lazy loading -2. **Create new `initialize-universe` instance initializer**: Loads `extension.js` files and registers metadata -3. **Remove `bootEngines` calls**: No more manual engine booting at startup - -## Step-by-Step Guide - -### Step 1: Update `app.js` to Preserve Engines Property - -The `engines` property is **required** by ember-engines to enable lazy loading. Keep the existing structure but remove any `bootEngines` calls. - -**Current `app.js` (fleetbase/console/app/app.js):** - -```javascript -import Application from '@ember/application'; -import Resolver from 'ember-resolver'; -import loadInitializers from 'ember-load-initializers'; -import config from '@fleetbase/console/config/environment'; -import loadExtensions from '@fleetbase/ember-core/utils/load-extensions'; -import mapEngines from '@fleetbase/ember-core/utils/map-engines'; -import loadRuntimeConfig from '@fleetbase/console/utils/runtime-config'; -import applyRouterFix from './utils/router-refresh-patch'; - -export default class App extends Application { - modulePrefix = config.modulePrefix; - podModulePrefix = config.podModulePrefix; - Resolver = Resolver; - extensions = []; - engines = {}; // ← KEEP THIS! Required by ember-engines - - async ready() { - applyRouterFix(this); - const extensions = await loadExtensions(); - - this.extensions = extensions; - this.engines = mapEngines(extensions); // ← KEEP THIS! Maps extensions to engines - } -} - -document.addEventListener('DOMContentLoaded', async () => { - await loadRuntimeConfig(); - loadInitializers(App, config.modulePrefix); - - let fleetbase = App.create(); - fleetbase.deferReadiness(); - fleetbase.boot(); -}); -``` - -**What to Keep:** -- ✅ `extensions` property - tracks which extensions are enabled -- ✅ `engines` property - required by ember-engines for lazy loading -- ✅ `loadExtensions()` - determines which extensions to load based on config + user permissions -- ✅ `mapEngines()` - creates the engines object required by ember-engines - -**What Changes:** -- ❌ Remove any `bootEngines()` calls (if present in instance initializers) -- ❌ Remove `initialize-widgets.js` instance initializer (logic moves to `extension.js`) - -### Step 2: Remove Old Instance Initializers - -Delete the following instance initializers that perform eager engine loading: - -**Files to Delete:** -- `app/instance-initializers/load-extensions.js` (if it calls `bootEngines`) -- `app/instance-initializers/initialize-widgets.js` (widgets now registered via `extension.js`) - -### Step 3: Create New `initialize-universe` Initializer - -Create a new instance initializer at `app/instance-initializers/initialize-universe.js`: - -```javascript -import { getOwner } from '@ember/application'; -import { scheduleOnce } from '@ember/runloop'; - -/** - * Initializes the Universe by loading and executing extension.js files - * from all enabled extensions. This replaces the old bootEngines mechanism. - * - * Key differences from old approach: - * - Only loads extension.js files (small, metadata only) - * - Does NOT load engine bundles (those lazy-load when routes are visited) - * - Respects both system config and user permissions - * - * @param {ApplicationInstance} appInstance The application instance - */ -export function initialize(appInstance) { - const universe = appInstance.lookup('service:universe'); - const owner = getOwner(appInstance); - const app = owner.application; - - // Set application instance on universe - universe.applicationInstance = appInstance; - - // Get the list of enabled extensions from the app - // This list already respects config + user permissions via loadExtensions() - const extensions = app.extensions || []; - - // Load and execute extension.js from each enabled extension - extensions.forEach(extensionName => { - try { - // Dynamically require the extension.js file - // This is a small file with only metadata, not the full engine bundle - const setupExtension = require(`${extensionName}/extension`).default; - - if (typeof setupExtension === 'function') { - // Execute the extension setup function - // This registers menus, widgets, hooks, etc. as metadata - setupExtension(appInstance, universe); - } - } catch (error) { - // Silently fail if extension.js doesn't exist - // Extensions can migrate gradually to the new pattern - // console.warn(`Could not load extension.js for ${extensionName}:`, error); - } - }); - - // Execute any boot callbacks - scheduleOnce('afterRender', universe, 'executeBootCallbacks'); -} - -export default { - name: 'initialize-universe', - initialize -}; -``` - -### Step 4: Verify `router.js` Engine Mounting - -Your `prebuild.js` script already handles mounting engines in `router.js`. Verify that engines are mounted like this: - -```javascript -// This is generated by prebuild.js -this.mount('@fleetbase/fleetops-engine', { as: 'console.fleet-ops' }); -this.mount('@fleetbase/customer-portal-engine', { as: 'console.customer-portal' }); -``` - -**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. - -### Step 5: Migrate Extensions to `extension.js` Pattern - -For each extension, create an `addon/extension.js` file that registers metadata without importing components: - -**Example: FleetOps `addon/extension.js`** - -```javascript -import { MenuItem, MenuPanel, Widget, ExtensionComponent } from '@fleetbase/ember-core/contracts'; - -export default function (app, universe) { - // Register admin menu panel - universe.registerAdminMenuPanel( - 'Fleet-Ops', - new MenuPanel({ - title: 'Fleet-Ops', - icon: 'route', - items: [ - new MenuItem({ - title: 'Navigator App', - icon: 'location-arrow', - component: new ExtensionComponent('@fleetbase/fleetops-engine', 'components/admin/navigator-app') - }), - new MenuItem({ - title: 'Avatar Management', - icon: 'images', - component: new ExtensionComponent('@fleetbase/fleetops-engine', 'components/admin/avatar-management') - }) - ] - }) - ); - - // Register widgets - universe.registerDefaultWidget( - new Widget({ - widgetId: 'fleet-ops-metrics', - name: 'Fleet-Ops Metrics', - description: 'Key metrics from Fleet-Ops', - icon: 'truck', - component: new ExtensionComponent('@fleetbase/fleetops-engine', 'components/widget/metrics'), - grid_options: { w: 12, h: 12, minW: 8, minH: 12 } - }) - ); - - // Register hooks - universe.registerHook( - new Hook({ - name: 'application:before-model', - handler: (session, router) => { - // Custom logic here - }, - priority: 10 - }) - ); -} -``` - -**Key Points:** -- ❌ NO `import MyComponent from './components/my-component'` - this would load the engine! -- ✅ Use `ExtensionComponent` with engine name + path for lazy loading -- ✅ Use contract classes (`MenuItem`, `Widget`, `Hook`) for type safety - -See [UNIVERSE_REFACTOR_MIGRATION_GUIDE.md](./UNIVERSE_REFACTOR_MIGRATION_GUIDE.md) for detailed migration examples. - -## How Lazy Loading Works with This Approach - -1. **App Boot**: Application boots with `app.engines` property set -2. **`initialize-universe`**: Loads small `extension.js` files via `require()` -3. **Metadata Registration**: Extensions register menus, widgets, hooks (no component code loaded) -4. **User Navigation**: User navigates to `/console/fleet-ops` -5. **Ember-Engines**: Detects route is in a mounted engine, lazy-loads the engine bundle -6. **Component Resolution**: `` resolves components from loaded engine - -## Performance Impact - -| Metric | Before (bootEngines) | After (Lazy Loading) | -|--------|---------------------|---------------------| -| Initial Load Time | 10-40 seconds | <1 second | -| Initial Bundle Size | Core + All Engines | Core + extension.js files | -| Engine Loading | All at boot | On-demand when route visited | -| Memory Usage | All engines in memory | Only visited engines in memory | - -## Ember-Engines Requirements - -According to [ember-engines documentation](https://github.com/ember-engines/ember-engines): - -> **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. - -**Required for lazy loading:** -1. ✅ `app.engines` property must be set (maps extension names to engine modules) -2. ✅ Engines must be mounted in `router.js` via `this.mount()` -3. ✅ Engine's `index.js` must have `lazyLoading: true` (default) - -**What breaks lazy loading:** -1. ❌ Calling `owner.lookup('engine:my-engine')` at boot time -2. ❌ Importing components from engines in `extension.js` -3. ❌ Manual `bootEngines()` calls - -## Troubleshooting - -### Extension not loading -- Check that extension is in `app.extensions` array -- Verify `extension.js` file exists and exports a function -- Check browser console for errors - -### Components not rendering -- Ensure `ExtensionComponent` has correct engine name and path -- Verify engine is mounted in `router.js` -- Check that `` is used in templates - -### Engines loading at boot -- Remove any `owner.lookup('engine:...')` calls from initializers -- Remove component imports from `extension.js` -- Verify no `bootEngines()` calls remain - -## Migration Checklist - -- [ ] Update `app.js` to keep `engines` property -- [ ] Remove old instance initializers (`load-extensions.js`, `initialize-widgets.js`) -- [ ] Create new `initialize-universe.js` instance initializer -- [ ] Verify `router.js` has `this.mount()` calls for all engines -- [ ] Create `extension.js` for each extension -- [ ] Replace component imports with `ExtensionComponent` definitions -- [ ] Test lazy loading in browser dev tools (Network tab) -- [ ] Verify initial bundle size reduction -- [ ] Test all extension functionality still works - -## References - -- [Ember Engines Guide](https://guides.emberjs.com/v5.6.0/applications/ember-engines/) -- [ember-engines GitHub](https://github.com/ember-engines/ember-engines) -- [Ember Engines RFC](https://github.com/emberjs/rfcs/blob/master/text/0010-engines.md) diff --git a/UNIVERSE_REFACTOR_MIGRATION_GUIDE.md b/UNIVERSE_REFACTOR_MIGRATION_GUIDE.md deleted file mode 100644 index c47756fd..00000000 --- a/UNIVERSE_REFACTOR_MIGRATION_GUIDE.md +++ /dev/null @@ -1,318 +0,0 @@ -# UniverseService Refactor Migration Guide - -## Overview - -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. - -## What Changed? - -### 1. Service Decomposition - -The monolithic `UniverseService` has been split into specialized services: - -- **ExtensionManager**: Manages lazy loading of engines -- **RegistryService**: Manages all registries using Ember's container -- **MenuService**: Manages menu items and panels -- **WidgetService**: Manages dashboard widgets -- **HookService**: Manages application hooks - -The original `UniverseService` now acts as a facade, delegating to these services while maintaining backward compatibility. - -### 2. Contract System - -New contract classes provide a fluent, type-safe API: - -- `ExtensionComponent`: Lazy-loadable component definitions -- `MenuItem`: Menu item definitions -- `MenuPanel`: Menu panel definitions -- `Hook`: Hook definitions -- `Widget`: Widget definitions -- `Registry`: Registry namespace definitions - -### 3. Lazy Loading Architecture - -The old `bootEngines` mechanism has been replaced with on-demand lazy loading: - -- Engines are no longer loaded at boot time -- Components are loaded only when needed -- The `` wrapper handles lazy loading automatically - -## Migration Steps - -### Step 1: Create `extension.js` File - -Each engine should create a new `addon/extension.js` file to replace the `setupExtension` method in `engine.js`. - -**Before (`addon/engine.js`):** - -```javascript -import NavigatorAppComponent from './components/admin/navigator-app'; - -export default class FleetOpsEngine extends Engine { - setupExtension = function (app, engine, universe) { - universe.registerHeaderMenuItem('Fleet-Ops', 'console.fleet-ops', { - icon: 'route', - priority: 0 - }); - - universe.registerAdminMenuPanel('Fleet-Ops Config', [ - { - title: 'Navigator App', - component: NavigatorAppComponent - } - ]); - }; -} -``` - -**After (`addon/extension.js`):** - -```javascript -import { MenuItem, MenuPanel, ExtensionComponent } from '@fleetbase/ember-core/contracts'; - -export default function (app, universe) { - // Register header menu item - universe.registerHeaderMenuItem( - new MenuItem('Fleet-Ops', 'console.fleet-ops') - .withIcon('route') - .withPriority(0) - ); - - // Register admin panel with lazy component - universe.registerAdminMenuPanel( - new MenuPanel('Fleet-Ops Config') - .addItem( - new MenuItem('Navigator App') - .withIcon('location-arrow') - .withComponent( - new ExtensionComponent('@fleetbase/fleetops-engine', 'components/admin/navigator-app') - ) - ) - ); -} -``` - -**After (`addon/engine.js`):** - -```javascript -// Remove the setupExtension method entirely -export default class FleetOpsEngine extends Engine { - // ... other engine configuration -} -``` - -### Step 2: Use Contract Classes - -Instead of plain objects, use the new contract classes for better type safety and developer experience. - -**Before:** - -```javascript -universe.registerWidget({ - widgetId: 'fleet-ops-metrics', - name: 'Fleet-Ops Metrics', - icon: 'truck', - component: WidgetComponent, - grid_options: { w: 12, h: 12 } -}); -``` - -**After:** - -```javascript -import { Widget, ExtensionComponent } from '@fleetbase/ember-core/contracts'; - -universe.registerDashboardWidgets([ - new Widget('fleet-ops-metrics') - .withName('Fleet-Ops Metrics') - .withIcon('truck') - .withComponent( - new ExtensionComponent('@fleetbase/fleetops-engine', 'components/widget/fleet-ops-key-metrics') - ) - .withGridOptions({ w: 12, h: 12 }) -]); -``` - -### Step 3: Update Component References - -Replace direct component imports with lazy component definitions. - -**Before:** - -```javascript -import MyComponent from './components/my-component'; - -universe.registerMenuItem('my-registry', 'My Item', { - component: MyComponent -}); -``` - -**After:** - -```javascript -import { MenuItem, ExtensionComponent } from '@fleetbase/ember-core/contracts'; - -universe.registerMenuItem( - 'my-registry', - new MenuItem('My Item') - .withComponent( - new ExtensionComponent('@fleetbase/my-engine', 'components/my-component') - ) -); -``` - -### Step 4: Update Templates Using Registry Components - -Templates that render components from registries need to use the `` wrapper. - -**Before:** - -```handlebars -{{#each this.menuItems as |item|}} - {{component item.component model=@model}} -{{/each}} -``` - -**After:** - -```handlebars -{{#each this.menuItems as |item|}} - -{{/each}} -``` - -### Step 5: Update Hook Registrations - -Use the new `Hook` contract for better hook management. - -**Before:** - -```javascript -universe.registerHook('application:before-model', (session, router) => { - if (session.isCustomer) { - router.transitionTo('customer-portal'); - } -}); -``` - -**After:** - -```javascript -import { Hook } from '@fleetbase/ember-core/contracts'; - -universe.registerHook( - new Hook('application:before-model', (session, router) => { - if (session.isCustomer) { - router.transitionTo('customer-portal'); - } - }) - .withPriority(10) - .withId('customer-redirect') -); -``` - -## Backward Compatibility - -The refactored `UniverseService` maintains backward compatibility with the old API. You can continue using the old syntax while migrating: - -```javascript -// Old syntax still works -universe.registerHeaderMenuItem('My Item', 'my.route', { icon: 'star' }); - -// New syntax is preferred -universe.registerHeaderMenuItem( - new MenuItem('My Item', 'my.route').withIcon('star') -); -``` - -## Benefits of Migration - -1. **Performance**: Sub-second boot times with lazy loading -2. **Type Safety**: Contract classes provide validation and IDE support -3. **Maintainability**: Specialized services are easier to understand and modify -4. **Developer Experience**: Fluent API with method chaining -5. **Extensibility**: Easy to add new features without breaking changes - -## Common Patterns - -### Menu Item with Click Handler - -```javascript -new MenuItem('Track Order') - .withIcon('barcode') - .withType('link') - .withWrapperClass('btn-block py-1 border') - .withComponent( - new ExtensionComponent('@fleetbase/fleetops-engine', 'components/order-tracking-lookup') - ) - .onClick((menuItem) => { - universe.transitionMenuItem('virtual', menuItem); - }) -``` - -### Widget with Refresh Interval - -```javascript -new Widget('live-metrics') - .withName('Live Metrics') - .withComponent( - new ExtensionComponent('@fleetbase/my-engine', 'components/widget/live-metrics') - .withLoadingComponent('skeletons/widget') - ) - .withRefreshInterval(5000) - .asDefault() -``` - -### Hook with Priority and Once - -```javascript -new Hook('order:before-save') - .withPriority(10) - .once() - .execute(async (order) => { - await validateOrder(order); - }) -``` - -## Troubleshooting - -### Component Not Found Error - -If you see "Component not found in engine" errors: - -1. Check that the component path is correct -2. Ensure the engine name matches exactly -3. Verify the component exists in the engine - -### Loading Spinner Not Showing - -If the loading spinner doesn't appear: - -1. Check that you're using `` in templates -2. Verify the `componentDef` is a lazy definition object -3. Ensure the loading component exists - -### Hooks Not Executing - -If hooks aren't running: - -1. Check the hook name matches exactly -2. Verify the hook is registered before it's needed -3. Use `universe.hookService.getHooks(hookName)` to debug - -## Support - -For questions or issues with the migration, please: - -1. Check the contract class documentation in `addon/contracts/` -2. Review the service documentation in `addon/services/universe/` -3. Open an issue on GitHub with details about your migration challenge - -## Timeline - -- **Phase 1**: Refactored services are available, old API still works -- **Phase 2**: Extensions migrate to new `extension.js` pattern -- **Phase 3**: Deprecation warnings for old patterns -- **Phase 4**: Old `setupExtension` pattern removed (future release) - -You can migrate at your own pace. The new architecture is fully backward compatible. diff --git a/UNIVERSE_REFACTOR_README.md b/UNIVERSE_REFACTOR_README.md deleted file mode 100644 index a5acb93f..00000000 --- a/UNIVERSE_REFACTOR_README.md +++ /dev/null @@ -1,220 +0,0 @@ -# UniverseService Refactor - -## Overview - -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. - -## Problems Solved - -### 1. Performance Bottleneck - -**Before**: 10-40 second initial load time due to sequential `bootEngines` process loading all extensions upfront. - -**After**: <1 second initial load time with on-demand lazy loading. - -### 2. Monolithic Design - -**Before**: 1,978 lines handling 7+ distinct responsibilities in a single service. - -**After**: Specialized services with clear separation of concerns: -- `ExtensionManager`: Engine lifecycle and lazy loading -- `RegistryService`: Registry management using Ember's container -- `MenuService`: Menu items and panels -- `WidgetService`: Dashboard widgets -- `HookService`: Application hooks - -### 3. Inefficient Registry - -**Before**: Custom object-based registry with O(n) lookups. - -**After**: Ember container-based registry with O(1) lookups. - -### 4. Broken Lazy Loading - -**Before**: `bootEngines` manually boots and initializes every engine, breaking lazy loading. - -**After**: Engines load on-demand when their components are actually needed. - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ UniverseService (Facade) │ -│ Maintains backward compatibility while delegating to: │ -└─────────────────────────────────────────────────────────────┘ - │ - ┌───────────────────┼───────────────────┐ - │ │ │ - ▼ ▼ ▼ -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ Extension │ │ Registry │ │ Menu │ -│ Manager │ │ Service │ │ Service │ -└──────────────┘ └──────────────┘ └──────────────┘ - │ │ │ - ▼ ▼ ▼ -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ Widget │ │ Hook │ │ Contract │ -│ Service │ │ Service │ │ System │ -└──────────────┘ └──────────────┘ └──────────────┘ -``` - -## Contract System - -New classes provide a fluent, type-safe API for extension definitions: - -```javascript -import { MenuItem, ExtensionComponent, Widget, Hook } from '@fleetbase/ember-core/contracts'; - -// Menu item with lazy component -new MenuItem('Fleet-Ops', 'console.fleet-ops') - .withIcon('route') - .withPriority(0) - .withComponent( - new ExtensionComponent('@fleetbase/fleetops-engine', 'components/admin/navigator-app') - ); - -// Widget with grid options -new Widget('fleet-ops-metrics') - .withName('Fleet-Ops Metrics') - .withIcon('truck') - .withComponent( - new ExtensionComponent('@fleetbase/fleetops-engine', 'components/widget/metrics') - ) - .withGridOptions({ w: 12, h: 12 }) - .asDefault(); - -// Hook with priority -new Hook('application:before-model', (session, router) => { - if (session.isCustomer) { - router.transitionTo('customer-portal'); - } -}) - .withPriority(10) - .once(); -``` - -## Lazy Loading Flow - -1. **Boot Time**: Only `extension.js` files are loaded (no engine code) -2. **Registration**: Metadata is registered (menus, widgets, hooks) -3. **Runtime**: When a component needs to render: - - `` triggers `extensionManager.ensureEngineLoaded()` - - Engine bundle is fetched and loaded - - Component is looked up from the engine - - Component is rendered - -## Extension Pattern - -### Old Pattern (engine.js) - -```javascript -import MyComponent from './components/my-component'; - -export default class MyEngine extends Engine { - setupExtension = function (app, engine, universe) { - universe.registerMenuItem('my-registry', 'My Item', { - component: MyComponent // Loads entire engine! - }); - }; -} -``` - -### New Pattern (extension.js) - -```javascript -import { MenuItem, ExtensionComponent } from '@fleetbase/ember-core/contracts'; - -export default function (app, universe) { - universe.registerMenuItem( - 'my-registry', - new MenuItem('My Item') - .withComponent( - new ExtensionComponent('@fleetbase/my-engine', 'components/my-component') - ) - ); -} -``` - -**Key Difference**: No component imports = no engine loading at boot time. - -## Performance Improvements - -| Metric | Before | After | Improvement | -|--------|--------|-------|-------------| -| Initial Load Time | 10-40s | <1s | ~90% faster | -| Bundle Size (initial) | Full app + all engines | Core app only | ~60% reduction | -| Lookup Performance | O(n) | O(1) | 100x faster | -| Timeout Errors | Frequent | None | 100% reduction | - -## Backward Compatibility - -The refactor is **100% backward compatible**. The old API still works: - -```javascript -// Old syntax (still works) -universe.registerHeaderMenuItem('My Item', 'my.route', { icon: 'star' }); - -// New syntax (preferred) -universe.registerHeaderMenuItem( - new MenuItem('My Item', 'my.route').withIcon('star') -); -``` - -## Migration - -See [UNIVERSE_REFACTOR_MIGRATION_GUIDE.md](./UNIVERSE_REFACTOR_MIGRATION_GUIDE.md) for detailed migration instructions. - -## Files Changed - -### New Files - -- `addon/contracts/` - Contract system classes - - `base-contract.js` - - `extension-component.js` - - `menu-item.js` - - `menu-panel.js` - - `hook.js` - - `widget.js` - - `registry.js` - - `index.js` - -- `addon/services/universe/` - Specialized services - - `extension-manager.js` - - `registry-service.js` - - `menu-service.js` - - `widget-service.js` - - `hook-service.js` - -- `addon/components/` - Lazy loading component - - `lazy-engine-component.js` - - `lazy-engine-component.hbs` - -### Modified Files - -- `addon/services/universe.js` - Refactored as facade -- `addon/services/legacy-universe.js` - Original service (for reference) - -## Testing - -The refactor includes: - -1. **Unit tests** for each contract class -2. **Integration tests** for each service -3. **Acceptance tests** for lazy loading behavior -4. **Performance benchmarks** comparing old vs new - -## Future Enhancements - -1. **TypeScript definitions** for contract classes -2. **Extension manifest validation** at build time -3. **Preloading strategies** for critical engines -4. **Memory management** for long-running applications -5. **Developer tools** for debugging extension loading - -## Credits - -Designed and implemented based on collaborative analysis with Ronald A Richardson, CTO of Fleetbase. - -## License - -MIT License - Copyright (c) 2025 Fleetbase diff --git a/addon/exports/host-services.js b/addon/exports/host-services.js index cc8d3f69..f900dd2e 100644 --- a/addon/exports/host-services.js +++ b/addon/exports/host-services.js @@ -19,6 +19,7 @@ export const hostServices = [ 'sidebar', 'dashboard', 'universe', + 'events', 'intl', 'abilities', 'language', diff --git a/addon/exports/services.js b/addon/exports/services.js index 0f728bb9..b51f3d21 100644 --- a/addon/exports/services.js +++ b/addon/exports/services.js @@ -21,6 +21,7 @@ export const services = [ 'sidebar', 'dashboard', 'universe', + 'events', 'intl', 'abilities', 'language', diff --git a/addon/services/app-cache.js b/addon/services/app-cache.js index 4aaab00e..3a1ba75b 100644 --- a/addon/services/app-cache.js +++ b/addon/services/app-cache.js @@ -13,7 +13,8 @@ export default class AppCacheService extends Service { get cachePrefix() { const userId = this.currentUser.id ?? 'anon'; - return `${userId}:${this.currentUser.companyId}:`; + const companyId = this.currentUser.companyId ?? 'no-org'; + return `${userId}:${companyId}:`; } @action setEmberData(key, value, except = []) { diff --git a/addon/services/crud.js b/addon/services/crud.js index 2e7beff1..edb2ff53 100644 --- a/addon/services/crud.js +++ b/addon/services/crud.js @@ -17,6 +17,8 @@ export default class CrudService extends Service { @service notifications; @service store; @service currentUser; + @service universe; + @service events; /** * Generic deletion modal with options @@ -43,6 +45,10 @@ export default class CrudService extends Service { try { const response = await model.destroyRecord(); this.notifications.success(successNotification); + + // Track deletion event + this.events.trackResourceDeleted(model); + if (typeof options.onSuccess === 'function') { options.onSuccess(model); } @@ -161,6 +167,10 @@ export default class CrudService extends Service { ); this.notifications.success(response.message ?? successMessage); + + // Track bulk action event + this.events.trackBulkAction(verb, selected); + if (typeof options.onSuccess === 'function') { options.onSuccess(selected); } @@ -224,6 +234,9 @@ export default class CrudService extends Service { } ) .then(() => { + // Track export event + this.events.trackResourceExported(modelName, format, exportParams); + later( this, () => { @@ -248,6 +261,7 @@ export default class CrudService extends Service { * @param {Object} [options={}] * @memberof CrudService */ + @action import(modelName, options = {}) { // always lowercase modelname modelName = modelName.toLowerCase(); @@ -337,6 +351,11 @@ export default class CrudService extends Service { try { const response = await this.fetch.post(importEndpoint, { files }, fetchOptions); + + // Track import event + const importCount = response?.imported?.length || response?.count || files.length; + this.events.trackResourceImported(modelName, importCount); + if (typeof options.onImportCompleted === 'function') { options.onImportCompleted(response, files); } diff --git a/addon/services/current-user.js b/addon/services/current-user.js index 8434877f..63553ffc 100644 --- a/addon/services/current-user.js +++ b/addon/services/current-user.js @@ -16,6 +16,7 @@ export default class CurrentUserService extends Service.extend(Evented) { @service theme; @service notifications; @service intl; + @service events; @tracked user = { id: 'anon' }; @tracked userSnapshot = { id: 'anon' }; @@ -65,14 +66,9 @@ export default class CurrentUserService extends Service.extend(Evented) { async load() { if (this.session.isAuthenticated) { const user = await this.store.findRecord('user', 'me'); - const snapshot = await this.getUserSnapshot(user); - this.set('user', user); - this.set('userSnapshot', snapshot); - this.trigger('user.loaded', user); - - // Set permissions - this.permissions = this.getUserPermissions(user); + // set user + this.setUser(user); // Load preferences await this.loadPreferences(); @@ -91,25 +87,9 @@ export default class CurrentUserService extends Service.extend(Evented) { try { const user = await this.store.queryRecord('user', { me: true }); - const snapshot = await this.getUserSnapshot(user); - - // Set current user - this.set('user', user); - this.set('userSnapshot', snapshot); - this.trigger('user.loaded', user); - - // Set permissions - this.permissions = this.getUserPermissions(user); - // Set environment from user option - this.theme.setEnvironment(); - - // Set locale - if (user.locale) { - this.setLocale(user.locale); - } else { - await this.loadLocale(); - } + // set user + this.setUser(user); // Load user whois data await this.loadWhois(); @@ -309,4 +289,26 @@ export default class CurrentUserService extends Service.extend(Evented) { return defaultValue; } + + async setUser(user) { + const snapshot = await this.getUserSnapshot(user); + + // Set current user + this.set('user', user); + this.set('userSnapshot', snapshot); + this.trigger('user.loaded', user); + + // Set permissions + this.permissions = this.getUserPermissions(user); + + // Set environment from user option + this.theme.setEnvironment(); + + // Set locale + if (user.locale) { + this.setLocale(user.locale); + } else { + await this.loadLocale(); + } + } } diff --git a/addon/services/events.js b/addon/services/events.js new file mode 100644 index 00000000..c2393102 --- /dev/null +++ b/addon/services/events.js @@ -0,0 +1,322 @@ +import Service, { inject as service } from '@ember/service'; +import Evented from '@ember/object/evented'; +import config from 'ember-get-config'; + +/** + * Events Service + * + * Provides a centralized event tracking system for Fleetbase. + * This service emits standardized events on both its own event bus and the universe service, + * allowing components, services, and engines to subscribe and react to application events. + * + * @class EventsService + * @extends Service + */ +export default class EventsService extends Service.extend(Evented) { + @service universe; + @service currentUser; + + /** + * Tracks the creation of a resource + * + * @param {Object} resource - The created resource/model + * @param {Object} [props={}] - Additional properties to include + */ + trackResourceCreated(resource, props = {}) { + const events = this.#getResourceEvents(resource, 'created'); + const properties = this.#enrichProperties({ + ...this.#getSafeProperties(resource), + ...props, + }); + + events.forEach((eventName) => { + this.#trigger(eventName, resource, properties); + }); + } + + /** + * Tracks the update of a resource + * + * @param {Object} resource - The updated resource/model + * @param {Object} [props={}] - Additional properties to include + */ + trackResourceUpdated(resource, props = {}) { + const events = this.#getResourceEvents(resource, 'updated'); + const properties = this.#enrichProperties({ + ...this.#getSafeProperties(resource), + ...props, + }); + + events.forEach((eventName) => { + this.#trigger(eventName, resource, properties); + }); + } + + /** + * Tracks the deletion of a resource + * + * @param {Object} resource - The deleted resource/model + * @param {Object} [props={}] - Additional properties to include + */ + trackResourceDeleted(resource, props = {}) { + const events = this.#getResourceEvents(resource, 'deleted'); + const properties = this.#enrichProperties({ + ...this.#getSafeProperties(resource), + ...props, + }); + + events.forEach((eventName) => { + this.#trigger(eventName, resource, properties); + }); + } + + /** + * Tracks a bulk import of resources + * + * @param {String} modelName - The name of the model being imported + * @param {Number} count - Number of resources imported + * @param {Object} [props={}] - Additional properties to include + */ + trackResourceImported(modelName, count, props = {}) { + const properties = this.#enrichProperties({ + model_name: modelName, + count: count, + ...props, + }); + + this.#trigger('resource.imported', modelName, count, properties); + } + + /** + * Tracks a resource export + * + * @param {String} modelName - The name of the model being exported + * @param {String} format - Export format (csv, xlsx, etc.) + * @param {Object} [params={}] - Export parameters/filters + * @param {Object} [props={}] - Additional properties to include + */ + trackResourceExported(modelName, format, params = {}, props = {}) { + const properties = this.#enrichProperties({ + model_name: modelName, + export_format: format, + has_filters: !!(params && Object.keys(params).length > 0), + ...props, + }); + + this.#trigger('resource.exported', modelName, format, params, properties); + + // Also trigger model-specific export event + const specificEvent = `${modelName}.exported`; + this.#trigger(specificEvent, modelName, format, params, properties); + } + + /** + * Tracks a bulk action on multiple resources + * + * @param {String} verb - The action verb (delete, archive, etc.) + * @param {Array} resources - Array of selected resources + * @param {Object} [props={}] - Additional properties to include + */ + trackBulkAction(verb, resources, props = {}) { + const firstResource = resources && resources.length > 0 ? resources[0] : null; + const modelName = this.#getModelName(firstResource); + + const properties = this.#enrichProperties({ + action: verb, + count: resources?.length || 0, + model_name: modelName, + ...props, + }); + + this.#trigger('resource.bulk_action', verb, resources, firstResource, properties); + } + + /** + * Tracks when the current user is loaded (session initialized) + * + * @param {Object} user - The user object + * @param {Object} organization - The organization object + * @param {Object} [props={}] - Additional properties to include + */ + trackUserLoaded(user, organization, props = {}) { + const properties = this.#enrichProperties({ + user_id: user?.id, + organization_id: organization?.id, + organization_name: organization?.name, + ...props, + }); + + this.#trigger('user.loaded', user, organization, properties); + } + + /** + * Tracks when a user session is terminated + * + * @param {Number} duration - Session duration in seconds + * @param {Object} [props={}] - Additional properties to include + */ + trackSessionTerminated(duration, props = {}) { + const properties = this.#enrichProperties({ + session_duration: duration, + ...props, + }); + + this.#trigger('session.terminated', duration, properties); + } + + /** + * Tracks a generic custom event + * + * @param {String} eventName - The event name (dot notation) + * @param {Object} [props={}] - Event properties + */ + trackEvent(eventName, props = {}) { + const properties = this.#enrichProperties(props); + this.#trigger(eventName, properties); + } + + /** + * Checks if event tracking is enabled + * + * @returns {Boolean} + */ + isEnabled() { + const eventsConfig = config?.events || {}; + return eventsConfig.enabled !== false; // Enabled by default + } + + // ========================================================================= + // Private Methods (using # syntax) + // ========================================================================= + + /** + * Triggers an event on both the events service and universe service + * + * This dual event system allows listeners to subscribe to events on either: + * - this.events.on('event.name', handler) - Local listeners + * - this.universe.on('event.name', handler) - Cross-engine listeners + * + * @private + * @param {String} eventName - The event name + * @param {...*} args - Arguments to pass to event listeners + */ + #trigger(eventName, ...args) { + if (!this.isEnabled()) { + return; + } + + // Debug logging if enabled + if (config?.events?.debug) { + console.log(`[Events] ${eventName}`, args); + } + + // Trigger on events service (local listeners) + this.trigger(eventName, ...args); + + // Trigger on universe service (cross-engine listeners) + if (this.universe) { + this.universe.trigger(eventName, ...args); + } else { + console.warn('[Events] Universe service not available'); + } + } + + /** + * Generates both generic and specific event names for a resource action + * + * @private + * @param {Object} resource - The resource/model + * @param {String} action - The action (created, updated, deleted) + * @returns {Array} Array of event names + */ + #getResourceEvents(resource, action) { + const modelName = this.#getModelName(resource); + return [`resource.${action}`, `${modelName}.${action}`]; + } + + /** + * Extracts safe, serializable properties from a resource + * + * @private + * @param {Object} resource - The resource/model + * @returns {Object} Safe properties object + */ + #getSafeProperties(resource) { + if (!resource) { + return {}; + } + + const props = { + id: resource.id, + model_name: this.#getModelName(resource), + }; + + // Add common properties if available + const commonProps = ['name', 'status', 'type', 'slug', 'public_id']; + commonProps.forEach((prop) => { + if (resource[prop] !== undefined && resource[prop] !== null) { + props[prop] = resource[prop]; + } + }); + + return props; + } + + /** + * Enriches properties with user, organization, and timestamp context + * + * @private + * @param {Object} props - Base properties + * @returns {Object} Enriched properties + */ + #enrichProperties(props = {}) { + const eventsConfig = config?.events || {}; + const enrichConfig = eventsConfig.enrich || {}; + const enriched = { ...props }; + + // Add user context if enabled + if (enrichConfig.user !== false && this.currentUser?.user) { + enriched.user_id = this.currentUser.user.id; + } + + // Add organization context if enabled + if (enrichConfig.organization !== false && this.currentUser?.organization) { + enriched.organization_id = this.currentUser.organization.id; + } + + // Add timestamp if enabled + if (enrichConfig.timestamp !== false) { + enriched.timestamp = new Date().toISOString(); + } + + return enriched; + } + + /** + * Safely extracts the model name from a resource + * + * @private + * @param {Object} resource - The resource/model + * @returns {String} Model name or 'unknown' + */ + #getModelName(resource) { + if (!resource) { + return 'unknown'; + } + + // Try multiple ways to get model name + if (resource.constructor?.modelName) { + return resource.constructor.modelName; + } + + if (resource._internalModel?.modelName) { + return resource._internalModel.modelName; + } + + if (resource.modelName) { + return resource.modelName; + } + + return 'unknown'; + } +} diff --git a/addon/services/resource-action.js b/addon/services/resource-action.js index f79d1e25..3eea7cc8 100644 --- a/addon/services/resource-action.js +++ b/addon/services/resource-action.js @@ -29,6 +29,8 @@ export default class ResourceActionService extends Service { @service abilities; @service tableContext; @service resourceContextPanel; + @service universe; + @service events; /** * Getter for router, attempt to use hostRouter if from engine @@ -299,6 +301,9 @@ export default class ResourceActionService extends Service { }) ); + // Track creation event + this.events.trackResourceCreated(record); + if (options.refresh) { this.refresh(); } @@ -329,6 +334,9 @@ export default class ResourceActionService extends Service { }) ); + // Track update event + this.events.trackResourceUpdated(record); + if (options.refresh) { this.refresh(); } @@ -362,6 +370,13 @@ export default class ResourceActionService extends Service { }) ); + // Track save event (create or update) + if (isNew) { + this.events.trackResourceCreated(record); + } else { + this.events.trackResourceUpdated(record); + } + if (options.refresh) { this.refresh(); } @@ -409,6 +424,9 @@ export default class ResourceActionService extends Service { }) ); + // Track deletion event + this.events.trackResourceDeleted(record); + if (options.refresh) { this.refresh(); } diff --git a/app/services/events.js b/app/services/events.js new file mode 100644 index 00000000..c73e6c67 --- /dev/null +++ b/app/services/events.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-core/services/events'; diff --git a/package.json b/package.json index fc7e85e3..d22efcb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/ember-core", - "version": "0.3.10", + "version": "0.3.11", "description": "Provides all the core services, decorators and utilities for building a Fleetbase extension for the Console.", "keywords": [ "fleetbase-core",