From effeba5d22b2d8c84fc71a4e0a8609fe542e554f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 5 Jun 2026 16:48:38 +0800 Subject: [PATCH 1/5] Overhaul connectivity telematics workflow --- addon/components/telematic/details.hbs | 42 +++- addon/components/telematic/details.js | 14 +- addon/components/telematic/form.hbs | 13 +- .../connectivity/telematics/index/details.js | 55 +++++ .../telematics/index/details/devices.js | 47 +++- .../telematics/index/details/events.js | 47 +++- .../telematics/index/details/sensors.js | 46 +++- .../telematics/index/details/devices.js | 13 +- .../telematics/index/details/events.js | 13 +- .../telematics/index/details/sensors.js | 13 +- .../telematics/index/details/devices.hbs | 8 +- .../telematics/index/details/events.hbs | 8 +- .../telematics/index/details/sensors.hbs | 8 +- .../Internal/v1/TelematicController.php | 31 +-- .../TelematicWebhookController.php | 52 ++++- server/src/Jobs/SyncTelematicDevicesJob.php | 28 ++- .../src/Jobs/TestTelematicConnectionJob.php | 30 +-- server/src/Models/Device.php | 2 +- server/src/Models/DeviceEvent.php | 11 + server/src/Models/Sensor.php | 2 +- .../Telematics/Providers/AbstractProvider.php | 30 ++- .../Telematics/Providers/AfaqyProvider.php | 1 + .../Telematics/Providers/FlespiProvider.php | 1 + .../Telematics/Providers/GeotabProvider.php | 1 + .../Telematics/Providers/SafeeProvider.php | 1 + .../Telematics/Providers/SamsaraProvider.php | 4 +- .../Support/Telematics/TelematicService.php | 206 ++++++++++++++++-- server/src/routes.php | 6 +- 28 files changed, 642 insertions(+), 91 deletions(-) diff --git a/addon/components/telematic/details.hbs b/addon/components/telematic/details.hbs index ff34f340d..a0154fa17 100644 --- a/addon/components/telematic/details.hbs +++ b/addon/components/telematic/details.hbs @@ -23,10 +23,10 @@
{{n-a @resource.name}}
- {{#if @resource.provider_descriptor.supportsWebhooks}} + {{#if @resource.provider_descriptor.supports_webhooks}} - - + + Configure this URL in your @@ -38,6 +38,40 @@ + +
+
+
Last Connection Test
+
{{n-a @resource.meta.last_connection_test}}
+
+ +
+
Last Test Result
+
{{n-a @resource.meta.last_test_result}}
+
+ +
+
Last Error
+
{{n-a @resource.meta.last_error}}
+
+ +
+
Last Sync Started
+
{{n-a @resource.meta.last_sync_started_at}}
+
+ +
+
Last Sync Completed
+
{{n-a @resource.meta.last_sync_completed_at}}
+
+ +
+
Devices Synced
+
{{n-a @resource.meta.last_sync_total}}
+
+
+
+
@@ -106,4 +140,4 @@ -
\ No newline at end of file +
diff --git a/addon/components/telematic/details.js b/addon/components/telematic/details.js index 2f6ac1461..d6e042d46 100644 --- a/addon/components/telematic/details.js +++ b/addon/components/telematic/details.js @@ -1,3 +1,15 @@ import Component from '@glimmer/component'; -export default class TelematicDetailsComponent extends Component {} +export default class TelematicDetailsComponent extends Component { + get webhookUrl() { + const url = this.args.resource?.provider_descriptor?.webhook_url; + const id = this.args.resource?.public_id ?? this.args.resource?.id; + + if (!url || !id) { + return url; + } + + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}telematic=${id}`; + } +} diff --git a/addon/components/telematic/form.hbs b/addon/components/telematic/form.hbs index ef2744f93..a91e8f3f8 100644 --- a/addon/components/telematic/form.hbs +++ b/addon/components/telematic/form.hbs @@ -36,12 +36,21 @@ @name={{field.label}} @value={{get @resource.credentials field.name}} @required={{field.required}} - @type={{if (eq field.type "password") "text" field.type}} + @type={{field.type}} @helpText={{field.help_text}} {{on "input" (fn this.setCredential field)}} /> {{/each}} + + {{#if this.connectionTestResult}} +
+ + {{if this.connectionTestResult.success "Connection successful" "Connection failed"}} + + {{this.connectionTestResult.message}} +
+ {{/if}}
@@ -84,4 +93,4 @@ - \ No newline at end of file + diff --git a/addon/controllers/connectivity/telematics/index/details.js b/addon/controllers/connectivity/telematics/index/details.js index f50905663..f83dfdc7e 100644 --- a/addon/controllers/connectivity/telematics/index/details.js +++ b/addon/controllers/connectivity/telematics/index/details.js @@ -1,8 +1,11 @@ import Controller from '@ember/controller'; import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; export default class ConnectivityTelematicsIndexDetailsController extends Controller { @service hostRouter; + @service fetch; + @service notifications; get tabs() { return [ @@ -10,15 +13,67 @@ export default class ConnectivityTelematicsIndexDetailsController extends Contro route: 'connectivity.telematics.index.details.index', label: 'Overview', }, + { + route: 'connectivity.telematics.index.details.devices', + label: 'Devices', + }, + { + route: 'connectivity.telematics.index.details.sensors', + label: 'Sensors', + }, + { + route: 'connectivity.telematics.index.details.events', + label: 'Events', + }, ]; } get actionButtons() { return [ + { + icon: 'plug', + text: 'Test', + onClick: () => this.testConnection.perform(), + isLoading: this.testConnection.isRunning, + }, + { + icon: 'satellite-dish', + text: 'Discover', + onClick: () => this.discoverDevices.perform(), + isLoading: this.discoverDevices.isRunning, + }, { icon: 'pencil', fn: () => this.hostRouter.transitionTo('console.fleet-ops.connectivity.telematics.index.edit', this.model), }, ]; } + + get telematicId() { + return this.model?.id ?? this.model?.public_id ?? this.model?.uuid; + } + + @task *testConnection() { + try { + const result = yield this.fetch.post(`telematics/${this.telematicId}/test-connection`); + if (result.success) { + this.notifications.success(result.message ?? 'Connection successful.'); + } else { + this.notifications.error(result.message ?? 'Connection test failed.'); + } + yield this.hostRouter.refresh(); + } catch (error) { + this.notifications.serverError(error); + } + } + + @task *discoverDevices() { + try { + const result = yield this.fetch.post(`telematics/${this.telematicId}/discover`); + this.notifications.success(result.message ?? 'Device discovery initiated.'); + yield this.hostRouter.refresh(); + } catch (error) { + this.notifications.serverError(error); + } + } } diff --git a/addon/controllers/connectivity/telematics/index/details/devices.js b/addon/controllers/connectivity/telematics/index/details/devices.js index 4b4acf0e8..c0f599291 100644 --- a/addon/controllers/connectivity/telematics/index/details/devices.js +++ b/addon/controllers/connectivity/telematics/index/details/devices.js @@ -1,3 +1,48 @@ import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; -export default class ConnectivityTelematicsIndexDetailsDevicesController extends Controller {} +export default class ConnectivityTelematicsIndexDetailsDevicesController extends Controller { + @service deviceActions; + @service intl; + + get columns() { + return [ + { + sticky: true, + label: this.intl.t('column.name'), + valuePath: 'displayName', + cellComponent: 'table/cell/anchor', + action: this.deviceActions.transition.view, + permission: 'fleet-ops view device', + resizable: true, + sortable: true, + }, + { + label: 'Device ID', + valuePath: 'device_id', + resizable: true, + sortable: true, + }, + { + label: 'Provider', + valuePath: 'provider', + cellClassNames: 'uppercase', + resizable: true, + sortable: true, + }, + { + label: this.intl.t('column.status'), + valuePath: 'status', + cellComponent: 'table/cell/status', + resizable: true, + sortable: true, + }, + { + label: 'Last Seen', + valuePath: 'last_online_at', + resizable: true, + sortable: true, + }, + ]; + } +} diff --git a/addon/controllers/connectivity/telematics/index/details/events.js b/addon/controllers/connectivity/telematics/index/details/events.js index daded247e..e6d61bdde 100644 --- a/addon/controllers/connectivity/telematics/index/details/events.js +++ b/addon/controllers/connectivity/telematics/index/details/events.js @@ -1,3 +1,48 @@ import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; -export default class ConnectivityTelematicsIndexDetailsEventsController extends Controller {} +export default class ConnectivityTelematicsIndexDetailsEventsController extends Controller { + @service deviceEventActions; + @service intl; + + get columns() { + return [ + { + sticky: true, + label: 'Event', + valuePath: 'event_type', + cellComponent: 'table/cell/anchor', + action: this.deviceEventActions.transition.view, + permission: 'fleet-ops view device-event', + resizable: true, + sortable: true, + }, + { + label: 'Device', + valuePath: 'device.displayName', + resizable: true, + sortable: true, + }, + { + label: 'Provider', + valuePath: 'provider', + cellClassNames: 'uppercase', + resizable: true, + sortable: true, + }, + { + label: 'Severity', + valuePath: 'severity', + resizable: true, + sortable: true, + }, + { + label: this.intl.t('column.created-at'), + valuePath: 'createdAt', + sortParam: 'created_at', + resizable: true, + sortable: true, + }, + ]; + } +} diff --git a/addon/controllers/connectivity/telematics/index/details/sensors.js b/addon/controllers/connectivity/telematics/index/details/sensors.js index 3c445a307..94945a868 100644 --- a/addon/controllers/connectivity/telematics/index/details/sensors.js +++ b/addon/controllers/connectivity/telematics/index/details/sensors.js @@ -1,3 +1,47 @@ import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; -export default class ConnectivityTelematicsIndexDetailsSensorsController extends Controller {} +export default class ConnectivityTelematicsIndexDetailsSensorsController extends Controller { + @service sensorActions; + @service intl; + + get columns() { + return [ + { + sticky: true, + label: this.intl.t('column.name'), + valuePath: 'name', + cellComponent: 'table/cell/anchor', + action: this.sensorActions.transition.view, + permission: 'fleet-ops view sensor', + resizable: true, + sortable: true, + }, + { + label: 'Type', + valuePath: 'type', + resizable: true, + sortable: true, + }, + { + label: 'Value', + valuePath: 'last_value', + resizable: true, + sortable: true, + }, + { + label: 'Unit', + valuePath: 'unit', + resizable: true, + sortable: true, + }, + { + label: this.intl.t('column.status'), + valuePath: 'status', + cellComponent: 'table/cell/status', + resizable: true, + sortable: true, + }, + ]; + } +} diff --git a/addon/routes/connectivity/telematics/index/details/devices.js b/addon/routes/connectivity/telematics/index/details/devices.js index 3f31b5922..c3c355f0c 100644 --- a/addon/routes/connectivity/telematics/index/details/devices.js +++ b/addon/routes/connectivity/telematics/index/details/devices.js @@ -1,3 +1,14 @@ import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; -export default class ConnectivityTelematicsIndexDetailsDevicesRoute extends Route {} +export default class ConnectivityTelematicsIndexDetailsDevicesRoute extends Route { + @service store; + + model() { + const telematic = this.modelFor('connectivity.telematics.index.details'); + return this.store.query('device', { + telematic_uuid: telematic.uuid ?? telematic.id, + sort: '-updated_at', + }); + } +} diff --git a/addon/routes/connectivity/telematics/index/details/events.js b/addon/routes/connectivity/telematics/index/details/events.js index 898e3629e..28d656552 100644 --- a/addon/routes/connectivity/telematics/index/details/events.js +++ b/addon/routes/connectivity/telematics/index/details/events.js @@ -1,3 +1,14 @@ import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; -export default class ConnectivityTelematicsIndexDetailsEventsRoute extends Route {} +export default class ConnectivityTelematicsIndexDetailsEventsRoute extends Route { + @service store; + + model() { + const telematic = this.modelFor('connectivity.telematics.index.details'); + return this.store.query('device-event', { + telematic: telematic.uuid ?? telematic.id, + sort: '-created_at', + }); + } +} diff --git a/addon/routes/connectivity/telematics/index/details/sensors.js b/addon/routes/connectivity/telematics/index/details/sensors.js index 99ca3d50b..95fc51ec6 100644 --- a/addon/routes/connectivity/telematics/index/details/sensors.js +++ b/addon/routes/connectivity/telematics/index/details/sensors.js @@ -1,3 +1,14 @@ import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; -export default class ConnectivityTelematicsIndexDetailsSensorsRoute extends Route {} +export default class ConnectivityTelematicsIndexDetailsSensorsRoute extends Route { + @service store; + + model() { + const telematic = this.modelFor('connectivity.telematics.index.details'); + return this.store.query('sensor', { + telematic_uuid: telematic.uuid ?? telematic.id, + sort: '-updated_at', + }); + } +} diff --git a/addon/templates/connectivity/telematics/index/details/devices.hbs b/addon/templates/connectivity/telematics/index/details/devices.hbs index e2147cab0..617130111 100644 --- a/addon/templates/connectivity/telematics/index/details/devices.hbs +++ b/addon/templates/connectivity/telematics/index/details/devices.hbs @@ -1 +1,7 @@ -{{outlet}} \ No newline at end of file + diff --git a/addon/templates/connectivity/telematics/index/details/events.hbs b/addon/templates/connectivity/telematics/index/details/events.hbs index e2147cab0..be3e081a8 100644 --- a/addon/templates/connectivity/telematics/index/details/events.hbs +++ b/addon/templates/connectivity/telematics/index/details/events.hbs @@ -1 +1,7 @@ -{{outlet}} \ No newline at end of file + diff --git a/addon/templates/connectivity/telematics/index/details/sensors.hbs b/addon/templates/connectivity/telematics/index/details/sensors.hbs index e2147cab0..61b28f8b6 100644 --- a/addon/templates/connectivity/telematics/index/details/sensors.hbs +++ b/addon/templates/connectivity/telematics/index/details/sensors.hbs @@ -1 +1,7 @@ -{{outlet}} \ No newline at end of file + diff --git a/server/src/Http/Controllers/Internal/v1/TelematicController.php b/server/src/Http/Controllers/Internal/v1/TelematicController.php index caa382eb8..6b9331eba 100644 --- a/server/src/Http/Controllers/Internal/v1/TelematicController.php +++ b/server/src/Http/Controllers/Internal/v1/TelematicController.php @@ -54,9 +54,7 @@ public function providers(): JsonResponse */ public function testConnection(Request $request, string $id): JsonResponse { - $telematic = Telematic::where('uuid', $id) - ->where('company_uuid', session('company')) - ->firstOrFail(); + $telematic = $this->findTelematic($id); $async = $request->input('async', false); @@ -99,9 +97,7 @@ public function testCredentials(Request $request, string $key): JsonResponse */ public function discover(Request $request, string $id): JsonResponse { - $telematic = Telematic::where('uuid', $id) - ->where('company_uuid', session('company')) - ->firstOrFail(); + $telematic = $this->findTelematic($id); $jobId = $this->telematicService->discoverDevices($telematic, [ 'limit' => $request->input('limit', 100), @@ -119,9 +115,7 @@ public function discover(Request $request, string $id): JsonResponse */ public function devices(Request $request, string $id): JsonResponse { - $telematic = Telematic::where('uuid', $id) - ->where('company_uuid', session('company')) - ->firstOrFail(); + $telematic = $this->findTelematic($id); $devices = $this->telematicService->getDevices($telematic, [ 'status' => $request->input('status'), @@ -138,13 +132,13 @@ public function devices(Request $request, string $id): JsonResponse */ public function linkDevice(Request $request, string $id): JsonResponse { - $telematic = Telematic::where('uuid', $id) - ->where('company_uuid', session('company')) - ->firstOrFail(); + $telematic = $this->findTelematic($id); $request->validate([ - 'external_id' => 'required|string', - 'device_name' => 'required|string', + 'external_id' => 'required_without:device_id|nullable|string', + 'device_id' => 'required_without:external_id|nullable|string', + 'device_name' => 'nullable|string', + 'name' => 'nullable|string', ]); $device = $this->telematicService->linkDevice($telematic, $request->all()); @@ -153,4 +147,13 @@ public function linkDevice(Request $request, string $id): JsonResponse 'device' => $device, ], 201); } + + protected function findTelematic(string $id): Telematic + { + return Telematic::where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->where('company_uuid', session('company')) + ->firstOrFail(); + } } diff --git a/server/src/Http/Controllers/TelematicWebhookController.php b/server/src/Http/Controllers/TelematicWebhookController.php index da8ee4019..602bf26e4 100644 --- a/server/src/Http/Controllers/TelematicWebhookController.php +++ b/server/src/Http/Controllers/TelematicWebhookController.php @@ -60,8 +60,16 @@ public function handle(Request $request, string $providerKey): JsonResponse // Get provider $provider = $this->registry->resolve($providerKey); - // Find telematic for this provider - $telematic = Telematic::where('provider', $providerKey)->first(); + // Find telematic for this provider. Provider webhooks can include an + // integration id in the URL query or headers to disambiguate tenants. + $telematicId = $request->query('telematic') ?? $request->query('integration') ?? $request->header('X-Fleetbase-Telematic'); + $telematic = Telematic::where('provider', $providerKey) + ->when($telematicId, function ($query) use ($telematicId) { + $query->where(function ($query) use ($telematicId) { + $query->where('uuid', $telematicId)->orWhere('public_id', $telematicId); + }); + }) + ->first(); if (!$telematic) { Log::warning('No telematic found for provider', [ @@ -89,13 +97,26 @@ public function handle(Request $request, string $providerKey): JsonResponse try { $result = $provider->processWebhook($request->all(), $request->headers->all()); + $devicesByExternalId = []; + // Link devices foreach ($result['devices'] as $deviceData) { - $this->service->linkDevice($telematic, $deviceData); + $device = $this->service->linkDevice($telematic, $deviceData); + $externalId = $deviceData['external_id'] ?? $deviceData['device_id'] ?? null; + if ($externalId) { + $devicesByExternalId[$externalId] = $device; + } } - // Store events (TODO: implement event storage) - // Store sensors (TODO: implement sensor storage) + foreach ($result['events'] as $eventData) { + $externalId = $eventData['device_id'] ?? $eventData['external_id'] ?? $eventData['ident'] ?? null; + $this->service->storeDeviceEvent($telematic, $eventData, $externalId ? ($devicesByExternalId[$externalId] ?? null) : null); + } + + foreach ($result['sensors'] as $sensorData) { + $externalId = $sensorData['device_id'] ?? $sensorData['external_id'] ?? $sensorData['ident'] ?? null; + $this->service->storeSensor($telematic, $sensorData, $externalId ? ($devicesByExternalId[$externalId] ?? null) : null); + } // Mark as processed if ($idempotencyKey) { @@ -106,6 +127,7 @@ public function handle(Request $request, string $providerKey): JsonResponse 'correlation_id' => $correlationId, 'devices_count' => count($result['devices']), 'events_count' => count($result['events']), + 'sensors_count' => count($result['sensors']), ]); return response()->json(['status' => 'processed'], 200); @@ -139,13 +161,29 @@ public function ingest(Request $request, string $id): JsonResponse } try { + $devicesByExternalId = []; + // Process devices if ($request->has('devices')) { foreach ($request->input('devices') as $deviceData) { - $this->service->linkDevice($telematic, $deviceData); + $device = $this->service->linkDevice($telematic, $deviceData); + $externalId = $deviceData['external_id'] ?? $deviceData['device_id'] ?? null; + if ($externalId) { + $devicesByExternalId[$externalId] = $device; + } } } + foreach ($request->input('events', []) as $eventData) { + $externalId = $eventData['device_id'] ?? $eventData['external_id'] ?? $eventData['ident'] ?? null; + $this->service->storeDeviceEvent($telematic, $eventData, $externalId ? ($devicesByExternalId[$externalId] ?? null) : null); + } + + foreach ($request->input('sensors', []) as $sensorData) { + $externalId = $sensorData['device_id'] ?? $sensorData['external_id'] ?? $sensorData['ident'] ?? null; + $this->service->storeSensor($telematic, $sensorData, $externalId ? ($devicesByExternalId[$externalId] ?? null) : null); + } + // Mark as processed if ($idempotencyKey) { $this->idempotency->markProcessed($idempotencyKey); @@ -154,6 +192,8 @@ public function ingest(Request $request, string $id): JsonResponse Log::info('Custom ingest processed', [ 'correlation_id' => $correlationId, 'devices_count' => count($request->input('devices', [])), + 'events_count' => count($request->input('events', [])), + 'sensors_count' => count($request->input('sensors', [])), ]); return response()->json(['status' => 'ingested'], 200); diff --git a/server/src/Jobs/SyncTelematicDevicesJob.php b/server/src/Jobs/SyncTelematicDevicesJob.php index ce13f3963..a08d39eed 100644 --- a/server/src/Jobs/SyncTelematicDevicesJob.php +++ b/server/src/Jobs/SyncTelematicDevicesJob.php @@ -3,6 +3,7 @@ namespace Fleetbase\FleetOps\Jobs; use Fleetbase\FleetOps\Models\Telematic; +use Fleetbase\FleetOps\Support\Telematics\TelematicProviderRegistry; use Fleetbase\FleetOps\Support\Telematics\TelematicService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -25,23 +26,25 @@ class SyncTelematicDevicesJob implements ShouldQueue public Telematic $telematic; public array $options; + public string $jobId; public int $tries = 3; public int $timeout = 300; /** * Create a new job instance. */ - public function __construct(Telematic $telematic, array $options = []) + public function __construct(Telematic $telematic, array $options = [], ?string $jobId = null) { $this->telematic = $telematic; $this->options = $options; + $this->jobId = $jobId ?? \Illuminate\Support\Str::uuid()->toString(); $this->queue = 'telematics-sync'; } /** * Execute the job. */ - public function handle(ProviderRegistry $registry, TelematicService $service): void + public function handle(TelematicProviderRegistry $registry, TelematicService $service): void { $correlationId = \Illuminate\Support\Str::uuid()->toString(); @@ -86,12 +89,31 @@ public function handle(ProviderRegistry $registry, TelematicService $service): v 'correlation_id' => $correlationId, 'total_synced' => $totalSynced, ]); + + $this->telematic->status = 'active'; + $this->telematic->meta = array_merge($this->telematic->meta ?? [], [ + 'last_sync_job_id' => $this->jobId, + 'last_sync_completed_at' => now()->toDateTimeString(), + 'last_sync_result' => 'success', + 'last_sync_total' => $totalSynced, + 'last_sync_error' => null, + ]); + $this->telematic->save(); } catch (\Exception $e) { Log::error('Device discovery failed', [ 'correlation_id' => $correlationId, 'error' => $e->getMessage(), ]); + $this->telematic->status = 'error'; + $this->telematic->meta = array_merge($this->telematic->meta ?? [], [ + 'last_sync_job_id' => $this->jobId, + 'last_sync_result' => 'failed', + 'last_sync_error' => $e->getMessage(), + 'last_sync_failed_at' => now()->toDateTimeString(), + ]); + $this->telematic->save(); + throw $e; } } @@ -101,6 +123,6 @@ public function handle(ProviderRegistry $registry, TelematicService $service): v */ public function getJobId(): string { - return $this->job->getJobId() ?? \Illuminate\Support\Str::uuid()->toString(); + return $this->jobId; } } diff --git a/server/src/Jobs/TestTelematicConnectionJob.php b/server/src/Jobs/TestTelematicConnectionJob.php index d93048e52..0aa7ac896 100644 --- a/server/src/Jobs/TestTelematicConnectionJob.php +++ b/server/src/Jobs/TestTelematicConnectionJob.php @@ -3,12 +3,13 @@ namespace Fleetbase\FleetOps\Jobs; use Fleetbase\FleetOps\Models\Telematic; +use Fleetbase\FleetOps\Support\Telematics\TelematicProviderRegistry; +use Fleetbase\FleetOps\Support\Telematics\TelematicService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -25,22 +26,24 @@ class TestTelematicConnectionJob implements ShouldQueue use SerializesModels; public Telematic $telematic; + public string $jobId; public int $tries = 1; public int $timeout = 30; /** * Create a new job instance. */ - public function __construct(Telematic $telematic) + public function __construct(Telematic $telematic, ?string $jobId = null) { $this->telematic = $telematic; + $this->jobId = $jobId ?? Str::uuid()->toString(); $this->queue = 'telematics-priority'; } /** * Execute the job. */ - public function handle(ProviderRegistry $registry): void + public function handle(TelematicProviderRegistry $registry, TelematicService $service): void { $correlationId = Str::uuid()->toString(); @@ -52,26 +55,11 @@ public function handle(ProviderRegistry $registry): void try { $provider = $registry->resolve($this->telematic->provider); - $credentials = json_decode(Crypt::decryptString($this->telematic->credentials), true); + $credentials = $service->getCredentials($this->telematic); $result = $provider->testConnection($credentials); - if ($result['success']) { - $this->telematic->status = 'active'; - $this->telematic->meta = array_merge($this->telematic->meta ?? [], [ - 'last_connection_test' => now()->toDateTimeString(), - 'last_test_result' => 'success', - ]); - } else { - $this->telematic->status = 'error'; - $this->telematic->meta = array_merge($this->telematic->meta ?? [], [ - 'last_connection_test' => now()->toDateTimeString(), - 'last_test_result' => 'failed', - 'last_error' => $result['message'], - ]); - } - - $this->telematic->save(); + $service->recordConnectionTest($this->telematic, $result); Log::info('Connection test completed', [ 'correlation_id' => $correlationId, @@ -97,6 +85,6 @@ public function handle(ProviderRegistry $registry): void */ public function getJobId(): string { - return $this->job->getJobId() ?? Str::uuid()->toString(); + return $this->jobId; } } diff --git a/server/src/Models/Device.php b/server/src/Models/Device.php index 9d2da253a..ff3656f2e 100644 --- a/server/src/Models/Device.php +++ b/server/src/Models/Device.php @@ -74,7 +74,7 @@ class Device extends Model * * @var array */ - protected $filterParams = ['status', 'warranty_uuid', 'attachable_type']; + protected $filterParams = ['status', 'warranty_uuid', 'attachable_type', 'telematic_uuid', 'provider']; /** * The attributes that are mass assignable. diff --git a/server/src/Models/DeviceEvent.php b/server/src/Models/DeviceEvent.php index 57c3cbfc8..e4ceaf0b7 100644 --- a/server/src/Models/DeviceEvent.php +++ b/server/src/Models/DeviceEvent.php @@ -3,6 +3,8 @@ namespace Fleetbase\FleetOps\Models; use Fleetbase\Casts\Json; +use Fleetbase\FleetOps\Casts\Point; +use Fleetbase\LaravelMysqlSpatial\Eloquent\SpatialTrait; use Fleetbase\Models\Alert; use Fleetbase\Models\Company; use Fleetbase\Models\Model; @@ -35,6 +37,7 @@ class DeviceEvent extends Model use LogsActivity; use HasMetaAttributes; use Searchable; + use SpatialTrait; /** * The database table used by the model. @@ -108,6 +111,13 @@ class DeviceEvent extends Model */ protected $hidden = ['device']; + /** + * The attributes that are spatial columns. + * + * @var array + */ + protected $spatialFields = ['location']; + /** * The attributes that should be cast to native types. * @@ -116,6 +126,7 @@ class DeviceEvent extends Model protected $casts = [ 'payload' => Json::class, 'meta' => Json::class, + 'location' => Point::class, 'resolved_at' => 'datetime', ]; diff --git a/server/src/Models/Sensor.php b/server/src/Models/Sensor.php index 6aaea8460..425f2e763 100644 --- a/server/src/Models/Sensor.php +++ b/server/src/Models/Sensor.php @@ -73,7 +73,7 @@ class Sensor extends Model * * @var array */ - protected $filterParams = ['sensor_type', 'status', 'device_uuid', 'warranty_uuid', 'sensorable_type']; + protected $filterParams = ['sensor_type', 'status', 'device_uuid', 'warranty_uuid', 'sensorable_type', 'telematic_uuid']; /** * The attributes that are mass assignable. diff --git a/server/src/Support/Telematics/Providers/AbstractProvider.php b/server/src/Support/Telematics/Providers/AbstractProvider.php index 7363024ea..299475849 100644 --- a/server/src/Support/Telematics/Providers/AbstractProvider.php +++ b/server/src/Support/Telematics/Providers/AbstractProvider.php @@ -20,7 +20,7 @@ */ abstract class AbstractProvider implements TelematicProviderInterface { - protected Telematic $telematic; + protected ?Telematic $telematic = null; protected array $credentials = []; protected array $headers = []; protected string $baseUrl = ''; @@ -33,7 +33,7 @@ abstract class AbstractProvider implements TelematicProviderInterface public function connect(Telematic $telematic): void { $this->telematic = $telematic; - $this->credentials = json_decode(Crypt::decryptString($telematic->credentials), true); + $this->credentials = $this->resolveCredentials($telematic); $this->prepareAuthentication(); } @@ -88,7 +88,7 @@ protected function request(string $method, string $endpoint, array $data = []): */ protected function checkRateLimit(): void { - $key = 'rate_limit:' . class_basename($this) . ':' . $this->telematic->uuid; + $key = $this->rateLimitKey(); $tokens = Cache::get($key, $this->burstSize); if ($tokens <= 0) { @@ -103,7 +103,7 @@ protected function checkRateLimit(): void */ protected function recordRequest(): void { - $key = 'rate_limit:' . class_basename($this) . ':' . $this->telematic->uuid; + $key = $this->rateLimitKey(); $tokens = Cache::get($key, 0); // Refill tokens gradually @@ -112,6 +112,28 @@ protected function recordRequest(): void } } + protected function rateLimitKey(): string + { + return 'rate_limit:' . class_basename($this) . ':' . ($this->telematic?->uuid ?? 'credential-test'); + } + + protected function resolveCredentials(Telematic $telematic): array + { + if (is_array($telematic->credentials)) { + return $telematic->credentials; + } + + if (!$telematic->credentials) { + return []; + } + + try { + return json_decode(Crypt::decryptString($telematic->credentials), true) ?? []; + } catch (\Throwable) { + return json_decode($telematic->credentials, true) ?? []; + } + } + public function supportsWebhooks(): bool { return false; diff --git a/server/src/Support/Telematics/Providers/AfaqyProvider.php b/server/src/Support/Telematics/Providers/AfaqyProvider.php index ac286244d..753122dbe 100644 --- a/server/src/Support/Telematics/Providers/AfaqyProvider.php +++ b/server/src/Support/Telematics/Providers/AfaqyProvider.php @@ -145,6 +145,7 @@ public function normalizeEvent(array $payload): array return [ 'external_id' => $payload['_id'] ?? $payload['id'] ?? null, + 'device_id' => $payload['_id'] ?? $payload['id'] ?? null, 'event_type' => $payload['event'] ?? $payload['event_type'] ?? 'telemetry_update', 'occurred_at' => $this->parseTimestamp($lastUpdate['dtt'] ?? $lastUpdate['dts'] ?? null), 'location' => [ diff --git a/server/src/Support/Telematics/Providers/FlespiProvider.php b/server/src/Support/Telematics/Providers/FlespiProvider.php index de17d7372..f7b5effd1 100644 --- a/server/src/Support/Telematics/Providers/FlespiProvider.php +++ b/server/src/Support/Telematics/Providers/FlespiProvider.php @@ -97,6 +97,7 @@ public function normalizeEvent(array $payload): array { return [ 'external_id' => $payload['id'] ?? null, + 'device_id' => $payload['device.id'] ?? null, 'event_type' => $payload['event.enum'] ?? 'telemetry_update', 'occurred_at' => isset($payload['timestamp']) ? date('Y-m-d H:i:s', $payload['timestamp']) : now(), 'location' => [ diff --git a/server/src/Support/Telematics/Providers/GeotabProvider.php b/server/src/Support/Telematics/Providers/GeotabProvider.php index 1ed56cb46..0149178e1 100644 --- a/server/src/Support/Telematics/Providers/GeotabProvider.php +++ b/server/src/Support/Telematics/Providers/GeotabProvider.php @@ -131,6 +131,7 @@ public function normalizeEvent(array $payload): array { return [ 'external_id' => $payload['id'] ?? null, + 'device_id' => $payload['device']['id'] ?? $payload['deviceId'] ?? null, 'event_type' => $payload['type'] ?? 'status_data', 'occurred_at' => $payload['dateTime'] ?? now(), 'meta' => $payload, diff --git a/server/src/Support/Telematics/Providers/SafeeProvider.php b/server/src/Support/Telematics/Providers/SafeeProvider.php index c16908052..21d936e09 100644 --- a/server/src/Support/Telematics/Providers/SafeeProvider.php +++ b/server/src/Support/Telematics/Providers/SafeeProvider.php @@ -126,6 +126,7 @@ public function normalizeEvent(array $payload): array return [ 'external_id' => $payload['id'] ?? data_get($payload, 'vehicle.id') ?? null, + 'device_id' => $payload['id'] ?? data_get($payload, 'vehicle.id') ?? null, 'event_type' => data_get($payload, 'event.code') ?? data_get($payload, 'event.name') ?? 'telemetry_update', 'occurred_at' => $this->parseTimestamp($payload['date'] ?? $payload['deviceTime'] ?? $payload['time'] ?? null), 'location' => [ diff --git a/server/src/Support/Telematics/Providers/SamsaraProvider.php b/server/src/Support/Telematics/Providers/SamsaraProvider.php index 848803282..04b533178 100644 --- a/server/src/Support/Telematics/Providers/SamsaraProvider.php +++ b/server/src/Support/Telematics/Providers/SamsaraProvider.php @@ -94,6 +94,7 @@ public function normalizeEvent(array $payload): array { return [ 'external_id' => $payload['id'] ?? null, + 'device_id' => $payload['vehicle']['id'] ?? $payload['vehicleId'] ?? null, 'event_type' => $payload['eventType'] ?? 'vehicle_update', 'occurred_at' => $payload['time'] ?? now(), 'location' => [ @@ -135,7 +136,8 @@ public function processWebhook(array $payload, array $headers = []): array if (isset($payload['data'])) { foreach ($payload['data'] as $item) { if (isset($item['vehicle'])) { - $devices[] = $this->normalizeDevice($item['vehicle']); + $devices[] = $this->normalizeDevice($item['vehicle']); + $item['vehicleId'] = $item['vehicle']['id'] ?? null; } $events[] = $this->normalizeEvent($item); diff --git a/server/src/Support/Telematics/TelematicService.php b/server/src/Support/Telematics/TelematicService.php index f4eecbc96..501878f52 100644 --- a/server/src/Support/Telematics/TelematicService.php +++ b/server/src/Support/Telematics/TelematicService.php @@ -2,12 +2,15 @@ namespace Fleetbase\FleetOps\Support\Telematics; -use Fleetbase\FleetOps\Jobs\SyncDevicesJob; -use Fleetbase\FleetOps\Jobs\TestConnectionJob; +use Fleetbase\FleetOps\Jobs\SyncTelematicDevicesJob; +use Fleetbase\FleetOps\Jobs\TestTelematicConnectionJob; use Fleetbase\FleetOps\Models\Device; +use Fleetbase\FleetOps\Models\DeviceEvent; +use Fleetbase\FleetOps\Models\Sensor; use Fleetbase\FleetOps\Models\Telematic; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; /** @@ -58,7 +61,7 @@ public function create(array $data): Telematic $telematic->company_uuid = session('company'); $telematic->name = $data['name']; $telematic->provider = $providerKey; - $telematic->credentials = Crypt::encryptString(json_encode($data['credentials'])); + $telematic->credentials = $this->encryptCredentials($data['credentials']); $telematic->status = 'active'; $telematic->meta = $data['meta'] ?? []; $telematic->save(); @@ -78,7 +81,7 @@ public function update(Telematic $telematic, array $data): Telematic if (isset($data['credentials'])) { $descriptor = $this->registry->findByKey($telematic->provider); $this->validateCredentials($data['credentials'], $descriptor->requiredFields); - $telematic->credentials = Crypt::encryptString(json_encode($data['credentials'])); + $telematic->credentials = $this->encryptCredentials($data['credentials']); } if (isset($data['status'])) { @@ -110,18 +113,21 @@ public function delete(Telematic $telematic): bool public function testConnection(Telematic $telematic, bool $async = false) { if ($async) { - $job = new TestConnectionJob($telematic); - dispatch($job); + $jobId = (string) Str::uuid(); + dispatch(new TestTelematicConnectionJob($telematic, $jobId)); - return ['job_id' => $job->getJobId(), 'message' => 'Connection test queued']; + return ['job_id' => $jobId, 'message' => 'Connection test queued']; } $provider = $this->registry->resolve($telematic->provider); $provider->connect($telematic); - $credentials = json_decode(Crypt::decryptString($telematic->credentials), true); + $credentials = $this->getCredentials($telematic); - return $provider->testConnection($credentials); + $result = $provider->testConnection($credentials); + $this->recordConnectionTest($telematic, $result); + + return $result; } /** @@ -131,10 +137,17 @@ public function testConnection(Telematic $telematic, bool $async = false) */ public function discoverDevices(Telematic $telematic, array $options = []): string { - $job = new SyncDevicesJob($telematic, $options); - dispatch($job); + $jobId = (string) Str::uuid(); + dispatch(new SyncTelematicDevicesJob($telematic, $options, $jobId)); + + $telematic->status = 'synchronizing'; + $telematic->meta = array_merge($telematic->meta ?? [], [ + 'last_sync_job_id' => $jobId, + 'last_sync_started_at' => now()->toDateTimeString(), + ]); + $telematic->save(); - return $job->getJobId(); + return $jobId; } /** @@ -142,22 +155,108 @@ public function discoverDevices(Telematic $telematic, array $options = []): stri */ public function linkDevice(Telematic $telematic, array $deviceData): Device { + $externalId = $deviceData['device_id'] ?? $deviceData['external_id'] ?? null; + $device = Device::firstOrNew([ 'telematic_uuid' => $telematic->uuid, - 'external_id' => $deviceData['external_id'], + 'device_id' => $externalId, ]); - $device->device_name = $deviceData['device_name'] ?? 'Unknown Device'; - $device->device_model = $deviceData['device_model'] ?? null; - $device->device_provider = $telematic->provider; - $device->status = $deviceData['status'] ?? 'active'; - $device->meta = array_merge($device->meta ?? [], $deviceData['meta'] ?? []); + $device->company_uuid = $telematic->company_uuid; + $device->name = $deviceData['name'] ?? $deviceData['device_name'] ?? 'Unknown Device'; + $device->model = $deviceData['model'] ?? $deviceData['device_model'] ?? null; + $device->provider = $deviceData['provider'] ?? $deviceData['device_provider'] ?? $telematic->provider; + $device->type = $deviceData['type'] ?? null; + $device->internal_id = $deviceData['internal_id'] ?? $externalId; + $device->imei = $deviceData['imei'] ?? null; + $device->imsi = $deviceData['imsi'] ?? null; + $device->serial_number = $deviceData['serial_number'] ?? null; + $device->firmware_version = $deviceData['firmware_version'] ?? null; + $device->status = $deviceData['status'] ?? $device->status ?? 'active'; + $device->online = $deviceData['online'] ?? ($device->status === 'active'); + $device->last_online_at = $deviceData['last_online_at'] ?? $deviceData['last_seen_at'] ?? now(); + $device->meta = array_merge($device->meta ?? [], [ + 'external_id' => $externalId, + ], $deviceData['meta'] ?? []); + + $location = $this->normalizeLocation($deviceData['location'] ?? null); + if ($location) { + $device->last_position = $location; + } $device->save(); return $device; } + public function storeDeviceEvent(Telematic $telematic, array $eventData, ?Device $device = null): DeviceEvent + { + if (!$device) { + $device = $this->resolveDeviceForPayload($telematic, $eventData); + } + + $event = new DeviceEvent(); + $event->company_uuid = $telematic->company_uuid; + $event->device_uuid = $device?->uuid; + $event->event_type = $eventData['event_type'] ?? $eventData['type'] ?? 'telemetry_update'; + $event->severity = $eventData['severity'] ?? 'info'; + $event->provider = $telematic->provider; + $event->ident = $eventData['ident'] ?? $eventData['external_id'] ?? null; + $event->code = $eventData['code'] ?? null; + $event->state = $eventData['state'] ?? null; + $event->reason = $eventData['reason'] ?? null; + $event->payload = $eventData['payload'] ?? $eventData['meta'] ?? $eventData; + $event->meta = array_merge($eventData['meta'] ?? [], [ + 'telematic_uuid' => $telematic->uuid, + 'occurred_at' => $eventData['occurred_at'] ?? null, + 'speed' => $eventData['speed'] ?? null, + 'heading' => $eventData['heading'] ?? null, + 'odometer' => $eventData['odometer'] ?? null, + 'ignition' => $eventData['ignition'] ?? null, + 'fuel_level' => $eventData['fuel_level'] ?? null, + ]); + + $location = $this->normalizeLocation($eventData['location'] ?? null); + if ($location) { + $event->location = $location; + } + + $event->save(); + + return $event; + } + + public function storeSensor(Telematic $telematic, array $sensorData, ?Device $device = null): Sensor + { + if (!$device) { + $device = $this->resolveDeviceForPayload($telematic, $sensorData); + } + + $sensor = Sensor::firstOrNew([ + 'telematic_uuid' => $telematic->uuid, + 'device_uuid' => $device?->uuid, + 'type' => $sensorData['type'] ?? $sensorData['sensor_type'] ?? 'generic', + 'internal_id' => $sensorData['internal_id'] ?? $sensorData['external_id'] ?? null, + ]); + + $sensor->company_uuid = $telematic->company_uuid; + $sensor->name = $sensorData['name'] ?? $sensorData['sensor_type'] ?? $sensor->type ?? 'Sensor'; + $sensor->unit = $sensorData['unit'] ?? null; + $sensor->last_value = isset($sensorData['value']) ? (string) $sensorData['value'] : $sensor->last_value; + $sensor->last_reading_at = $sensorData['recorded_at'] ?? now(); + $sensor->status = $sensorData['status'] ?? 'active'; + $sensor->meta = array_merge($sensor->meta ?? [], $sensorData['meta'] ?? []); + + $location = $this->normalizeLocation($sensorData['location'] ?? null); + if ($location) { + $sensor->last_position = $location; + } + + $sensor->save(); + + return $sensor; + } + /** * Get devices for a telematic. * @@ -173,14 +272,28 @@ public function getDevices(Telematic $telematic, array $filters = []) if (isset($filters['search'])) { $query->where(function ($q) use ($filters) { - $q->where('device_name', 'like', "%{$filters['search']}%") - ->orWhere('external_id', 'like', "%{$filters['search']}%"); + $q->where('name', 'like', "%{$filters['search']}%") + ->orWhere('device_id', 'like', "%{$filters['search']}%") + ->orWhere('internal_id', 'like', "%{$filters['search']}%") + ->orWhere('imei', 'like', "%{$filters['search']}%"); }); } return $query->get(); } + public function recordConnectionTest(Telematic $telematic, array $result): void + { + $telematic->status = ($result['success'] ?? false) ? 'connected' : 'error'; + $telematic->meta = array_merge($telematic->meta ?? [], [ + 'last_connection_test' => now()->toDateTimeString(), + 'last_test_result' => ($result['success'] ?? false) ? 'success' : 'failed', + 'last_error' => ($result['success'] ?? false) ? null : ($result['message'] ?? 'Connection test failed'), + 'last_test_metadata' => $result['metadata'] ?? [], + ]); + $telematic->save(); + } + /** * Validate credentials against provider schema. * @@ -218,6 +331,57 @@ protected function validateCredentials(array $credentials, array $schema): void */ public function getCredentials(Telematic $telematic): array { - return json_decode(Crypt::decryptString($telematic->credentials), true); + if (is_array($telematic->credentials)) { + return $telematic->credentials; + } + + $credentials = $telematic->credentials; + if (!$credentials) { + return []; + } + + try { + return json_decode(Crypt::decryptString($credentials), true) ?? []; + } catch (\Throwable) { + return json_decode($credentials, true) ?? []; + } + } + + protected function encryptCredentials(array $credentials): string + { + return Crypt::encryptString(json_encode($credentials)); + } + + protected function resolveDeviceForPayload(Telematic $telematic, array $payload): ?Device + { + $externalId = $payload['device_id'] ?? $payload['external_id'] ?? $payload['ident'] ?? null; + + if (!$externalId) { + return null; + } + + return Device::where('telematic_uuid', $telematic->uuid) + ->where(function ($query) use ($externalId) { + $query->where('device_id', $externalId) + ->orWhere('internal_id', $externalId) + ->orWhere('imei', $externalId); + }) + ->first(); + } + + protected function normalizeLocation(?array $location): mixed + { + if (!$location) { + return null; + } + + $lat = $location['lat'] ?? $location['latitude'] ?? null; + $lng = $location['lng'] ?? $location['longitude'] ?? null; + + if ($lat === null || $lng === null) { + return null; + } + + return ['latitude' => (float) $lat, 'longitude' => (float) $lng]; } } diff --git a/server/src/routes.php b/server/src/routes.php index f41160817..8dab5280a 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -486,9 +486,9 @@ function ($router, $controller) { $router->fleetbaseRoutes('sensors'); $router->fleetbaseRoutes('telematics', function ($router, $controller) { $router->get('providers', $controller('providers')); - $router->get('devices', $controller('devices')); - $router->post('link-device', $controller('linkDevice')); - $router->post('discover', $controller('discover')); + $router->get('{id}/devices', $controller('devices')); + $router->post('{id}/link-device', $controller('linkDevice')); + $router->post('{id}/discover', $controller('discover')); $router->post('{id}/test-connection', $controller('testConnection')); $router->post('{key}/test-credentials', $controller('testCredentials')); }); From d9721051a289f955afb0ab8afb44d7b03f341b84 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 5 Jun 2026 16:57:28 +0800 Subject: [PATCH 2/5] Use Ember model ids for telematics detail routes --- addon/controllers/connectivity/telematics/index/details.js | 2 +- addon/routes/connectivity/telematics/index/details/devices.js | 2 +- addon/routes/connectivity/telematics/index/details/events.js | 2 +- addon/routes/connectivity/telematics/index/details/sensors.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/addon/controllers/connectivity/telematics/index/details.js b/addon/controllers/connectivity/telematics/index/details.js index f83dfdc7e..75da30384 100644 --- a/addon/controllers/connectivity/telematics/index/details.js +++ b/addon/controllers/connectivity/telematics/index/details.js @@ -50,7 +50,7 @@ export default class ConnectivityTelematicsIndexDetailsController extends Contro } get telematicId() { - return this.model?.id ?? this.model?.public_id ?? this.model?.uuid; + return this.model?.id; } @task *testConnection() { diff --git a/addon/routes/connectivity/telematics/index/details/devices.js b/addon/routes/connectivity/telematics/index/details/devices.js index c3c355f0c..57edb3c80 100644 --- a/addon/routes/connectivity/telematics/index/details/devices.js +++ b/addon/routes/connectivity/telematics/index/details/devices.js @@ -7,7 +7,7 @@ export default class ConnectivityTelematicsIndexDetailsDevicesRoute extends Rout model() { const telematic = this.modelFor('connectivity.telematics.index.details'); return this.store.query('device', { - telematic_uuid: telematic.uuid ?? telematic.id, + telematic_uuid: telematic.id, sort: '-updated_at', }); } diff --git a/addon/routes/connectivity/telematics/index/details/events.js b/addon/routes/connectivity/telematics/index/details/events.js index 28d656552..78ddbc6b8 100644 --- a/addon/routes/connectivity/telematics/index/details/events.js +++ b/addon/routes/connectivity/telematics/index/details/events.js @@ -7,7 +7,7 @@ export default class ConnectivityTelematicsIndexDetailsEventsRoute extends Route model() { const telematic = this.modelFor('connectivity.telematics.index.details'); return this.store.query('device-event', { - telematic: telematic.uuid ?? telematic.id, + telematic: telematic.id, sort: '-created_at', }); } diff --git a/addon/routes/connectivity/telematics/index/details/sensors.js b/addon/routes/connectivity/telematics/index/details/sensors.js index 95fc51ec6..39e54e849 100644 --- a/addon/routes/connectivity/telematics/index/details/sensors.js +++ b/addon/routes/connectivity/telematics/index/details/sensors.js @@ -7,7 +7,7 @@ export default class ConnectivityTelematicsIndexDetailsSensorsRoute extends Rout model() { const telematic = this.modelFor('connectivity.telematics.index.details'); return this.store.query('sensor', { - telematic_uuid: telematic.uuid ?? telematic.id, + telematic_uuid: telematic.id, sort: '-updated_at', }); } From cc9d1678e8867d7e9a6a61088158a51c4524db96 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 6 Jun 2026 12:09:10 +0800 Subject: [PATCH 3/5] Harden telematics ingestion workflows --- addon/components/telematic/details.hbs | 57 ++++-- addon/components/telematic/details.js | 36 +++- ...rden_device_events_telematics_contract.php | 59 ++++++ .../TelematicWebhookController.php | 122 ++++++++---- server/src/Jobs/SyncTelematicDevicesJob.php | 11 +- server/src/Models/DeviceEvent.php | 7 + .../Telematics/Providers/AbstractProvider.php | 3 +- .../Telematics/Providers/AfaqyProvider.php | 25 +-- .../Telematics/Providers/SafeeProvider.php | 29 +-- .../Support/Telematics/TelematicService.php | 178 ++++++++++++++++-- server/tests/TelematicsHardeningTest.php | 80 ++++++++ 11 files changed, 510 insertions(+), 97 deletions(-) create mode 100644 server/migrations/2026_06_06_000001_harden_device_events_telematics_contract.php create mode 100644 server/tests/TelematicsHardeningTest.php diff --git a/addon/components/telematic/details.hbs b/addon/components/telematic/details.hbs index a0154fa17..ca3aa05c3 100644 --- a/addon/components/telematic/details.hbs +++ b/addon/components/telematic/details.hbs @@ -25,14 +25,20 @@ {{#if @resource.provider_descriptor.supports_webhooks}} - - - - - Configure this URL in your - {{@resource.provider_descriptor.label}} - dashboard to receive real-time updates. - + {{#if this.hasWebhookUrl}} + + + + + Configure this URL in your + {{@resource.provider_descriptor.label}} + dashboard to receive real-time updates. + + {{else}} +
+ Webhook URL unavailable until this integration has a public ID. +
+ {{/if}}
{{/if}} @@ -42,12 +48,18 @@
Last Connection Test
-
{{n-a @resource.meta.last_connection_test}}
+
{{n-a (format-date-fns @resource.meta.last_connection_test "dd MMM yyyy, HH:mm")}}
Last Test Result
-
{{n-a @resource.meta.last_test_result}}
+
+ {{#if @resource.meta.last_test_result}} + {{smart-humanize @resource.meta.last_test_result}} + {{else}} + {{n-a @resource.meta.last_test_result}} + {{/if}} +
@@ -57,18 +69,39 @@
Last Sync Started
-
{{n-a @resource.meta.last_sync_started_at}}
+
{{n-a (format-date-fns @resource.meta.last_sync_started_at "dd MMM yyyy, HH:mm")}}
Last Sync Completed
-
{{n-a @resource.meta.last_sync_completed_at}}
+
{{n-a (format-date-fns @resource.meta.last_sync_completed_at "dd MMM yyyy, HH:mm")}}
Devices Synced
{{n-a @resource.meta.last_sync_total}}
+ +
+
Last Sync Result
+
+ {{#if @resource.meta.last_sync_result}} + {{smart-humanize @resource.meta.last_sync_result}} + {{else}} + {{n-a @resource.meta.last_sync_result}} + {{/if}} +
+
+ +
+
Last Sync Error
+
{{n-a @resource.meta.last_sync_error}}
+
+ +
+
Last Job ID
+
{{n-a @resource.meta.last_sync_job_id}}
+
diff --git a/addon/components/telematic/details.js b/addon/components/telematic/details.js index d6e042d46..3b2694e71 100644 --- a/addon/components/telematic/details.js +++ b/addon/components/telematic/details.js @@ -3,13 +3,45 @@ import Component from '@glimmer/component'; export default class TelematicDetailsComponent extends Component { get webhookUrl() { const url = this.args.resource?.provider_descriptor?.webhook_url; - const id = this.args.resource?.public_id ?? this.args.resource?.id; + const id = this.args.resource?.public_id; if (!url || !id) { - return url; + return null; } const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}telematic=${id}`; } + + get hasWebhookUrl() { + return Boolean(this.webhookUrl); + } + + get lastTestStatus() { + const result = this.args.resource?.meta?.last_test_result; + + if (result === 'success') { + return 'success'; + } + + if (result === 'failed') { + return 'danger'; + } + + return 'default'; + } + + get lastSyncStatus() { + const result = this.args.resource?.meta?.last_sync_result; + + if (result === 'success') { + return 'success'; + } + + if (result === 'failed') { + return 'danger'; + } + + return 'default'; + } } diff --git a/server/migrations/2026_06_06_000001_harden_device_events_telematics_contract.php b/server/migrations/2026_06_06_000001_harden_device_events_telematics_contract.php new file mode 100644 index 000000000..29a85ad97 --- /dev/null +++ b/server/migrations/2026_06_06_000001_harden_device_events_telematics_contract.php @@ -0,0 +1,59 @@ +text('message')->nullable()->after('severity'); + } + + if (!Schema::hasColumn('device_events', 'occurred_at')) { + $table->timestamp('occurred_at')->nullable()->index()->after('message'); + } + + if (!Schema::hasColumn('device_events', 'processed_at')) { + $table->timestamp('processed_at')->nullable()->index()->after('occurred_at'); + } + + if (!Schema::hasColumn('device_events', 'data')) { + $table->json('data')->nullable()->after('payload'); + } + }); + } + + public function down(): void + { + if (!Schema::hasTable('device_events')) { + return; + } + + Schema::table('device_events', function (Blueprint $table) { + if (Schema::hasColumn('device_events', 'data')) { + $table->dropColumn('data'); + } + + if (Schema::hasColumn('device_events', 'processed_at')) { + $table->dropIndex(['processed_at']); + $table->dropColumn('processed_at'); + } + + if (Schema::hasColumn('device_events', 'occurred_at')) { + $table->dropIndex(['occurred_at']); + $table->dropColumn('occurred_at'); + } + + if (Schema::hasColumn('device_events', 'message')) { + $table->dropColumn('message'); + } + }); + } +}; diff --git a/server/src/Http/Controllers/TelematicWebhookController.php b/server/src/Http/Controllers/TelematicWebhookController.php index 602bf26e4..5ecc78fb9 100644 --- a/server/src/Http/Controllers/TelematicWebhookController.php +++ b/server/src/Http/Controllers/TelematicWebhookController.php @@ -41,9 +41,10 @@ public function handle(Request $request, string $providerKey): JsonResponse $correlationId = Str::uuid()->toString(); Log::info('Webhook received', [ - 'correlation_id' => $correlationId, - 'provider' => $providerKey, - 'headers' => $request->headers->all(), + 'correlation_id' => $correlationId, + 'provider' => $providerKey, + 'has_signature' => $request->headers->has('X-Webhook-Signature'), + 'has_idempotency_key' => $request->headers->has('X-Idempotency-Key'), ]); // Check idempotency @@ -60,28 +61,42 @@ public function handle(Request $request, string $providerKey): JsonResponse // Get provider $provider = $this->registry->resolve($providerKey); - // Find telematic for this provider. Provider webhooks can include an - // integration id in the URL query or headers to disambiguate tenants. + // Find telematic for this provider. Native provider payloads are + // resolved by provider identifiers first; Fleetbase URL/header ids are + // optional configuration affordances where provider dashboards allow it. $telematicId = $request->query('telematic') ?? $request->query('integration') ?? $request->header('X-Fleetbase-Telematic'); - $telematic = Telematic::where('provider', $providerKey) - ->when($telematicId, function ($query) use ($telematicId) { - $query->where(function ($query) use ($telematicId) { - $query->where('uuid', $telematicId)->orWhere('public_id', $telematicId); - }); - }) - ->first(); + $signature = $request->header('X-Webhook-Signature'); + $telematic = $this->service->resolveWebhookTelematic($providerKey, $request->all(), $request->headers->all(), $telematicId); + + if (!$telematic && !$telematicId && $signature) { + $signatureMatches = Telematic::where('provider', $providerKey) + ->get() + ->filter(fn (Telematic $candidate) => $provider->validateWebhookSignature($request->getContent(), $signature, $this->service->getCredentials($candidate))); + + if ($signatureMatches->count() === 1) { + $telematic = $signatureMatches->first(); + } elseif ($signatureMatches->count() > 1) { + Log::warning('Ambiguous telematic webhook signature match', [ + 'correlation_id' => $correlationId, + 'provider' => $providerKey, + 'matches' => $signatureMatches->count(), + ]); + + return response()->json(['error' => 'Ambiguous telematic integration'], 409); + } + } if (!$telematic) { - Log::warning('No telematic found for provider', [ - 'correlation_id' => $correlationId, - 'provider' => $providerKey, + Log::warning('Unable to resolve telematic for provider webhook', [ + 'correlation_id' => $correlationId, + 'provider' => $providerKey, + 'has_integration_id' => (bool) $telematicId, ]); - return response()->json(['error' => 'No telematic configured'], 404); + return response()->json(['error' => 'Unable to resolve telematic integration'], 422); } // Validate signature - $signature = $request->header('X-Webhook-Signature'); $credentials = $this->service->getCredentials($telematic); if ($signature && !$provider->validateWebhookSignature($request->getContent(), $signature, $credentials)) { @@ -95,27 +110,44 @@ public function handle(Request $request, string $providerKey): JsonResponse // Process webhook try { - $result = $provider->processWebhook($request->all(), $request->headers->all()); + $result = $provider->processWebhook($request->all(), $request->headers->all()); + $devices = $result['devices'] ?? []; + $events = $result['events'] ?? []; + $sensors = $result['sensors'] ?? []; $devicesByExternalId = []; // Link devices - foreach ($result['devices'] as $deviceData) { - $device = $this->service->linkDevice($telematic, $deviceData); - $externalId = $deviceData['external_id'] ?? $deviceData['device_id'] ?? null; - if ($externalId) { - $devicesByExternalId[$externalId] = $device; + foreach ($devices as $deviceData) { + try { + $device = $this->service->linkDevice($telematic, $deviceData); + $externalId = $deviceData['external_id'] ?? $deviceData['device_id'] ?? $deviceData['unit_id'] ?? $deviceData['vehicle_id'] ?? $deviceData['imei'] ?? null; + if ($externalId) { + $devicesByExternalId[$externalId] = $device; + } + } catch (\Illuminate\Validation\ValidationException) { + Log::warning('Skipping webhook device without provider identity', [ + 'correlation_id' => $correlationId, + 'provider' => $providerKey, + ]); } } - foreach ($result['events'] as $eventData) { - $externalId = $eventData['device_id'] ?? $eventData['external_id'] ?? $eventData['ident'] ?? null; + foreach ($events as $eventData) { + $externalId = $eventData['device_id'] ?? $eventData['external_id'] ?? $eventData['ident'] ?? $eventData['unit_id'] ?? $eventData['vehicle_id'] ?? $eventData['imei'] ?? null; $this->service->storeDeviceEvent($telematic, $eventData, $externalId ? ($devicesByExternalId[$externalId] ?? null) : null); } - foreach ($result['sensors'] as $sensorData) { - $externalId = $sensorData['device_id'] ?? $sensorData['external_id'] ?? $sensorData['ident'] ?? null; - $this->service->storeSensor($telematic, $sensorData, $externalId ? ($devicesByExternalId[$externalId] ?? null) : null); + foreach ($sensors as $sensorData) { + try { + $externalId = $sensorData['device_id'] ?? $sensorData['external_id'] ?? $sensorData['ident'] ?? $sensorData['unit_id'] ?? $sensorData['vehicle_id'] ?? $sensorData['imei'] ?? null; + $this->service->storeSensor($telematic, $sensorData, $externalId ? ($devicesByExternalId[$externalId] ?? null) : null); + } catch (\Illuminate\Validation\ValidationException) { + Log::warning('Skipping webhook sensor without provider or device identity', [ + 'correlation_id' => $correlationId, + 'provider' => $providerKey, + ]); + } } // Mark as processed @@ -125,9 +157,9 @@ public function handle(Request $request, string $providerKey): JsonResponse Log::info('Webhook processed successfully', [ 'correlation_id' => $correlationId, - 'devices_count' => count($result['devices']), - 'events_count' => count($result['events']), - 'sensors_count' => count($result['sensors']), + 'devices_count' => count($devices), + 'events_count' => count($events), + 'sensors_count' => count($sensors), ]); return response()->json(['status' => 'processed'], 200); @@ -146,7 +178,7 @@ public function handle(Request $request, string $providerKey): JsonResponse */ public function ingest(Request $request, string $id): JsonResponse { - $telematic = Telematic::where('uuid', $id)->orWhere('public_id', $id)->firstOrFail(); + $telematic = Telematic::where('public_id', $id)->orWhere('uuid', $id)->firstOrFail(); $correlationId = Str::uuid()->toString(); Log::info('Custom ingest received', [ @@ -166,22 +198,34 @@ public function ingest(Request $request, string $id): JsonResponse // Process devices if ($request->has('devices')) { foreach ($request->input('devices') as $deviceData) { - $device = $this->service->linkDevice($telematic, $deviceData); - $externalId = $deviceData['external_id'] ?? $deviceData['device_id'] ?? null; - if ($externalId) { - $devicesByExternalId[$externalId] = $device; + try { + $device = $this->service->linkDevice($telematic, $deviceData); + $externalId = $deviceData['external_id'] ?? $deviceData['device_id'] ?? $deviceData['unit_id'] ?? $deviceData['vehicle_id'] ?? $deviceData['imei'] ?? null; + if ($externalId) { + $devicesByExternalId[$externalId] = $device; + } + } catch (\Illuminate\Validation\ValidationException) { + Log::warning('Skipping custom ingest device without provider identity', [ + 'correlation_id' => $correlationId, + ]); } } } foreach ($request->input('events', []) as $eventData) { - $externalId = $eventData['device_id'] ?? $eventData['external_id'] ?? $eventData['ident'] ?? null; + $externalId = $eventData['device_id'] ?? $eventData['external_id'] ?? $eventData['ident'] ?? $eventData['unit_id'] ?? $eventData['vehicle_id'] ?? $eventData['imei'] ?? null; $this->service->storeDeviceEvent($telematic, $eventData, $externalId ? ($devicesByExternalId[$externalId] ?? null) : null); } foreach ($request->input('sensors', []) as $sensorData) { - $externalId = $sensorData['device_id'] ?? $sensorData['external_id'] ?? $sensorData['ident'] ?? null; - $this->service->storeSensor($telematic, $sensorData, $externalId ? ($devicesByExternalId[$externalId] ?? null) : null); + try { + $externalId = $sensorData['device_id'] ?? $sensorData['external_id'] ?? $sensorData['ident'] ?? $sensorData['unit_id'] ?? $sensorData['vehicle_id'] ?? $sensorData['imei'] ?? null; + $this->service->storeSensor($telematic, $sensorData, $externalId ? ($devicesByExternalId[$externalId] ?? null) : null); + } catch (\Illuminate\Validation\ValidationException) { + Log::warning('Skipping custom ingest sensor without provider or device identity', [ + 'correlation_id' => $correlationId, + ]); + } } // Mark as processed diff --git a/server/src/Jobs/SyncTelematicDevicesJob.php b/server/src/Jobs/SyncTelematicDevicesJob.php index a08d39eed..84fafbeaa 100644 --- a/server/src/Jobs/SyncTelematicDevicesJob.php +++ b/server/src/Jobs/SyncTelematicDevicesJob.php @@ -70,8 +70,15 @@ public function handle(TelematicProviderRegistry $registry, TelematicService $se foreach ($response['devices'] as $devicePayload) { $normalizedDevice = $provider->normalizeDevice($devicePayload); - $service->linkDevice($this->telematic, $normalizedDevice); - $totalSynced++; + try { + $service->linkDevice($this->telematic, $normalizedDevice); + $totalSynced++; + } catch (\Illuminate\Validation\ValidationException $e) { + Log::warning('Skipping telematics device without provider identity', [ + 'correlation_id' => $correlationId, + 'provider' => $this->telematic->provider, + ]); + } } $cursor = $response['next_cursor']; diff --git a/server/src/Models/DeviceEvent.php b/server/src/Models/DeviceEvent.php index e4ceaf0b7..7dc6773a6 100644 --- a/server/src/Models/DeviceEvent.php +++ b/server/src/Models/DeviceEvent.php @@ -76,10 +76,12 @@ class DeviceEvent extends Model 'company_uuid', 'device_uuid', 'payload', + 'data', 'meta', 'location', 'event_type', 'severity', + 'message', 'ident', 'protocol', 'provider', @@ -89,6 +91,8 @@ class DeviceEvent extends Model 'reason', 'comment', 'resolved_at', + 'occurred_at', + 'processed_at', 'slug', ]; @@ -125,9 +129,12 @@ class DeviceEvent extends Model */ protected $casts = [ 'payload' => Json::class, + 'data' => Json::class, 'meta' => Json::class, 'location' => Point::class, 'resolved_at' => 'datetime', + 'occurred_at' => 'datetime', + 'processed_at' => 'datetime', ]; /** diff --git a/server/src/Support/Telematics/Providers/AbstractProvider.php b/server/src/Support/Telematics/Providers/AbstractProvider.php index 299475849..6149744c7 100644 --- a/server/src/Support/Telematics/Providers/AbstractProvider.php +++ b/server/src/Support/Telematics/Providers/AbstractProvider.php @@ -72,10 +72,9 @@ protected function request(string $method, string $endpoint, array $data = []): Log::error('Provider API request failed', [ 'correlation_id' => $correlationId, 'status' => $response->status(), - 'body' => $response->body(), ]); - throw new \Exception('API request failed: ' . $response->body()); + throw new \Exception('Provider API request failed with status ' . $response->status()); } return $response->json(); diff --git a/server/src/Support/Telematics/Providers/AfaqyProvider.php b/server/src/Support/Telematics/Providers/AfaqyProvider.php index 753122dbe..12a1901bf 100644 --- a/server/src/Support/Telematics/Providers/AfaqyProvider.php +++ b/server/src/Support/Telematics/Providers/AfaqyProvider.php @@ -112,15 +112,17 @@ public function normalizeDevice(array $payload): array $lastUpdate = $payload['last_update'] ?? []; return [ - 'external_id' => $payload['_id'] ?? $payload['id'] ?? null, - 'device_name' => $payload['name'] ?? data_get($payload, 'profile.plate_number') ?? 'Unknown Unit', - 'device_provider' => 'afaqy', - 'device_model' => data_get($payload, 'profile.model') ?? $payload['device'] ?? null, - 'imei' => $payload['imei'] ?? null, - 'phone' => $payload['sim_number'] ?? null, - 'vin' => data_get($payload, 'profile.vin'), - 'status' => ($payload['active'] ?? false) ? 'active' : 'inactive', - 'location' => [ + 'device_id' => $payload['_id'] ?? $payload['id'] ?? null, + 'name' => $payload['name'] ?? data_get($payload, 'profile.plate_number') ?? 'Unknown Unit', + 'provider' => 'afaqy', + 'model' => data_get($payload, 'profile.model') ?? $payload['device'] ?? null, + 'imei' => $payload['imei'] ?? null, + 'phone' => $payload['sim_number'] ?? null, + 'vin' => data_get($payload, 'profile.vin'), + 'status' => ($payload['active'] ?? false) ? 'active' : 'inactive', + 'online' => $payload['active'] ?? null, + 'last_seen_at' => $this->parseTimestamp($lastUpdate['dtt'] ?? $lastUpdate['dts'] ?? null), + 'location' => [ 'lat' => $lastUpdate['lat'] ?? null, 'lng' => $lastUpdate['lng'] ?? null, ], @@ -147,6 +149,7 @@ public function normalizeEvent(array $payload): array 'external_id' => $payload['_id'] ?? $payload['id'] ?? null, 'device_id' => $payload['_id'] ?? $payload['id'] ?? null, 'event_type' => $payload['event'] ?? $payload['event_type'] ?? 'telemetry_update', + 'message' => $payload['message'] ?? $payload['event'] ?? null, 'occurred_at' => $this->parseTimestamp($lastUpdate['dtt'] ?? $lastUpdate['dts'] ?? null), 'location' => [ 'lat' => $lastUpdate['lat'] ?? data_get($lastUpdate, 'loc.coordinates.1'), @@ -228,7 +231,7 @@ protected function authenticate(): string ]); if ($response->failed()) { - throw new \RuntimeException('AFAQY authentication failed: ' . $response->body()); + throw new \RuntimeException('AFAQY authentication failed with status ' . $response->status()); } $token = data_get($response->json(), 'data.token'); @@ -247,7 +250,7 @@ protected function afaqyPost(string $endpoint, array $payload = []): array ->post($this->baseUrl . $endpoint . '?token=' . urlencode($this->credentials['token']), $payload); if ($response->failed()) { - throw new \RuntimeException('AFAQY API request failed: ' . $response->body()); + throw new \RuntimeException('AFAQY API request failed with status ' . $response->status()); } return $response->json() ?? []; diff --git a/server/src/Support/Telematics/Providers/SafeeProvider.php b/server/src/Support/Telematics/Providers/SafeeProvider.php index 21d936e09..852550343 100644 --- a/server/src/Support/Telematics/Providers/SafeeProvider.php +++ b/server/src/Support/Telematics/Providers/SafeeProvider.php @@ -90,17 +90,21 @@ public function fetchDeviceDetails(string $externalId): array public function normalizeDevice(array $payload): array { - $position = $this->extractPosition($payload); + $position = $this->extractPosition($payload); + $rawStatus = $payload['status'] ?? $payload['vehicleStatus'] ?? null; + $status = $this->normalizeVehicleStatus($rawStatus); return [ - 'external_id' => $payload['id'] ?? data_get($payload, 'vehicle.id') ?? null, - 'device_name' => $payload['name'] ?? $payload['plateNumber'] ?? data_get($payload, 'vehicle.name') ?? 'Unknown Vehicle', - 'device_provider' => 'safee', - 'device_model' => $payload['model'] ?? data_get($payload, 'device.model') ?? null, - 'imei' => data_get($payload, 'device.imei') ?? data_get($payload, 'device.serial') ?? null, - 'vin' => $payload['vin'] ?? null, - 'status' => $this->normalizeVehicleStatus($payload['status'] ?? $payload['vehicleStatus'] ?? null), - 'location' => [ + 'device_id' => $payload['id'] ?? data_get($payload, 'vehicle.id') ?? null, + 'name' => $payload['name'] ?? $payload['plateNumber'] ?? data_get($payload, 'vehicle.name') ?? 'Unknown Vehicle', + 'provider' => 'safee', + 'model' => $payload['model'] ?? data_get($payload, 'device.model') ?? null, + 'imei' => data_get($payload, 'device.imei') ?? data_get($payload, 'device.serial') ?? null, + 'vin' => $payload['vin'] ?? null, + 'status' => $status, + 'online' => $rawStatus ? $status === 'active' : null, + 'last_seen_at' => $this->parseTimestamp($payload['date'] ?? $payload['deviceTime'] ?? $payload['time'] ?? null), + 'location' => [ 'lat' => $position['lat'] ?? null, 'lng' => $position['lng'] ?? null, ], @@ -128,6 +132,7 @@ public function normalizeEvent(array $payload): array 'external_id' => $payload['id'] ?? data_get($payload, 'vehicle.id') ?? null, 'device_id' => $payload['id'] ?? data_get($payload, 'vehicle.id') ?? null, 'event_type' => data_get($payload, 'event.code') ?? data_get($payload, 'event.name') ?? 'telemetry_update', + 'message' => data_get($payload, 'event.name') ?? data_get($payload, 'event.message') ?? null, 'occurred_at' => $this->parseTimestamp($payload['date'] ?? $payload['deviceTime'] ?? $payload['time'] ?? null), 'location' => [ 'lat' => $position['lat'] ?? null, @@ -224,7 +229,7 @@ protected function authenticate(): string ]); if ($response->failed()) { - throw new \RuntimeException('Safee authentication failed: ' . $response->body()); + throw new \RuntimeException('Safee authentication failed with status ' . $response->status()); } $token = $response->json('access_token'); @@ -243,7 +248,7 @@ protected function safeeGet(string $endpoint): array ->get($this->baseUrl . $endpoint); if ($response->failed()) { - throw new \RuntimeException('Safee API request failed: ' . $response->body()); + throw new \RuntimeException('Safee API request failed with status ' . $response->status()); } return $response->json() ?? []; @@ -256,7 +261,7 @@ protected function safeePost(string $endpoint, array|\stdClass $payload = []): a ->post($this->baseUrl . $endpoint, $payload); if ($response->failed()) { - throw new \RuntimeException('Safee API request failed: ' . $response->body()); + throw new \RuntimeException('Safee API request failed with status ' . $response->status()); } return $response->json() ?? []; diff --git a/server/src/Support/Telematics/TelematicService.php b/server/src/Support/Telematics/TelematicService.php index 501878f52..483da4bf1 100644 --- a/server/src/Support/Telematics/TelematicService.php +++ b/server/src/Support/Telematics/TelematicService.php @@ -155,7 +155,11 @@ public function discoverDevices(Telematic $telematic, array $options = []): stri */ public function linkDevice(Telematic $telematic, array $deviceData): Device { - $externalId = $deviceData['device_id'] ?? $deviceData['external_id'] ?? null; + $externalId = $this->resolveExternalId($deviceData); + + if (!$externalId) { + throw ValidationException::withMessages(['device_id' => ['Provider device identity is required to link a telematics device.']]); + } $device = Device::firstOrNew([ 'telematic_uuid' => $telematic->uuid, @@ -163,8 +167,8 @@ public function linkDevice(Telematic $telematic, array $deviceData): Device ]); $device->company_uuid = $telematic->company_uuid; - $device->name = $deviceData['name'] ?? $deviceData['device_name'] ?? 'Unknown Device'; - $device->model = $deviceData['model'] ?? $deviceData['device_model'] ?? null; + $device->name = $deviceData['name'] ?? $deviceData['device_name'] ?? $device->name ?? 'Unknown Device'; + $device->model = $deviceData['model'] ?? $deviceData['device_model'] ?? $device->model; $device->provider = $deviceData['provider'] ?? $deviceData['device_provider'] ?? $telematic->provider; $device->type = $deviceData['type'] ?? null; $device->internal_id = $deviceData['internal_id'] ?? $externalId; @@ -173,8 +177,17 @@ public function linkDevice(Telematic $telematic, array $deviceData): Device $device->serial_number = $deviceData['serial_number'] ?? null; $device->firmware_version = $deviceData['firmware_version'] ?? null; $device->status = $deviceData['status'] ?? $device->status ?? 'active'; - $device->online = $deviceData['online'] ?? ($device->status === 'active'); - $device->last_online_at = $deviceData['last_online_at'] ?? $deviceData['last_seen_at'] ?? now(); + + if (array_key_exists('online', $deviceData) && $deviceData['online'] !== null) { + $device->online = (bool) $deviceData['online']; + } elseif (!$device->exists) { + $device->online = $device->status === 'active'; + } + + if (!empty($deviceData['last_online_at']) || !empty($deviceData['last_seen_at'])) { + $device->last_online_at = $deviceData['last_online_at'] ?? $deviceData['last_seen_at']; + } + $device->meta = array_merge($device->meta ?? [], [ 'external_id' => $externalId, ], $deviceData['meta'] ?? []); @@ -195,25 +208,38 @@ public function storeDeviceEvent(Telematic $telematic, array $eventData, ?Device $device = $this->resolveDeviceForPayload($telematic, $eventData); } - $event = new DeviceEvent(); + $eventKey = $this->makeEventKey($telematic, $eventData, $device); + $event = $eventKey ? DeviceEvent::firstOrNew(['_key' => $eventKey]) : new DeviceEvent(); $event->company_uuid = $telematic->company_uuid; $event->device_uuid = $device?->uuid; $event->event_type = $eventData['event_type'] ?? $eventData['type'] ?? 'telemetry_update'; $event->severity = $eventData['severity'] ?? 'info'; + $event->message = $eventData['message'] ?? $eventData['reason'] ?? null; $event->provider = $telematic->provider; - $event->ident = $eventData['ident'] ?? $eventData['external_id'] ?? null; + $event->ident = $eventData['ident'] ?? $eventData['event_id'] ?? $eventData['external_event_id'] ?? $eventData['external_id'] ?? null; $event->code = $eventData['code'] ?? null; $event->state = $eventData['state'] ?? null; $event->reason = $eventData['reason'] ?? null; + $event->occurred_at = $eventData['occurred_at'] ?? $eventData['recorded_at'] ?? $eventData['timestamp'] ?? null; + $event->data = $eventData['data'] ?? array_filter([ + 'speed' => $eventData['speed'] ?? null, + 'heading' => $eventData['heading'] ?? null, + 'odometer' => $eventData['odometer'] ?? null, + 'ignition' => $eventData['ignition'] ?? null, + 'fuel_level' => $eventData['fuel_level'] ?? null, + ], fn ($value) => $value !== null); $event->payload = $eventData['payload'] ?? $eventData['meta'] ?? $eventData; + $event->_key = $eventKey; $event->meta = array_merge($eventData['meta'] ?? [], [ - 'telematic_uuid' => $telematic->uuid, - 'occurred_at' => $eventData['occurred_at'] ?? null, - 'speed' => $eventData['speed'] ?? null, - 'heading' => $eventData['heading'] ?? null, - 'odometer' => $eventData['odometer'] ?? null, - 'ignition' => $eventData['ignition'] ?? null, - 'fuel_level' => $eventData['fuel_level'] ?? null, + 'telematic_uuid' => $telematic->uuid, + 'telematic_id' => $telematic->public_id, + 'provider_event_id' => $event->ident, + 'occurred_at' => $eventData['occurred_at'] ?? null, + 'speed' => $eventData['speed'] ?? null, + 'heading' => $eventData['heading'] ?? null, + 'odometer' => $eventData['odometer'] ?? null, + 'ignition' => $eventData['ignition'] ?? null, + 'fuel_level' => $eventData['fuel_level'] ?? null, ]); $location = $this->normalizeLocation($eventData['location'] ?? null); @@ -232,18 +258,23 @@ public function storeSensor(Telematic $telematic, array $sensorData, ?Device $de $device = $this->resolveDeviceForPayload($telematic, $sensorData); } + $sensorIdentity = $sensorData['internal_id'] ?? $sensorData['sensor_id'] ?? $sensorData['external_id'] ?? null; + if (!$sensorIdentity && !$device) { + throw ValidationException::withMessages(['sensor_id' => ['Provider sensor identity or device identity is required to link a telematics sensor.']]); + } + $sensor = Sensor::firstOrNew([ 'telematic_uuid' => $telematic->uuid, 'device_uuid' => $device?->uuid, 'type' => $sensorData['type'] ?? $sensorData['sensor_type'] ?? 'generic', - 'internal_id' => $sensorData['internal_id'] ?? $sensorData['external_id'] ?? null, + 'internal_id' => $sensorIdentity ?? $this->makeSensorIdentity($sensorData, $device), ]); $sensor->company_uuid = $telematic->company_uuid; $sensor->name = $sensorData['name'] ?? $sensorData['sensor_type'] ?? $sensor->type ?? 'Sensor'; $sensor->unit = $sensorData['unit'] ?? null; $sensor->last_value = isset($sensorData['value']) ? (string) $sensorData['value'] : $sensor->last_value; - $sensor->last_reading_at = $sensorData['recorded_at'] ?? now(); + $sensor->last_reading_at = $sensorData['recorded_at'] ?? $sensorData['last_reading_at'] ?? $sensor->last_reading_at ?? now(); $sensor->status = $sensorData['status'] ?? 'active'; $sensor->meta = array_merge($sensor->meta ?? [], $sensorData['meta'] ?? []); @@ -354,7 +385,7 @@ protected function encryptCredentials(array $credentials): string protected function resolveDeviceForPayload(Telematic $telematic, array $payload): ?Device { - $externalId = $payload['device_id'] ?? $payload['external_id'] ?? $payload['ident'] ?? null; + $externalId = $this->resolveExternalId($payload); if (!$externalId) { return null; @@ -369,6 +400,54 @@ protected function resolveDeviceForPayload(Telematic $telematic, array $payload) ->first(); } + public function resolveWebhookTelematic(string $providerKey, array $payload = [], array $headers = [], ?string $integrationId = null): ?Telematic + { + $query = Telematic::where('provider', $providerKey); + + if ($integrationId) { + return (clone $query) + ->where(function ($query) use ($integrationId) { + $query->where('public_id', $integrationId)->orWhere('uuid', $integrationId); + }) + ->first(); + } + + $providerAccountId = $this->resolveProviderAccountId($payload, $headers); + if ($providerAccountId) { + $matches = (clone $query) + ->where(function ($query) use ($providerAccountId) { + $query->where('meta->provider_account_id', $providerAccountId) + ->orWhere('meta->account_id', $providerAccountId) + ->orWhere('meta->organization_id', $providerAccountId) + ->orWhere('meta->customer_id', $providerAccountId); + }) + ->limit(2) + ->get(); + + if ($matches->count() === 1) { + return $matches->first(); + } + } + + $deviceId = $this->resolveExternalId($payload); + if ($deviceId) { + $matches = (clone $query) + ->whereHas('device', function ($query) use ($deviceId) { + $query->where('device_id', $deviceId) + ->orWhere('internal_id', $deviceId) + ->orWhere('imei', $deviceId); + }) + ->limit(2) + ->get(); + + if ($matches->count() === 1) { + return $matches->first(); + } + } + + return null; + } + protected function normalizeLocation(?array $location): mixed { if (!$location) { @@ -384,4 +463,69 @@ protected function normalizeLocation(?array $location): mixed return ['latitude' => (float) $lat, 'longitude' => (float) $lng]; } + + protected function resolveExternalId(array $payload): ?string + { + $value = $payload['device_id'] + ?? $payload['external_id'] + ?? $payload['ident'] + ?? $payload['unit_id'] + ?? $payload['vehicle_id'] + ?? $payload['imei'] + ?? null; + + return $value === null || $value === '' ? null : (string) $value; + } + + protected function resolveProviderAccountId(array $payload, array $headers = []): ?string + { + $headerValue = data_get($headers, 'x-provider-account.0') + ?? data_get($headers, 'x-organization-id.0') + ?? data_get($headers, 'x-customer-id.0'); + + $value = $payload['provider_account_id'] + ?? $payload['account_id'] + ?? $payload['organization_id'] + ?? $payload['org_id'] + ?? $payload['customer_id'] + ?? data_get($payload, 'account.id') + ?? data_get($payload, 'organization.id') + ?? $headerValue; + + return $value === null || $value === '' ? null : (string) $value; + } + + protected function makeEventKey(Telematic $telematic, array $eventData, ?Device $device = null): ?string + { + $deviceId = $device?->device_id ?? $this->resolveExternalId($eventData); + if (!$deviceId) { + return null; + } + + $providerEventId = $eventData['event_id'] ?? $eventData['external_event_id'] ?? $eventData['ident'] ?? $eventData['external_id'] ?? null; + $occurredAt = $eventData['occurred_at'] ?? $eventData['recorded_at'] ?? $eventData['timestamp'] ?? null; + $eventType = $eventData['event_type'] ?? $eventData['type'] ?? 'telemetry_update'; + + if (!$providerEventId && !$occurredAt) { + return null; + } + + return sha1(implode('|', [ + $telematic->provider, + $telematic->public_id ?? $telematic->uuid, + $deviceId, + $providerEventId, + $eventType, + $occurredAt, + ])); + } + + protected function makeSensorIdentity(array $sensorData, ?Device $device = null): string + { + return sha1(implode('|', [ + $device?->uuid ?? 'no-device', + $sensorData['type'] ?? $sensorData['sensor_type'] ?? 'generic', + $sensorData['name'] ?? $sensorData['unit'] ?? 'sensor', + ])); + } } diff --git a/server/tests/TelematicsHardeningTest.php b/server/tests/TelematicsHardeningTest.php new file mode 100644 index 000000000..a396ba759 --- /dev/null +++ b/server/tests/TelematicsHardeningTest.php @@ -0,0 +1,80 @@ +toContain("'message'") + ->toContain("'occurred_at'") + ->toContain("'processed_at'") + ->toContain("'data'"); + + expect($model) + ->toContain("'message'") + ->toContain("'occurred_at'") + ->toContain("'processed_at'") + ->toContain("'data'") + ->toContain("'occurred_at' => 'datetime'") + ->toContain("'processed_at' => 'datetime'"); +}); + +test('webhook controller resolves integrations explicitly and does not select the first provider record', function () { + $controller = file_get_contents(__DIR__ . '/../src/Http/Controllers/TelematicWebhookController.php'); + + expect($controller) + ->toContain('$this->service->resolveWebhookTelematic($providerKey') + ->toContain("'Unable to resolve telematic integration'") + ->toContain('$result[\'devices\'] ?? []') + ->toContain('$result[\'events\'] ?? []') + ->toContain('$result[\'sensors\'] ?? []') + ->toContain('Ambiguous telematic integration') + ->toContain('validateWebhookSignature($request->getContent(), $signature') + ->not->toContain("Telematic::where('provider', \$providerKey)\n ->when") + ->not->toContain("->first();\n\n if (!\$telematic)"); +}); + +test('telematics service requires provider identity and stores idempotent event keys', function () { + $service = file_get_contents(__DIR__ . '/../src/Support/Telematics/TelematicService.php'); + + expect($service) + ->toContain('Provider device identity is required to link a telematics device.') + ->toContain('DeviceEvent::firstOrNew([\'_key\' => $eventKey])') + ->toContain('protected function makeEventKey') + ->toContain('$telematic->public_id ?? $telematic->uuid') + ->toContain('resolveWebhookTelematic') + ->toContain('whereHas(\'device\'') + ->toContain('meta->provider_account_id'); +}); + +test('native providers normalize device payloads to canonical FleetOps keys', function () { + $afaqy = file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/AfaqyProvider.php'); + $safee = file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/SafeeProvider.php'); + + foreach ([$afaqy, $safee] as $provider) { + expect($provider) + ->toContain("'device_id'") + ->toContain("'name'") + ->toContain("'provider'") + ->toContain("'model'") + ->toContain("'online'") + ->toContain("'last_seen_at'"); + } +}); + +test('telematics details use public id for consumer webhook URLs and do not read ember uuid', function () { + $component = file_get_contents(__DIR__ . '/../../addon/components/telematic/details.js'); + $template = file_get_contents(__DIR__ . '/../../addon/components/telematic/details.hbs'); + + expect($component) + ->toContain('const id = this.args.resource?.public_id;') + ->toContain('return null;') + ->not->toContain('this.args.resource?.uuid') + ->not->toContain('this.args.resource?.public_id ?? this.args.resource?.id'); + + expect($template) + ->toContain('this.hasWebhookUrl') + ->toContain('Webhook URL unavailable until this integration has a public ID.') + ->toContain('last_sync_job_id') + ->toContain('last_sync_error'); +}); From a53b2d458dfde77f54d46aebd0bd8cacecb3a0a6 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 6 Jun 2026 15:45:49 +0800 Subject: [PATCH 4/5] Redesign telematics connectivity hub --- addon/components/cell/telematic-provider.hbs | 12 + addon/components/cell/telematic-provider.js | 36 ++ .../telematic-connection-diagnostics.hbs | 75 +++ .../telematic-connection-diagnostics.js | 287 ++++++++++ addon/components/telematic/details.hbs | 242 +++------ addon/components/telematic/details.js | 104 +++- addon/components/telematic/form.hbs | 351 +++++++++--- addon/components/telematic/form.js | 508 +++++++++++++++++- addon/components/telematic/hub.hbs | 134 +++++ addon/components/telematic/hub.js | 138 +++++ .../connectivity/telematics/index.js | 23 +- .../connectivity/telematics/index/details.js | 63 ++- .../telematics/index/details/attachments.js | 45 ++ .../connectivity/telematics/index/index.js | 5 + .../connectivity/telematics/index/new.js | 6 + addon/routes.js | 2 + .../telematics/index/details/attachments.js | 14 + .../connectivity/telematics/index/index.js | 7 + addon/services/telematic-actions.js | 8 +- addon/styles/fleetops-engine.css | 124 +++++ .../connectivity/telematics/index.hbs | 15 +- .../connectivity/telematics/index/details.hbs | 66 ++- .../telematics/index/details/attachments.hbs | 39 ++ .../telematics/index/details/index.hbs | 1 - .../connectivity/telematics/index/edit.hbs | 38 +- .../connectivity/telematics/index/index.hbs | 12 + .../connectivity/telematics/index/new.hbs | 36 +- app/components/cell/telematic-provider.js | 1 + .../telematic-connection-diagnostics.js | 1 + app/components/telematic/hub.js | 1 + .../telematics/index/details/attachments.js | 1 + .../connectivity/telematics/index/index.js | 1 + .../telematics/index/details/attachments.js | 1 + .../connectivity/telematics/index/index.js | 1 + .../telematics/index/details/attachments.js | 1 + .../connectivity/telematics/index/index.js | 1 + assets/images/telematics/providers/afaqy.webp | Bin 0 -> 1018 bytes .../images/telematics/providers/default.webp | Bin 0 -> 1354 bytes .../images/telematics/providers/flespi.webp | Bin 0 -> 1094 bytes .../images/telematics/providers/geotab.webp | Bin 0 -> 812 bytes assets/images/telematics/providers/safee.webp | Bin 0 -> 848 bytes .../images/telematics/providers/samsara.webp | Bin 0 -> 1272 bytes server/config/telematics.php | 9 +- .../Contracts/TelematicProviderDescriptor.php | 4 +- .../Internal/v1/TelematicController.php | 11 +- server/tests/TelematicsHardeningTest.php | 33 ++ 46 files changed, 2140 insertions(+), 317 deletions(-) create mode 100644 addon/components/cell/telematic-provider.hbs create mode 100644 addon/components/cell/telematic-provider.js create mode 100644 addon/components/modals/telematic-connection-diagnostics.hbs create mode 100644 addon/components/modals/telematic-connection-diagnostics.js create mode 100644 addon/components/telematic/hub.hbs create mode 100644 addon/components/telematic/hub.js create mode 100644 addon/controllers/connectivity/telematics/index/details/attachments.js create mode 100644 addon/controllers/connectivity/telematics/index/index.js create mode 100644 addon/routes/connectivity/telematics/index/details/attachments.js create mode 100644 addon/routes/connectivity/telematics/index/index.js create mode 100644 addon/templates/connectivity/telematics/index/details/attachments.hbs create mode 100644 addon/templates/connectivity/telematics/index/index.hbs create mode 100644 app/components/cell/telematic-provider.js create mode 100644 app/components/modals/telematic-connection-diagnostics.js create mode 100644 app/components/telematic/hub.js create mode 100644 app/controllers/connectivity/telematics/index/details/attachments.js create mode 100644 app/controllers/connectivity/telematics/index/index.js create mode 100644 app/routes/connectivity/telematics/index/details/attachments.js create mode 100644 app/routes/connectivity/telematics/index/index.js create mode 100644 app/templates/connectivity/telematics/index/details/attachments.js create mode 100644 app/templates/connectivity/telematics/index/index.js create mode 100644 assets/images/telematics/providers/afaqy.webp create mode 100644 assets/images/telematics/providers/default.webp create mode 100644 assets/images/telematics/providers/flespi.webp create mode 100644 assets/images/telematics/providers/geotab.webp create mode 100644 assets/images/telematics/providers/safee.webp create mode 100644 assets/images/telematics/providers/samsara.webp diff --git a/addon/components/cell/telematic-provider.hbs b/addon/components/cell/telematic-provider.hbs new file mode 100644 index 000000000..1b3961fd1 --- /dev/null +++ b/addon/components/cell/telematic-provider.hbs @@ -0,0 +1,12 @@ +
+ + +
diff --git a/addon/components/cell/telematic-provider.js b/addon/components/cell/telematic-provider.js new file mode 100644 index 000000000..0aef0f697 --- /dev/null +++ b/addon/components/cell/telematic-provider.js @@ -0,0 +1,36 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class CellTelematicProviderComponent extends Component { + get descriptor() { + return this.args.row?.provider_descriptor ?? {}; + } + + get name() { + return this.args.row?.name ?? this.descriptor.label ?? this.args.row?.provider; + } + + get description() { + return this.descriptor.description ?? this.args.row?.provider; + } + + get icon() { + return this.descriptor.icon; + } + + @action onClick(event) { + const { row, column, onClick } = this.args; + + if (typeof onClick === 'function') { + onClick(row, event); + } + + if (typeof column?.action === 'function') { + column.action(row, event); + } + + if (typeof column?.onClick === 'function') { + column.onClick(row, event); + } + } +} diff --git a/addon/components/modals/telematic-connection-diagnostics.hbs b/addon/components/modals/telematic-connection-diagnostics.hbs new file mode 100644 index 000000000..d60593a7d --- /dev/null +++ b/addon/components/modals/telematic-connection-diagnostics.hbs @@ -0,0 +1,75 @@ + + + diff --git a/addon/components/modals/telematic-connection-diagnostics.js b/addon/components/modals/telematic-connection-diagnostics.js new file mode 100644 index 000000000..16b252885 --- /dev/null +++ b/addon/components/modals/telematic-connection-diagnostics.js @@ -0,0 +1,287 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; +import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard'; + +const SENSITIVE_METADATA_KEYS = ['password', 'secret', 'session', 'token']; + +export default class ModalsTelematicConnectionDiagnosticsComponent extends Component { + @service fetch; + @service modalsManager; + @service notifications; + + @tracked result; + @tracked startedAt; + @tracked completedAt; + + constructor(owner, { options = {} }) { + super(...arguments); + this.options = options; + this.setupOptions(); + this.runTest.perform(); + } + + get telematic() { + return this.options.telematic; + } + + get providerLabel() { + return this.telematic?.provider_descriptor?.label ?? this.telematic?.provider ?? 'Provider'; + } + + get providerKey() { + return this.telematic?.provider; + } + + get connectionState() { + if (this.runTest.isRunning) { + return 'testing'; + } + + if (!this.result) { + return 'idle'; + } + + return this.result.success ? 'success' : 'failed'; + } + + get connectionStateTitle() { + if (this.connectionState === 'testing') { + return 'Testing provider connection...'; + } + + if (this.connectionState === 'success') { + return 'Connection verified'; + } + + if (this.connectionState === 'failed') { + return 'Connection failed'; + } + + return 'Ready to test'; + } + + get connectionStateMessage() { + if (this.connectionState === 'testing') { + return 'Waiting for the provider to accept the saved credentials.'; + } + + if (this.connectionState === 'idle') { + return 'Run a connection test to verify the saved provider credentials.'; + } + + if (this.isDuplicateConnectionMessage) { + return null; + } + + return this.result?.message; + } + + get isDuplicateConnectionMessage() { + const message = this.result?.message; + + if (!message) { + return false; + } + + return ['connection successful', 'connection verified'].includes(message.trim().toLowerCase()); + } + + get connectionMetadataEntries() { + const metadata = this.result?.metadata ?? {}; + + return Object.entries(metadata) + .filter(([key, value]) => { + if (value === null || value === undefined || value === '') { + return false; + } + + return !this.isSensitiveMetadataKey(key); + }) + .map(([key, value]) => ({ + label: this.formatMetadataLabel(key), + value: this.formatDiagnosticValue(value), + })); + } + + get hasConnectionMetadata() { + return this.connectionMetadataEntries.length > 0; + } + + get connectionDiagnosticEntries() { + const entries = []; + const startedAt = this.startedAt; + const completedAt = this.completedAt; + + entries.push({ + time: this.formatDiagnosticTime(startedAt), + tone: 'muted', + text: `Provider selected: ${this.providerLabel}${this.providerKey ? ` (${this.providerKey})` : ''}`, + }); + + entries.push({ + time: this.formatDiagnosticTime(startedAt), + tone: 'info', + text: 'Sending saved provider connection test request', + }); + + if (this.connectionState === 'testing') { + entries.push({ + time: this.formatDiagnosticTime(), + tone: 'info', + text: 'Awaiting provider response', + }); + return entries; + } + + if (this.connectionState === 'idle') { + entries.push({ + time: this.formatDiagnosticTime(), + tone: 'muted', + text: 'Connection test has not been run yet', + }); + return entries; + } + + entries.push({ + time: this.formatDiagnosticTime(completedAt), + tone: 'info', + text: 'Provider response received', + }); + + if (this.hasConnectionMetadata) { + entries.push({ + time: this.formatDiagnosticTime(completedAt), + tone: 'muted', + text: `Provider metadata: ${this.connectionMetadataEntries.map((entry) => `${entry.label}=${entry.value}`).join(', ')}`, + }); + } + + if (this.connectionState === 'success') { + entries.push({ + time: this.formatDiagnosticTime(completedAt), + tone: 'success', + text: 'Connection verified', + }); + return entries; + } + + entries.push({ + time: this.formatDiagnosticTime(completedAt), + tone: 'danger', + text: `Connection failed${this.result?.message ? `: ${this.formatDiagnosticValue(this.result.message)}` : ''}`, + }); + + return entries; + } + + get connectionDiagnosticText() { + return this.connectionDiagnosticEntries.map((entry) => `[${entry.time}] ${entry.text}`).join('\n'); + } + + setupOptions() { + this.options.title = this.options.title ?? 'Test Connection'; + this.options.acceptButtonText = 'Run Test'; + this.options.acceptButtonIcon = 'plug'; + this.options.declineButtonText = this.options.declineButtonText ?? 'Close'; + this.options.confirm = async (modal) => { + modal.startLoading(); + + try { + await this.runTest.perform(); + } finally { + modal.stopLoading(); + } + }; + } + + @task *runTest() { + if (!this.telematic?.id) { + return; + } + + this.startedAt = new Date(); + this.completedAt = null; + this.modalsManager.setOption('acceptButtonDisabled', true); + this.modalsManager.setOption('acceptButtonText', 'Testing...'); + + try { + const result = yield this.fetch.post(`telematics/${this.telematic.id}/test-connection`); + this.result = result; + this.completedAt = new Date(); + yield this.options.onTested?.(result); + } catch (error) { + this.completedAt = new Date(); + this.result = { + success: false, + message: error.message || 'Connection test failed', + }; + } finally { + this.modalsManager.setOption('acceptButtonDisabled', false); + this.modalsManager.setOption('acceptButtonText', this.result ? 'Test Again' : 'Run Test'); + } + } + + @action + copyConnectionDiagnostics() { + copyToClipboard(this.connectionDiagnosticText) + .then(() => { + this.notifications.success('Connection diagnostics copied.'); + }) + .catch(() => { + this.notifications.error('Unable to copy connection diagnostics.'); + }); + } + + formatMetadataLabel(key) { + return key.replace(/_/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()); + } + + formatDiagnosticValue(value) { + const sanitizedValue = this.sanitizeDiagnosticValue(value); + + if (typeof sanitizedValue === 'object') { + return JSON.stringify(sanitizedValue); + } + + return String(sanitizedValue); + } + + sanitizeDiagnosticValue(value, seen = new WeakSet()) { + if (!value || typeof value !== 'object') { + return value; + } + + if (seen.has(value)) { + return '[circular]'; + } + + seen.add(value); + + if (Array.isArray(value)) { + return value.map((item) => this.sanitizeDiagnosticValue(item, seen)); + } + + return Object.entries(value).reduce((acc, [key, item]) => { + if (!this.isSensitiveMetadataKey(key)) { + acc[key] = this.sanitizeDiagnosticValue(item, seen); + } + + return acc; + }, {}); + } + + formatDiagnosticTime(date = new Date()) { + if (!(date instanceof Date)) { + return '--:--:--'; + } + + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); + } + + isSensitiveMetadataKey(key) { + return SENSITIVE_METADATA_KEYS.some((sensitiveKey) => key.toLowerCase().includes(sensitiveKey)); + } +} diff --git a/addon/components/telematic/details.hbs b/addon/components/telematic/details.hbs index ca3aa05c3..dc52ffd30 100644 --- a/addon/components/telematic/details.hbs +++ b/addon/components/telematic/details.hbs @@ -1,162 +1,103 @@ -
- -
-
Provider
-
-
-
- -
-
-
{{@resource.provider_descriptor.label}}
-
{{n-a @resource.provider_descriptor.description}}
+
+
+ {{#each this.healthCards as |card|}} +
+
+
+
+ +
+
+
{{card.label}}
+
{{n-a card.value}}
+
{{n-a (format-date-fns card.detail "dd MMM yyyy, HH:mm")}}
+
+ {{#if card.statusLabel}} + {{card.statusLabel}} + {{/if}}
-
- - - -
-
-
Integration Name
-
{{n-a @resource.name}}
-
+ {{/each}} + - {{#if @resource.provider_descriptor.supports_webhooks}} - - {{#if this.hasWebhookUrl}} - - - - - Configure this URL in your - {{@resource.provider_descriptor.label}} - dashboard to receive real-time updates. - - {{else}} -
- Webhook URL unavailable until this integration has a public ID. + {{#if this.attentionItems.length}} +
+ {{#each this.attentionItems as |item|}} +
+
+
+
- {{/if}} - - {{/if}} -
- - - -
-
-
Last Connection Test
-
{{n-a (format-date-fns @resource.meta.last_connection_test "dd MMM yyyy, HH:mm")}}
-
- -
-
Last Test Result
-
- {{#if @resource.meta.last_test_result}} - {{smart-humanize @resource.meta.last_test_result}} - {{else}} - {{n-a @resource.meta.last_test_result}} - {{/if}} +
+
{{item.title}}
+

{{item.description}}

+
+
-
- -
-
Last Error
-
{{n-a @resource.meta.last_error}}
-
- -
-
Last Sync Started
-
{{n-a (format-date-fns @resource.meta.last_sync_started_at "dd MMM yyyy, HH:mm")}}
-
- -
-
Last Sync Completed
-
{{n-a (format-date-fns @resource.meta.last_sync_completed_at "dd MMM yyyy, HH:mm")}}
-
- -
-
Devices Synced
-
{{n-a @resource.meta.last_sync_total}}
-
- -
-
Last Sync Result
-
- {{#if @resource.meta.last_sync_result}} - {{smart-humanize @resource.meta.last_sync_result}} - {{else}} - {{n-a @resource.meta.last_sync_result}} - {{/if}} + {{/each}} +
+ {{else}} +
+
+
+
-
- -
-
Last Sync Error
-
{{n-a @resource.meta.last_sync_error}}
-
- -
-
Last Job ID
-
{{n-a @resource.meta.last_sync_job_id}}
-
-
- - - -
-
-
Model
-
{{n-a @resource.model}}
-
- -
-
Serial Number
-
{{n-a @resource.serial_number}}
-
- -
-
Firmware Version
-
{{n-a @resource.firmware_version}}
-
- -
-
Status
-
- {{smart-humanize @resource.status}} +
+
No immediate attention needed
+
Connection, sync, and telemetry status are ready for review.
- -
-
IMEI
-
{{n-a @resource.imei}}
-
- -
-
ICCID
-
{{n-a @resource.iccid}}
-
- -
-
IMSI
-
{{n-a @resource.imsi}}
-
- -
-
MSISDN
-
{{n-a @resource.msisdn}}
+ + {{/if}} + +
+
+
+

Provider Connection

+

External provider identity and public webhook configuration.

+
+
+
+
Provider
+
+ + {{@resource.provider_descriptor.label}} +
+
+
+
Integration ID
+
{{n-a @resource.public_id}}
+
+ {{#if @resource.provider_descriptor.supports_webhooks}} +
+
Webhook URL
+ {{#if this.hasWebhookUrl}} + + + + {{else}} +
Webhook URL unavailable until this integration has a public ID.
+ {{/if}} +
+ {{/if}}
+
-
-
Last Seen
-
{{n-a (format-date @resource.last_seen_at)}}
+
+
+

Hardware Identity

+

Provider blackbox metadata stored on the integration.

- -
-
Online Status
-
+
+ {{#each this.hardwareFields as |field|}} +
+ {{field.label}} + {{n-a field.value}} +
+ {{/each}} +
+ Online {{#if @resource.is_online}} Online {{else}} @@ -164,13 +105,8 @@ {{/if}}
- -
-
Signal Strength
-
{{n-a @resource.signal_strength}}
-
- +
diff --git a/addon/components/telematic/details.js b/addon/components/telematic/details.js index 3b2694e71..6614a7b6b 100644 --- a/addon/components/telematic/details.js +++ b/addon/components/telematic/details.js @@ -21,27 +21,119 @@ export default class TelematicDetailsComponent extends Component { const result = this.args.resource?.meta?.last_test_result; if (result === 'success') { - return 'success'; + return { + status: 'success', + label: 'Verified', + }; } if (result === 'failed') { - return 'danger'; + return { + status: 'danger', + label: 'Failed', + }; } - return 'default'; + return null; } get lastSyncStatus() { const result = this.args.resource?.meta?.last_sync_result; if (result === 'success') { - return 'success'; + return { + status: 'success', + label: 'Synced', + }; } if (result === 'failed') { - return 'danger'; + return { + status: 'danger', + label: 'Failed', + }; } - return 'default'; + return null; + } + + get healthCards() { + const resource = this.args.resource; + + return [ + { + icon: 'plug', + label: 'Connection test', + value: resource?.meta?.last_test_result ?? 'Not tested', + detail: resource?.meta?.last_connection_test, + status: this.lastTestStatus?.status, + statusLabel: this.lastTestStatus?.label, + }, + { + icon: 'satellite-dish', + label: 'Device sync', + value: resource?.meta?.last_sync_result ?? 'Not synced', + detail: resource?.meta?.last_sync_completed_at, + status: this.lastSyncStatus?.status, + statusLabel: this.lastSyncStatus?.label, + }, + { + icon: 'microchip', + label: 'Devices synced', + value: resource?.meta?.last_sync_total ?? 'None', + detail: resource?.meta?.last_sync_job_id, + status: resource?.meta?.last_sync_total ? 'success' : null, + statusLabel: resource?.meta?.last_sync_total ? 'Available' : null, + }, + ]; + } + + get attentionItems() { + const resource = this.args.resource; + const items = []; + + if (resource?.meta?.last_error) { + items.push({ + icon: 'triangle-exclamation', + title: 'Connection issue', + description: resource.meta.last_error, + status: 'warning', + }); + } + + if (resource?.meta?.last_sync_error) { + items.push({ + icon: 'circle-exclamation', + title: 'Sync issue', + description: resource.meta.last_sync_error, + status: 'warning', + }); + } + + if (resource?.meta?.unattached_devices_count > 0) { + items.push({ + icon: 'truck', + title: 'Devices need vehicles', + description: `${resource.meta.unattached_devices_count} synced devices are waiting to be attached.`, + status: 'warning', + }); + } + + return items; + } + + get hardwareFields() { + const resource = this.args.resource; + + return [ + { label: 'Model', value: resource?.model }, + { label: 'Serial Number', value: resource?.serial_number }, + { label: 'Firmware Version', value: resource?.firmware_version }, + { label: 'IMEI', value: resource?.imei }, + { label: 'ICCID', value: resource?.iccid }, + { label: 'IMSI', value: resource?.imsi }, + { label: 'MSISDN', value: resource?.msisdn }, + { label: 'Signal Strength', value: resource?.signal_strength }, + ]; } } diff --git a/addon/components/telematic/form.hbs b/addon/components/telematic/form.hbs index a91e8f3f8..1e7d38d86 100644 --- a/addon/components/telematic/form.hbs +++ b/addon/components/telematic/form.hbs @@ -1,96 +1,291 @@ -
- -
- -
- -
-
-
- +
+
+
+
+
+ {{#each this.setupSteps as |step index|}} + + {{/each}} +
+
+ + {{#if (eq this.activeStep 0)}} +
+
+

Choose Provider

+

Select the provider that will supply device connectivity and telemetry.

+
+ + {{#if this.loadProviders.isRunning}} +
+ {{else}} +
+ {{#each this.providerCards as |provider|}} + + {{/each}} +
+ {{/if}} +
+ {{else if (eq this.activeStep 1)}} +
+
+
+
+

Credentials

+

FleetOps uses these credentials to test the connection and sync devices.

+
+
+ + {{#if this.selectedProvider}} +
+ {{#each this.selectedProvider.required_fields as |field|}} + + {{/each}} +
+ {{else}} +
+ Choose a provider before entering credentials. +
+ {{/if}} +
+ + {{#if this.selectedProvider}} + + {{/if}} +
+ {{else if (eq this.activeStep 2)}} +
+
+
+

Test Connection

+

Confirm the provider accepts the credentials before saving this connection.

- -
- - - {{#if this.selectedProvider}} - -
- {{#each this.selectedProvider.required_fields as |field|}} - - {{/each}}
- {{#if this.connectionTestResult}} -
- - {{if this.connectionTestResult.success "Connection successful" "Connection failed"}} - - {{this.connectionTestResult.message}} +
+
+
+
+ {{#if (eq this.connectionState "testing")}} + + {{else if (eq this.connectionState "success")}} + + {{else if (eq this.connectionState "failed")}} + + {{else}} + + {{/if}} +
+
+
{{this.connectionStateTitle}}
+ {{#if this.connectionStateMessage}} +

{{this.connectionStateMessage}}

+ {{/if}} + + {{#if this.hasConnectionMetadata}} +
+ {{#each this.connectionMetadataEntries as |item|}} + + {{item.label}} + {{item.value}} + + {{/each}} +
+ {{/if}} +
+
+ +
+ {{#if (eq this.connectionState "failed")}} +
- {{/if}} - - -
+
+
+ + + {{#if this.showConnectionDiagnostics}} +
+ + {{#if this.showConnectionDiagnostics}} +
+ {{#each this.connectionDiagnosticEntries as |entry|}} +
+ [{{entry.time}}] + {{entry.text}} +
+ {{/each}} +
+ {{/if}} +
+
+ +
+ {{else if (eq this.activeStep 3)}} +
+
+

Configure Integration

+

Name the connection and confirm any provider-facing URLs.

+
+ +
{{#if this.selectedProvider.supports_webhooks}} - - - - - - Configure this URL in your - {{this.selectedProvider.label}} - dashboard to receive real-time updates. - + + {{#if this.webhookUrl}} + + + + Use this public URL in the provider dashboard. + {{else}} +
+ The webhook URL will be available after this connection has a public ID. +
+ {{/if}}
+ {{else}} +
+ This provider does not require FleetOps webhook configuration. +
{{/if}}
- - {{/if}} - -
- - - - - - -
-
+
+ {{else}} +
+
+

Hardware Identity

+

Optional blackbox metadata for provider hardware installed in the fleet.

+
+
+ + + + + + +
+
- + - - - + + + + {{/if}} +
+
+ +
+
+
{{@footerHelp}}
+
+
+
+
diff --git a/addon/components/telematic/form.js b/addon/components/telematic/form.js index e12b4d92b..402e229e6 100644 --- a/addon/components/telematic/form.js +++ b/addon/components/telematic/form.js @@ -3,6 +3,9 @@ import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; import { task } from 'ember-concurrency'; +import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard'; + +const SENSITIVE_METADATA_KEYS = ['password', 'secret', 'session', 'token']; export default class TelematicFormComponent extends Component { @service fetch; @@ -10,6 +13,357 @@ export default class TelematicFormComponent extends Component { @tracked providers = []; @tracked selectedProvider = this.args.resource?.provider_descriptor ?? null; @tracked connectionTestResult; + @tracked showConnectionDiagnostics = false; + @tracked lastConnectionTestStartedAt; + @tracked lastConnectionTestCompletedAt; + @tracked activeStep = 0; + @tracked initialProviderApplied = false; + + get setupSteps() { + return [ + { + icon: 'plug', + label: 'Provider', + complete: this.activeStep > 0 && this.isProviderStepValid, + active: this.activeStep === 0, + }, + { + icon: 'key', + label: 'Credentials', + complete: this.activeStep > 1 && this.areCredentialsValid, + active: this.activeStep === 1, + }, + { + icon: 'circle-check', + label: 'Test', + complete: this.activeStep > 2 && this.isTestStepValid, + active: this.activeStep === 2, + }, + { + icon: 'link', + label: 'Webhook', + complete: this.activeStep > 3 && this.isIntegrationStepValid, + active: this.activeStep === 3, + }, + { + icon: 'clipboard-check', + label: 'Review', + complete: false, + active: this.activeStep === 4, + }, + ]; + } + + get providerCards() { + return this.providers.map((provider) => ({ + ...provider, + selected: this.selectedProvider?.key === provider.key, + })); + } + + get hasCredentialValues() { + const credentials = this.args.resource?.credentials ?? {}; + return Object.values(credentials).some((value) => value !== null && value !== undefined && value !== ''); + } + + get requiredCredentialFields() { + return (this.selectedProvider?.required_fields ?? []).filter((field) => field.required); + } + + get missingCredentialFields() { + const credentials = this.args.resource?.credentials ?? {}; + + return this.requiredCredentialFields.filter((field) => { + const value = credentials[field.name]; + return value === null || value === undefined || value === ''; + }); + } + + get isProviderStepValid() { + return Boolean(this.selectedProvider); + } + + get areCredentialsValid() { + return this.isProviderStepValid && this.missingCredentialFields.length === 0; + } + + get isTestStepValid() { + return this.areCredentialsValid; + } + + get isIntegrationStepValid() { + return Boolean(this.args.resource?.name); + } + + get isReviewStepValid() { + return this.isProviderStepValid && this.areCredentialsValid && this.isIntegrationStepValid; + } + + get isLastStep() { + return this.activeStep === this.setupSteps.length - 1; + } + + get canGoBack() { + return this.activeStep > 0; + } + + get primaryFooterLabel() { + if (this.isLastStep) { + return this.args.isSaving ? this.args.savingLabel : this.args.saveLabel; + } + + return 'Continue'; + } + + get primaryFooterIcon() { + return this.isLastStep ? 'check' : 'arrow-right'; + } + + get canUsePrimaryAction() { + if (this.args.isSaving) { + return false; + } + + if (this.isLastStep) { + return this.isReviewStepValid; + } + + return this.canLeaveStep(this.activeStep); + } + + get webhookUrl() { + const url = this.selectedProvider?.webhook_url; + const id = this.args.resource?.public_id; + + if (!url || !id) { + return null; + } + + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}telematic=${id}`; + } + + get connectionState() { + if (this.testConnection.isRunning) { + return 'testing'; + } + + if (!this.connectionTestResult) { + return 'idle'; + } + + return this.connectionTestResult.success ? 'success' : 'failed'; + } + + get connectionStateTitle() { + if (this.connectionState === 'testing') { + return 'Testing provider credentials...'; + } + + if (this.connectionState === 'success') { + return 'Connection verified'; + } + + if (this.connectionState === 'failed') { + return 'Connection failed'; + } + + return 'Ready to test'; + } + + get connectionStateMessage() { + if (this.connectionState === 'testing') { + return 'FleetOps is sending the configured credentials to the provider and waiting for a response.'; + } + + if (this.connectionState === 'idle') { + return 'Run a connection test to verify credentials before saving. You can still continue if the provider does not support live credential testing.'; + } + + if (this.isDuplicateConnectionMessage) { + return null; + } + + return this.connectionTestResult?.message; + } + + get isDuplicateConnectionMessage() { + const message = this.connectionTestResult?.message; + + if (!message) { + return false; + } + + return ['connection successful', 'connection verified'].includes(message.trim().toLowerCase()); + } + + get connectionMetadataEntries() { + const metadata = this.connectionTestResult?.metadata ?? {}; + + return Object.entries(metadata) + .filter(([key, value]) => { + if (value === null || value === undefined || value === '') { + return false; + } + + return !this.isSensitiveMetadataKey(key); + }) + .map(([key, value]) => ({ + label: this.formatMetadataLabel(key), + value: this.formatDiagnosticValue(value), + })); + } + + get hasConnectionMetadata() { + return this.connectionMetadataEntries.length > 0; + } + + get connectionDiagnosticEntries() { + const entries = []; + const providerLabel = this.selectedProvider?.label ?? 'No provider selected'; + const providerKey = this.selectedProvider?.key; + const startedAt = this.lastConnectionTestStartedAt; + const completedAt = this.lastConnectionTestCompletedAt; + + entries.push({ + time: this.formatDiagnosticTime(startedAt), + tone: 'muted', + text: `Provider selected: ${providerLabel}${providerKey ? ` (${providerKey})` : ''}`, + }); + + if (this.areCredentialsValid) { + entries.push({ + time: this.formatDiagnosticTime(startedAt), + tone: 'muted', + text: `Credentials prepared: ${this.requiredCredentialFields.length} required fields present`, + }); + } else { + entries.push({ + time: this.formatDiagnosticTime(startedAt), + tone: 'warning', + text: `Credentials incomplete: ${this.missingCredentialFields.length} required fields missing`, + }); + } + + if (this.connectionState === 'idle') { + entries.push({ + time: this.formatDiagnosticTime(), + tone: 'muted', + text: 'Credential test has not been run yet', + }); + return entries; + } + + entries.push({ + time: this.formatDiagnosticTime(startedAt), + tone: 'info', + text: 'Sending provider credential test request', + }); + + if (this.connectionState === 'testing') { + entries.push({ + time: this.formatDiagnosticTime(), + tone: 'info', + text: 'Awaiting provider response', + }); + return entries; + } + + entries.push({ + time: this.formatDiagnosticTime(completedAt), + tone: 'info', + text: 'Provider response received', + }); + + if (this.hasConnectionMetadata) { + entries.push({ + time: this.formatDiagnosticTime(completedAt), + tone: 'muted', + text: `Provider metadata: ${this.connectionMetadataEntries.map((entry) => `${entry.label}=${entry.value}`).join(', ')}`, + }); + } + + if (this.connectionState === 'success') { + entries.push({ + time: this.formatDiagnosticTime(completedAt), + tone: 'success', + text: 'Connection verified', + }); + return entries; + } + + entries.push({ + time: this.formatDiagnosticTime(completedAt), + tone: 'danger', + text: `Connection failed${this.connectionTestResult?.message ? `: ${this.formatDiagnosticValue(this.connectionTestResult.message)}` : ''}`, + }); + + return entries; + } + + get hasConnectionDiagnostics() { + return this.connectionDiagnosticEntries.length > 0; + } + + get connectionDiagnosticText() { + return this.connectionDiagnosticEntries.map((entry) => `[${entry.time}] ${entry.text}`).join('\n'); + } + + formatMetadataLabel(key) { + return key.replace(/_/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()); + } + + formatDiagnosticValue(value) { + const sanitizedValue = this.sanitizeDiagnosticValue(value); + + if (typeof sanitizedValue === 'object') { + return JSON.stringify(sanitizedValue); + } + + return String(sanitizedValue); + } + + sanitizeDiagnosticValue(value, seen = new WeakSet()) { + if (!value || typeof value !== 'object') { + return value; + } + + if (seen.has(value)) { + return '[circular]'; + } + + seen.add(value); + + if (Array.isArray(value)) { + return value.map((item) => this.sanitizeDiagnosticValue(item, seen)); + } + + return Object.entries(value).reduce((acc, [key, item]) => { + if (!this.isSensitiveMetadataKey(key)) { + acc[key] = this.sanitizeDiagnosticValue(item, seen); + } + + return acc; + }, {}); + } + + formatDiagnosticTime(date = new Date()) { + if (!(date instanceof Date)) { + return '--:--:--'; + } + + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); + } + + isSensitiveMetadataKey(key) { + return SENSITIVE_METADATA_KEYS.some((sensitiveKey) => key.toLowerCase().includes(sensitiveKey)); + } + + resetConnectionTest() { + this.connectionTestResult = null; + this.lastConnectionTestStartedAt = null; + this.lastConnectionTestCompletedAt = null; + } get credentialsActionButtons() { return [ @@ -25,6 +379,7 @@ export default class TelematicFormComponent extends Component { constructor() { super(...arguments); + this.activeStep = this.selectedProvider ? 1 : 0; this.loadProviders.perform(); } @@ -34,32 +389,165 @@ export default class TelematicFormComponent extends Component { ...credentials, [field.name]: value, }); + this.resetConnectionTest(); } @action selectProvider(provider) { + this.applyProvider(provider); + this.activeStep = 1; + } + + applyProvider(provider) { this.selectedProvider = provider; + this.resetConnectionTest(); this.args.resource.setProperties({ + name: this.args.resource.name ?? provider.label, provider: provider.key, - credentials: (provider.required_fields ?? {}).reduce((acc, item) => { + credentials: (provider.required_fields ?? []).reduce((acc, item) => { acc[item.name] = null; return acc; }, {}), }); } + @action goToStep(index) { + if (!this.canReachStep(index)) { + return; + } + + this.activeStep = index; + } + + @action nextStep() { + if (!this.validateStep(this.activeStep)) { + return; + } + + this.activeStep = Math.min(this.activeStep + 1, this.setupSteps.length - 1); + } + + @action previousStep() { + this.activeStep = Math.max(this.activeStep - 1, 0); + } + + @action primaryFooterAction() { + if (this.isLastStep) { + if (!this.validateStep(this.activeStep) || !this.isReviewStepValid) { + return; + } + + return this.args.onSave?.(); + } + + return this.nextStep(); + } + + @action toggleConnectionDiagnostics() { + this.showConnectionDiagnostics = !this.showConnectionDiagnostics; + } + + @action copyConnectionDiagnostics() { + copyToClipboard(this.connectionDiagnosticText) + .then(() => { + this.notifications.success('Connection diagnostics copied.'); + }) + .catch(() => { + this.notifications.error('Unable to copy connection diagnostics.'); + }); + } + + canReachStep(index) { + if (index <= this.activeStep) { + return true; + } + + for (let step = 0; step < index; step++) { + if (!this.canLeaveStep(step)) { + return false; + } + } + + return true; + } + + canLeaveStep(index) { + if (index === 0) { + return this.isProviderStepValid; + } + + if (index === 1) { + return this.areCredentialsValid; + } + + if (index === 3) { + return this.isIntegrationStepValid; + } + + return true; + } + + validateStep(index) { + if (index === 0 && !this.isProviderStepValid) { + this.notifications.warning('Choose a provider to continue.'); + return false; + } + + if (index === 1 && !this.areCredentialsValid) { + this.notifications.warning('Enter the required provider credentials to continue.'); + return false; + } + + if (index === 3 && !this.isIntegrationStepValid) { + this.notifications.warning('Enter an integration name to continue.'); + return false; + } + + if (index === 4 && !this.isReviewStepValid) { + this.notifications.warning('Complete the required setup steps before saving.'); + return false; + } + + return true; + } + + applyInitialProvider() { + if (this.initialProviderApplied || !this.args.initialProviderKey || this.selectedProvider) { + return; + } + + const provider = this.providers.find((candidate) => candidate.key === this.args.initialProviderKey); + + if (!provider) { + return; + } + + this.initialProviderApplied = true; + this.applyProvider(provider); + this.activeStep = 1; + } + @task *loadProviders() { try { const providers = yield this.fetch.get('telematics/providers'); this.providers = providers; + this.applyInitialProvider(); } catch (err) { this.notifications.serverError(err); } } @task *testConnection() { + this.lastConnectionTestStartedAt = new Date(); + this.lastConnectionTestCompletedAt = null; + try { - const result = yield this.fetch.post(`telematics/${this.selectedProvider.key}/test-credentials`, { credentials: this.args.resource.credentials }); + const result = yield this.fetch.post(`telematics/${this.selectedProvider.key}/test-credentials`, { + credentials: this.args.resource.credentials, + telematic_id: this.args.resource?.id, + }); this.connectionTestResult = result; + this.lastConnectionTestCompletedAt = new Date(); + this.updateResourceConnectionTestMeta(result); if (result.success) { this.notifications.success('Connection successful!'); @@ -67,11 +555,27 @@ export default class TelematicFormComponent extends Component { this.notifications.error(result.message); } } catch (error) { + this.lastConnectionTestCompletedAt = new Date(); this.connectionTestResult = { success: false, message: error.message || 'Connection test failed', }; + this.updateResourceConnectionTestMeta(this.connectionTestResult); this.notifications.error('Connection test failed'); } } + + updateResourceConnectionTestMeta(result) { + const meta = this.args.resource?.meta ?? {}; + const testedAt = this.lastConnectionTestCompletedAt ?? new Date(); + + this.args.resource?.set('status', result?.success ? 'connected' : 'error'); + this.args.resource?.set('meta', { + ...meta, + last_connection_test: testedAt.toISOString(), + last_test_result: result?.success ? 'success' : 'failed', + last_error: result?.success ? null : (result?.message ?? 'Connection test failed'), + last_test_metadata: result?.metadata ?? {}, + }); + } } diff --git a/addon/components/telematic/hub.hbs b/addon/components/telematic/hub.hbs new file mode 100644 index 000000000..5f6b6d479 --- /dev/null +++ b/addon/components/telematic/hub.hbs @@ -0,0 +1,134 @@ +
+
+
+
+
+
+

Connectivity Hub

+ + {{this.healthWarningCount}} warnings + +
+

+ Connect telematics providers, sync devices, attach them to vehicles, and monitor telemetry health from one workflow. +

+
+
+
+
+
+ +
+
+ {{#each this.kpiWidgets as |widget|}} +
+
+
+
+
{{widget.label}}
+
{{widget.value}}
+
+
+ +
+
+ +
+ {{widget.help}} + +
+
+
+ {{/each}} +
+ +
+
+
+

Native Providers

+

Start with a provider connection, then sync devices and attach them to vehicles.

+
+ {{#if @providersLoading}} +
Loading providers
+ {{/if}} +
+ +
+ {{#each this.providerCards as |provider|}} + + {{/each}} +
+
+ +
+
+
+

Provider Connections

+

Manage connected provider accounts, review sync status, and open a connection to sync devices or update settings.

+
+
+ +
+
+ +
+
+
+
diff --git a/addon/components/telematic/hub.js b/addon/components/telematic/hub.js new file mode 100644 index 000000000..a987851be --- /dev/null +++ b/addon/components/telematic/hub.js @@ -0,0 +1,138 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +const WARNING_STATUSES = ['error', 'degraded', 'disconnected']; + +export default class TelematicHubComponent extends Component { + @service telematicActions; + @service hostRouter; + @tracked table; + @tracked columns = this.args.columns ?? []; + + get integrations() { + return Array.from(this.args.integrations ?? []); + } + + get providers() { + return Array.from(this.args.providers ?? []); + } + + get connectedProviderKeys() { + return new Set(this.integrations.map((integration) => integration.provider).filter(Boolean)); + } + + get providerCards() { + const connected = this.connectedProviderKeys; + + return this.providers.map((provider) => { + const integration = this.integrations.find((record) => record.provider === provider.key); + + return { + ...provider, + connected: connected.has(provider.key), + integration, + }; + }); + } + + get activeProviderCount() { + return this.integrations.filter((integration) => !['disabled', 'archived'].includes(integration.status)).length; + } + + get syncedDeviceCount() { + return this.integrations.reduce((total, integration) => total + Number(integration.meta?.last_sync_total ?? 0), 0); + } + + get unattachedDeviceCount() { + return this.integrations.reduce((total, integration) => total + Number(integration.meta?.unattached_devices_count ?? 0), 0); + } + + get healthWarningCount() { + return this.integrations.filter((integration) => WARNING_STATUSES.includes(integration.status) || integration.meta?.last_error || integration.meta?.last_sync_error).length; + } + + get kpiWidgets() { + return [ + { + icon: 'plug', + label: 'Provider count', + value: this.activeProviderCount, + help: 'Connected provider accounts', + action: 'Connect a provider', + routeAction: 'create', + accent: 'blue', + accentClass: 'fleetops-connectivity-kpi-accent-blue', + }, + { + icon: 'satellite-dish', + label: 'Synced Devices', + value: this.syncedDeviceCount, + help: 'Discovered from providers', + action: 'Review synced devices', + routeAction: 'devices', + accent: 'green', + accentClass: 'fleetops-connectivity-kpi-accent-green', + }, + { + icon: 'truck', + label: 'Unattached devices', + value: this.unattachedDeviceCount, + help: this.unattachedDeviceCount > 0 ? 'Devices need vehicles' : 'No open attachment count', + action: 'Attach to vehicles', + routeAction: 'attachments', + accent: 'amber', + accentClass: 'fleetops-connectivity-kpi-accent-amber', + }, + { + icon: 'wave-square', + label: 'Health warnings', + value: this.healthWarningCount, + help: this.healthWarningCount > 0 ? 'Warnings need review' : 'No warnings', + action: 'Review health', + routeAction: 'health', + accent: 'rose', + accentClass: 'fleetops-connectivity-kpi-accent-rose', + }, + ]; + } + + statusForProvider(provider) { + if (provider.connected) { + return provider.integration?.status ?? 'connected'; + } + + return 'not-connected'; + } + + @action openProvider(provider) { + if (provider.integration) { + return this.telematicActions.transition.view(provider.integration); + } + + return this.telematicActions.transition.create(provider.key); + } + + @action refresh() { + return this.telematicActions.refresh(); + } + + @action setupTable(table) { + this.table = table; + } + + @action runKpiAction(widget) { + if (widget.routeAction === 'create') { + return this.telematicActions.transition.create(); + } + + const integration = this.integrations[0]; + + if (!integration) { + return this.telematicActions.transition.create(); + } + + return this.telematicActions.transition.view(integration); + } +} diff --git a/addon/controllers/connectivity/telematics/index.js b/addon/controllers/connectivity/telematics/index.js index 76eb79a36..b9966a2dc 100644 --- a/addon/controllers/connectivity/telematics/index.js +++ b/addon/controllers/connectivity/telematics/index.js @@ -1,11 +1,15 @@ import Controller from '@ember/controller'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; import fleetOpsOptions from '../../../utils/fleet-ops-options'; export default class ConnectivityTelematicsIndexController extends Controller { @service telematicActions; + @service fetch; @service intl; + @service notifications; + @tracked providers = []; /** query params */ @tracked queryParams = ['name', 'provider', 'status', 'page', 'limit', 'sort', 'query', 'public_id', 'created_at', 'updated_at']; @@ -17,6 +21,11 @@ export default class ConnectivityTelematicsIndexController extends Controller { @tracked provider; @tracked status; + constructor() { + super(...arguments); + this.loadProviders.perform(); + } + /** action buttons */ @tracked actionButtons = [ { @@ -59,11 +68,11 @@ export default class ConnectivityTelematicsIndexController extends Controller { { sticky: true, label: 'Provider', - valuePath: 'provider', - cellComponent: 'table/cell/anchor', - cellClassNames: 'uppercase', + valuePath: 'name', + cellComponent: 'cell/telematic-provider', action: this.telematicActions.transition.view, permission: 'fleet-ops view telematic', + width: 460, resizable: true, sortable: true, filterable: true, @@ -137,4 +146,12 @@ export default class ConnectivityTelematicsIndexController extends Controller { searchable: false, }, ]; + + @task *loadProviders() { + try { + this.providers = yield this.fetch.get('telematics/providers'); + } catch (error) { + this.notifications.serverError(error); + } + } } diff --git a/addon/controllers/connectivity/telematics/index/details.js b/addon/controllers/connectivity/telematics/index/details.js index 75da30384..66adb14aa 100644 --- a/addon/controllers/connectivity/telematics/index/details.js +++ b/addon/controllers/connectivity/telematics/index/details.js @@ -1,10 +1,12 @@ import Controller from '@ember/controller'; import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; import { task } from 'ember-concurrency'; export default class ConnectivityTelematicsIndexDetailsController extends Controller { @service hostRouter; @service fetch; + @service modalsManager; @service notifications; get tabs() { @@ -17,6 +19,10 @@ export default class ConnectivityTelematicsIndexDetailsController extends Contro route: 'connectivity.telematics.index.details.devices', label: 'Devices', }, + { + route: 'connectivity.telematics.index.details.attachments', + label: 'Vehicle Attachments', + }, { route: 'connectivity.telematics.index.details.sensors', label: 'Sensors', @@ -25,7 +31,10 @@ export default class ConnectivityTelematicsIndexDetailsController extends Contro route: 'connectivity.telematics.index.details.events', label: 'Events', }, - ]; + ].map((tab) => ({ + ...tab, + active: this.isTabActive(tab.route), + })); } get actionButtons() { @@ -33,8 +42,7 @@ export default class ConnectivityTelematicsIndexDetailsController extends Contro { icon: 'plug', text: 'Test', - onClick: () => this.testConnection.perform(), - isLoading: this.testConnection.isRunning, + onClick: () => this.openConnectionTestDialog(), }, { icon: 'satellite-dish', @@ -43,7 +51,7 @@ export default class ConnectivityTelematicsIndexDetailsController extends Contro isLoading: this.discoverDevices.isRunning, }, { - icon: 'pencil', + icon: 'cog', fn: () => this.hostRouter.transitionTo('console.fleet-ops.connectivity.telematics.index.edit', this.model), }, ]; @@ -53,18 +61,43 @@ export default class ConnectivityTelematicsIndexDetailsController extends Contro return this.model?.id; } - @task *testConnection() { - try { - const result = yield this.fetch.post(`telematics/${this.telematicId}/test-connection`); - if (result.success) { - this.notifications.success(result.message ?? 'Connection successful.'); - } else { - this.notifications.error(result.message ?? 'Connection test failed.'); - } - yield this.hostRouter.refresh(); - } catch (error) { - this.notifications.serverError(error); + isTabActive(routeName) { + return this.hostRouter.currentRouteName?.endsWith(routeName); + } + + get healthStatus() { + if (this.model?.meta?.last_error || this.model?.meta?.last_sync_error || ['error', 'degraded', 'disconnected'].includes(this.model?.status)) { + return 'warning'; + } + + if (['active', 'connected'].includes(this.model?.status)) { + return 'success'; + } + + return 'default'; + } + + get statusLabel() { + if (this.healthStatus === 'success') { + return 'Healthy'; } + + if (this.healthStatus === 'warning') { + return 'Needs attention'; + } + + return 'Not verified'; + } + + @action openConnectionTestDialog() { + this.modalsManager.show('modals/telematic-connection-diagnostics', { + title: 'Test Connection', + acceptButtonText: 'Run Test', + acceptButtonIcon: 'plug', + declineButtonText: 'Close', + telematic: this.model, + onTested: () => this.hostRouter.refresh(), + }); } @task *discoverDevices() { diff --git a/addon/controllers/connectivity/telematics/index/details/attachments.js b/addon/controllers/connectivity/telematics/index/details/attachments.js new file mode 100644 index 000000000..837932af4 --- /dev/null +++ b/addon/controllers/connectivity/telematics/index/details/attachments.js @@ -0,0 +1,45 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; + +export default class ConnectivityTelematicsIndexDetailsAttachmentsController extends Controller { + @service deviceActions; + @service intl; + + get unattachedDevices() { + return Array.from(this.model ?? []).filter((device) => !device.attachable_uuid); + } + + get columns() { + return [ + { + sticky: true, + label: this.intl.t('column.name'), + valuePath: 'displayName', + cellComponent: 'table/cell/anchor', + action: this.deviceActions.transition.view, + permission: 'fleet-ops view device', + resizable: true, + sortable: true, + }, + { + label: 'Provider ID', + valuePath: 'device_id', + resizable: true, + sortable: true, + }, + { + label: 'Attached Vehicle', + valuePath: 'attached_to_name', + resizable: true, + sortable: true, + }, + { + label: this.intl.t('column.status'), + valuePath: 'status', + cellComponent: 'table/cell/status', + resizable: true, + sortable: true, + }, + ]; + } +} diff --git a/addon/controllers/connectivity/telematics/index/index.js b/addon/controllers/connectivity/telematics/index/index.js new file mode 100644 index 000000000..6890a6adb --- /dev/null +++ b/addon/controllers/connectivity/telematics/index/index.js @@ -0,0 +1,5 @@ +import Controller, { inject as controller } from '@ember/controller'; + +export default class ConnectivityTelematicsIndexIndexController extends Controller { + @controller('connectivity.telematics.index') index; +} diff --git a/addon/controllers/connectivity/telematics/index/new.js b/addon/controllers/connectivity/telematics/index/new.js index 9e34b3101..713f9b223 100644 --- a/addon/controllers/connectivity/telematics/index/new.js +++ b/addon/controllers/connectivity/telematics/index/new.js @@ -12,6 +12,8 @@ export default class ConnectivityTelematicsIndexNewController extends Controller @service events; @tracked overlay; @tracked telematic = this.telematicActions.createNewInstance(); + queryParams = ['setupProvider']; + @tracked setupProvider; @task *save(telematic) { try { @@ -31,4 +33,8 @@ export default class ConnectivityTelematicsIndexNewController extends Controller @action resetForm() { this.telematic = this.telematicActions.createNewInstance(); } + + @action cancel() { + return this.hostRouter.transitionTo('console.fleet-ops.connectivity.telematics.index'); + } } diff --git a/addon/routes.js b/addon/routes.js index be671c947..8f8dd94ae 100644 --- a/addon/routes.js +++ b/addon/routes.js @@ -148,11 +148,13 @@ export default buildRoutes(function () { this.route('connectivity', function () { this.route('telematics', function () { this.route('index', { path: '/' }, function () { + this.route('index', { path: '/' }); this.route('new'); this.route('edit', { path: '/edit/:public_id' }); this.route('details', { path: '/:public_id' }, function () { this.route('index', { path: '/' }); this.route('devices'); + this.route('attachments'); this.route('sensors'); this.route('events'); }); diff --git a/addon/routes/connectivity/telematics/index/details/attachments.js b/addon/routes/connectivity/telematics/index/details/attachments.js new file mode 100644 index 000000000..5da971149 --- /dev/null +++ b/addon/routes/connectivity/telematics/index/details/attachments.js @@ -0,0 +1,14 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class ConnectivityTelematicsIndexDetailsAttachmentsRoute extends Route { + @service store; + + model() { + const telematic = this.modelFor('connectivity.telematics.index.details'); + + return this.store.query('device', { + telematic_uuid: telematic.id, + }); + } +} diff --git a/addon/routes/connectivity/telematics/index/index.js b/addon/routes/connectivity/telematics/index/index.js new file mode 100644 index 000000000..892ac69b7 --- /dev/null +++ b/addon/routes/connectivity/telematics/index/index.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class ConnectivityTelematicsIndexIndexRoute extends Route { + model() { + return this.modelFor('connectivity.telematics.index'); + } +} diff --git a/addon/services/telematic-actions.js b/addon/services/telematic-actions.js index dd6f1920d..f221c819e 100644 --- a/addon/services/telematic-actions.js +++ b/addon/services/telematic-actions.js @@ -9,7 +9,13 @@ export default class TelematicActionsService extends ResourceActionService { transition = { view: (telematic) => this.transitionTo('connectivity.telematics.index.details', telematic), edit: (telematic) => this.transitionTo('connectivity.telematics.index.edit', telematic), - create: () => this.transitionTo('connectivity.telematics.index.new'), + create: (provider) => { + if (provider) { + return this.transitionTo('connectivity.telematics.index.new', { queryParams: { setupProvider: provider } }); + } + + return this.transitionTo('connectivity.telematics.index.new'); + }, }; panel = { diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css index efeb1a593..8726fb121 100644 --- a/addon/styles/fleetops-engine.css +++ b/addon/styles/fleetops-engine.css @@ -161,6 +161,130 @@ body.fleetbase-console .next-content-overlay > .next-content-overlay-panel-conta flex-shrink: 0; } +.fleetops-connectivity-kpi-tile { + transition: + transform 160ms ease-out, + box-shadow 160ms ease-out, + border-color 160ms ease-out; +} + +.fleetops-connectivity-kpi-tile:hover { + box-shadow: 0 8px 24px -12px rgb(15 23 42 / 35%); +} + +.fleetops-connectivity-kpi-tile .fleetops-connectivity-kpi-icon { + color: #64748b; + background-color: rgb(100 116 139 / 12%); +} + +.fleetops-connectivity-kpi-accent-blue { + border-color: rgb(59 130 246 / 28%); + background-image: linear-gradient(135deg, rgb(59 130 246 / 10%) 0%, rgb(59 130 246 / 0%) 62%); +} + +.fleetops-connectivity-kpi-accent-green { + border-color: rgb(16 185 129 / 28%); + background-image: linear-gradient(135deg, rgb(16 185 129 / 10%) 0%, rgb(16 185 129 / 0%) 62%); +} + +.fleetops-connectivity-kpi-accent-amber { + border-color: rgb(245 158 11 / 32%); + background-image: linear-gradient(135deg, rgb(245 158 11 / 12%) 0%, rgb(245 158 11 / 0%) 62%); +} + +.fleetops-connectivity-kpi-accent-rose { + border-color: rgb(244 63 94 / 28%); + background-image: linear-gradient(135deg, rgb(244 63 94 / 10%) 0%, rgb(244 63 94 / 0%) 62%); +} + +.fleetops-connectivity-kpi-accent-blue .fleetops-connectivity-kpi-icon { + color: rgb(37 99 235); + background-color: rgb(59 130 246 / 12%); +} + +.fleetops-connectivity-kpi-accent-green .fleetops-connectivity-kpi-icon { + color: rgb(5 150 105); + background-color: rgb(16 185 129 / 12%); +} + +.fleetops-connectivity-kpi-accent-amber .fleetops-connectivity-kpi-icon { + color: rgb(217 119 6); + background-color: rgb(245 158 11 / 14%); +} + +.fleetops-connectivity-kpi-accent-rose .fleetops-connectivity-kpi-icon { + color: rgb(225 29 72); + background-color: rgb(244 63 94 / 12%); +} + +body[data-theme='dark'] .fleetops-connectivity-kpi-accent-blue .fleetops-connectivity-kpi-icon { + color: rgb(147 197 253); +} + +body[data-theme='dark'] .fleetops-connectivity-kpi-accent-green .fleetops-connectivity-kpi-icon { + color: rgb(110 231 183); +} + +body[data-theme='dark'] .fleetops-connectivity-kpi-accent-amber .fleetops-connectivity-kpi-icon { + color: rgb(252 211 77); +} + +body[data-theme='dark'] .fleetops-connectivity-kpi-accent-rose .fleetops-connectivity-kpi-icon { + color: rgb(251 113 133); +} + +.fleetops-provider-connections-actions .fleetops-provider-connections-search { + min-width: 16rem; + height: 2.25rem; +} + +.fleetops-provider-connections-header { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0.75rem; +} + +@media (min-width: 1024px) { + .fleetops-provider-connections-header { + grid-template-columns: minmax(0, 26rem) minmax(22rem, max-content); + align-items: start; + justify-content: space-between; + } +} + +.fleetops-provider-connections-actions .fleetops-provider-connections-action-button, +.fleetops-provider-connections-actions button { + min-height: 2.25rem; +} + +.fleetops-provider-connections-panel .table-wrapper table tbody tr td, +.fleetops-provider-connections-panel .next-table-wrapper table tbody tr td { + vertical-align: top; +} + +body[data-theme='dark'] .fleetops-provider-connections-panel .table-wrapper.table-fluid, +body[data-theme='dark'] .fleetops-provider-connections-panel .table-wrapper.table-fluid table, +body[data-theme='dark'] .fleetops-provider-connections-panel .table-wrapper.table-fluid table tbody, +body[data-theme='dark'] .fleetops-provider-connections-panel .table-wrapper.table-fluid table tbody tr, +body[data-theme='dark'] .fleetops-provider-connections-panel .table-wrapper.table-fluid table tbody tr td, +body[data-theme='dark'] .fleetops-provider-connections-panel .next-table-wrapper, +body[data-theme='dark'] .fleetops-provider-connections-panel .next-table-wrapper table, +body[data-theme='dark'] .fleetops-provider-connections-panel .next-table-wrapper table tbody, +body[data-theme='dark'] .fleetops-provider-connections-panel .next-table-wrapper table tbody tr, +body[data-theme='dark'] .fleetops-provider-connections-panel .next-table-wrapper table tbody tr td { + background-color: rgb(17 24 39); +} + +body[data-theme='dark'] .fleetops-provider-connections-panel .table-wrapper.table-fluid table tbody tr:nth-child(even):not(.is-selected) td, +body[data-theme='dark'] .fleetops-provider-connections-panel .next-table-wrapper table tbody tr:nth-child(even):not(.is-selected) td { + background-color: rgb(17 24 39); +} + +.fleetops-provider-connections-panel div.has-floating-pagination, +.fleetops-provider-connections-panel .next-table-wrapper.has-floating-pagination { + padding-bottom: 0; +} + /** calendar event css mods */ #fleet-ops-scheduler-calendar .fc-h-event .fc-event-main .fc-event-title { diff --git a/addon/templates/connectivity/telematics/index.hbs b/addon/templates/connectivity/telematics/index.hbs index 8c049c952..c24cd6895 100644 --- a/addon/templates/connectivity/telematics/index.hbs +++ b/addon/templates/connectivity/telematics/index.hbs @@ -1,14 +1 @@ - -{{outlet}} \ No newline at end of file +{{outlet}} diff --git a/addon/templates/connectivity/telematics/index/details.hbs b/addon/templates/connectivity/telematics/index/details.hbs index 94ae43b93..acca628b1 100644 --- a/addon/templates/connectivity/telematics/index/details.hbs +++ b/addon/templates/connectivity/telematics/index/details.hbs @@ -1,14 +1,52 @@ - - - {{outlet}} - - \ No newline at end of file +
+
+
+
+
+ +
+
+

{{or @model.name @model.provider_descriptor.label @model.provider}}

+ {{this.statusLabel}} +
+
+ {{@model.provider_descriptor.label}} + {{#if @model.public_id}} + / + {{@model.public_id}} + {{/if}} +
+
+ Last test: {{n-a (format-date-fns @model.meta.last_connection_test "dd MMM HH:mm")}} + Last sync: {{n-a (format-date-fns @model.meta.last_sync_completed_at "dd MMM HH:mm")}} + Devices: {{n-a @model.meta.last_sync_total}} +
+
+
+ +
+
+
+ + +
+ +
+ {{outlet}} +
+
+
diff --git a/addon/templates/connectivity/telematics/index/details/attachments.hbs b/addon/templates/connectivity/telematics/index/details/attachments.hbs new file mode 100644 index 000000000..372ade3e4 --- /dev/null +++ b/addon/templates/connectivity/telematics/index/details/attachments.hbs @@ -0,0 +1,39 @@ +
+
+
+

Vehicle Attachments

+

Review synced devices and attach them to the vehicles they report from.

+
+ + {{this.unattachedDevices.length}} unattached + +
+ + {{#if this.unattachedDevices.length}} +
+ {{#each this.unattachedDevices as |device|}} +
+
+
+
{{or device.displayName device.name device.device_id}}
+
{{n-a device.device_id}}
+
+ Needs vehicle +
+
+
+
+ {{/each}} +
+ {{/if}} + + +
diff --git a/addon/templates/connectivity/telematics/index/details/index.hbs b/addon/templates/connectivity/telematics/index/details/index.hbs index 1d9381dd0..66428d927 100644 --- a/addon/templates/connectivity/telematics/index/details/index.hbs +++ b/addon/templates/connectivity/telematics/index/details/index.hbs @@ -1,2 +1 @@ - \ No newline at end of file diff --git a/addon/templates/connectivity/telematics/index/edit.hbs b/addon/templates/connectivity/telematics/index/edit.hbs index 02ffced69..7c0575b48 100644 --- a/addon/templates/connectivity/telematics/index/edit.hbs +++ b/addon/templates/connectivity/telematics/index/edit.hbs @@ -1,12 +1,26 @@ - - - - \ No newline at end of file +
+
+
+
+
Connection settings
+

Edit {{or @model.name @model.provider}}

+

Update credentials, webhook configuration, and hardware identity for this provider connection.

+
+
+
+
+
+ + +
diff --git a/addon/templates/connectivity/telematics/index/index.hbs b/addon/templates/connectivity/telematics/index/index.hbs new file mode 100644 index 000000000..37cbd75fb --- /dev/null +++ b/addon/templates/connectivity/telematics/index/index.hbs @@ -0,0 +1,12 @@ + diff --git a/addon/templates/connectivity/telematics/index/new.hbs b/addon/templates/connectivity/telematics/index/new.hbs index d12520f1f..a927dd207 100644 --- a/addon/templates/connectivity/telematics/index/new.hbs +++ b/addon/templates/connectivity/telematics/index/new.hbs @@ -1,12 +1,24 @@ - - - - \ No newline at end of file +
+
+
+
+
Connectivity setup
+

Connect Provider

+

Create a provider connection, test credentials, and prepare device sync.

+
+
+
+ + +
diff --git a/app/components/cell/telematic-provider.js b/app/components/cell/telematic-provider.js new file mode 100644 index 000000000..c3f92800e --- /dev/null +++ b/app/components/cell/telematic-provider.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/cell/telematic-provider'; diff --git a/app/components/modals/telematic-connection-diagnostics.js b/app/components/modals/telematic-connection-diagnostics.js new file mode 100644 index 000000000..d5144f191 --- /dev/null +++ b/app/components/modals/telematic-connection-diagnostics.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/modals/telematic-connection-diagnostics'; diff --git a/app/components/telematic/hub.js b/app/components/telematic/hub.js new file mode 100644 index 000000000..0db5e84e2 --- /dev/null +++ b/app/components/telematic/hub.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/telematic/hub'; diff --git a/app/controllers/connectivity/telematics/index/details/attachments.js b/app/controllers/connectivity/telematics/index/details/attachments.js new file mode 100644 index 000000000..83a3c1191 --- /dev/null +++ b/app/controllers/connectivity/telematics/index/details/attachments.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/controllers/connectivity/telematics/index/details/attachments'; diff --git a/app/controllers/connectivity/telematics/index/index.js b/app/controllers/connectivity/telematics/index/index.js new file mode 100644 index 000000000..9c7acc552 --- /dev/null +++ b/app/controllers/connectivity/telematics/index/index.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/controllers/connectivity/telematics/index/index'; diff --git a/app/routes/connectivity/telematics/index/details/attachments.js b/app/routes/connectivity/telematics/index/details/attachments.js new file mode 100644 index 000000000..1bcce61b1 --- /dev/null +++ b/app/routes/connectivity/telematics/index/details/attachments.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/routes/connectivity/telematics/index/details/attachments'; diff --git a/app/routes/connectivity/telematics/index/index.js b/app/routes/connectivity/telematics/index/index.js new file mode 100644 index 000000000..4315abf79 --- /dev/null +++ b/app/routes/connectivity/telematics/index/index.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/routes/connectivity/telematics/index/index'; diff --git a/app/templates/connectivity/telematics/index/details/attachments.js b/app/templates/connectivity/telematics/index/details/attachments.js new file mode 100644 index 000000000..c6c93b6dc --- /dev/null +++ b/app/templates/connectivity/telematics/index/details/attachments.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/templates/connectivity/telematics/index/details/attachments'; diff --git a/app/templates/connectivity/telematics/index/index.js b/app/templates/connectivity/telematics/index/index.js new file mode 100644 index 000000000..ad46260c6 --- /dev/null +++ b/app/templates/connectivity/telematics/index/index.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/templates/connectivity/telematics/index/index'; diff --git a/assets/images/telematics/providers/afaqy.webp b/assets/images/telematics/providers/afaqy.webp new file mode 100644 index 0000000000000000000000000000000000000000..bd0e8cafd6ff5dcf779627f9aa671d843c569913 GIT binary patch literal 1018 zcmVz00Dp++g2^< zI3$I-F^~g+kOPj0uve1I4FZk7-90n!Q}vFWj))1sg{gG4?T@=ndr7!o+VV$VP43Cl z3C?_-sFPH=s5;SlMHo(Xgj3O+ti=;?Io%Zx1#`j{jtr+f4pb*y{}87wzbP;MB|Gu_ zV=|in#HkC=xHVvLTS!hm6?Ubw>&i}zoo#m8zyAOF{~v&x-8Ocn?6kA%gj`hI9L% zgIi(pjo-iskBVX5Q|nULJ6-0UQ{pIY!h;w`=ljZJqJzUVP$zw0F90Mg5R&1i5T(Xp z$`sApgovGt@xt!<-;;GxNVx>q-4LXaq7IMSO#vnPj-A_~$j&!pd; zj4G>Z=XF?;`?%e*8)v+aObJA-H_r5Ueij2Bt@*TI0Fy3IBk4bw_7`pVK?3>Fj_gks zM7Kzo%F%J`mbJS`T6`B?aSOiSq5#ibVJ;R9x6Y-1rz00Dp++g2^< zI3$I-F^~g+kOPj0uve1I4FZk7-90n!Q}vFWj))1sg{gG4?T@=ndr7!o+VV$VP43Cl z3C?_-sFPH=s5;SlMHo(Xgj3O+ti=;?Io%Zx1#`j{jtr+f4pb*y{}87wzbP;MB|Gu_ zV=|in#HkC=xHVvLTS!hm6?Ubw>&i}zoo#m8zyAOF{~v&x-8Ocn?6kA%gx+=YFopmpvB*$-n2X8 ziecA;@p}n^DQ42LfjaVw9`{|Wf@USvL~I04NNqLmCIG%Zgtk*&0WUnP}v9b32Mq{rQ?vUp<^D$r}%GWpT=A>!Y}4V8r{QH0~vMJvWFr zn-!Y5aM)FHS4QI)es2owE@s?$CEy~N`tu*$dnz0X09?<`Ib4HXb9Q&k_XU8X_)u|A zYrq;YPbaIB0GaGz_nBoVyuU%Q>>&%r+gQS!4_31oXJ*#c^e<_LY5w{E$F z{wIEPFW$PWlE)lQ?pzm)N;C5xBG{l^;4g{Lmy+<|IJ(o6I^^5?v-&f%m$A@J^KuZg z=+rN1{ql#PQKIQuut;LCR^l$!qYeP`my1QISqx!6lk1o@4mu2_khrMqKs1r2FXZUQ za-@*Y8v$9jhIgoc5h0swKlDDwacSdX(FV$%zCJyN2k_%W9tZN?$Y_gMv$7G z@3dhbb3Twa5oywHzJ;M6M6)?YkoxkZ!S1rHHKRUC2M%ojc&7QB9cob;>ba964$j)q zp@M}Ie3kAx$f3TdKgfSJ*=|l8qw>@#+Dlt z-fPg2+2SVd9sIfq9eg;rJ?q_DC_rg1N5BzuvME?}&?LB6Ksa!LW)@%LT<@5{*_KQ~ z2?T!hUnsH-lM4pyL1vNhCz8Z-lB#?MI1{*nuW12pzxhSF*~0Exo^Q}lodd}k-2~xh z9Kew{J8Z`@z_j3Za(W&D75MdK3AuOwT!8^5lW)sZQW9|=`?%1n;v!xd3Nsl%?!pK~ zVCVTxNY+0uQ&C9OMtJ@RH(5Lh=nA^vew*|+ZSDqgi>mwLPpso0k%~SMsUkhuj71-^ zxEj~!7#dC6w2{W{nl=LV$He>h3HEvnY^j^}Ad9#UF`DhATnyyG_Bd(J1z00Dp++g2^< zI3$I-F^~g+kOPj0uve1I4FZk7-90n!Q}vFWj))1sg{gG4?T@=ndr7!o+VV$VP43Cl z3C?_-sFPH=s5;SlMHo(Xgj3O+ti=;?Io%Zx1#`j{jtr+f4pb*y{}87wzbP;MB|Gu_ zV=|in#HkC=xHVvLTS!hm6?Ubw>&i}zoo#m8zyAOF{~v&x-8Ocn?6kA%g9nVOnH`UT?+it7}4jq_92yf63X75wkuB3+dMgz+Qe$l&cdTTRHy=4Vvd$JvfwzcCRh8-JaSkTf@W!+f_7NL+m2JmwM-#h;VzUYwdu@n4zv# zHrFpC)z}82)%KyvJn#WGRaK>OuuX3p>q2 zSnK?#err2TGM>r&3b)C>%qU*TNQ0+2hI`jleS4^ed%E)#^ zA7V9fD+m3>O%zi&=d^>ncJ?qxc&gR?g6ODOyC_EauP@}5iPjI78x_G#JmHr4I7}s{ z_JAJRQjvQStf3QEdi1L4j!BInFLLDJO^VIG_MVU(V;&AP--)5eu+6dr0CfgWQ{)^h zF!YWyN1)~x2c9L(dsO^wL#b1ESuTF24TAV$3a8HsefQSw82w$W zO$Xl}xeBAN@k(?tzu2mff8o0PHFxsL061InFn>8*lT&^#u71t=^Sjg@lcuR`t~_u* MEN16}000000J;Yk-T(jq literal 0 HcmV?d00001 diff --git a/assets/images/telematics/providers/geotab.webp b/assets/images/telematics/providers/geotab.webp new file mode 100644 index 0000000000000000000000000000000000000000..59854c9f27dddd7f8a8fba4af57abdcb2c04f05d GIT binary patch literal 812 zcmV+{1JnFcNk&E_0{{S5MM6+kP&il$0000G0001g004gg06|PpNb~>z00Dp++g2^< zI3$I-F^~g+kOPj0uve1I4FZk7-90n!Q}vFWj))1sg{gG4?T@=ndr7!o+VV$VP43Cl z3C?_-sFPH=s5;SlMHo(Xgj3O+ti=;?Io%Zx1#`j{jtr+f4pb*y{}87wzbP;MB|Gu_ zV=|in#HkC=xHVvLTS!hm6?Ubw>&i}zoo#m8zyAOF{~v&x-8Ocn?6kA%gobM$#$b0b^Y&u~v%g#FrR97F3+&-9U4FRm+|X2@ zi4V^KPd9AkN?yYim5hor+a<+}ydS~S0sK#1A@&)#1Lv6UsNUv;Ru>*aX}=yV(uh)! ze8@LJ>D%}~0RFc$05&%(<+cAu{oneMqCc-%Tbpu1P-AI)NsZ3BBc?22^-PqABS4nf zA|0LnSlIys`QEH_(XCu^u94|rV`&dFw=;iT6>3Li`I*Z82ccg7?Yw+Fz;&1A!1TLUyS#`Tpyh61lh(-v56H?Gb3 z;B~^SlT;0-Meg%%F+c60Be=zov~3X`_7J8m(p7Wr?GYHnbBOzFTn&}X((INk1pEe4 zSo@zfkRT01c$~mLNzU0BzCv4W) zqKJH}1OB|^q*0Q^D6;7v7_;#5(P#iW`w!`fF*6uv7_+Xx93Tafi;d}jjWnC9LyRAU qlxs0>c3Yb5?AJe7J2-lRyvwWwv%yj?&0001knSee3 literal 0 HcmV?d00001 diff --git a/assets/images/telematics/providers/safee.webp b/assets/images/telematics/providers/safee.webp new file mode 100644 index 0000000000000000000000000000000000000000..99c8bb9c038aa5c37e14531d4475b8f257638f49 GIT binary patch literal 848 zcmV-W1F!s2Nk&FU0{{S5MM6+kP&il$0000G0001g004gg06|PpNb~>z00Dp++g2^< zI3$I-F^~g+kOPj0uve1I4FZk7-90n!Q}vFWj))1sg{gG4?T@=ndr7!o+VV$VP43Cl z3C?_-sFPH=s5;SlMHo(Xgj3O+ti=;?Io%Zx1#`j{jtr+f4pb*y{}87wzbP;MB|Gu_ zV=|in#HkC=xHVvLTS!hm6?Ubw>&i}zoo#m8zyAOF{~v&x-8Ocn?6kA%g#)D-x;B(bJn)@bIfY;APD>~%gW%&+vbAk~8e|ttyUPU>MeiC77>jNvHYat*jEjv!4+$pXA!my&nq& zoe{Ts&k62drK>qtpaWWXdGOX~r4@zf0RH*|K-s@AJ2Yfb2gt2E7uFuaW5HYZ7|wB5 z76rT%0GyUiQnmwcO>j!N$|)-z&^BMrn*C9J00P|by+iC$xziteCU z-h~TZ2|ClmPnp@L$Sr0Gn7Tr6PdCWX=qI$*rz9~NOT51gk3+G9is2+GBh1AF@pD( z(#t0;Ng!OcfC%H^MM+ej735?QIkUU=3P=1aR>xFPR2y+7UarpB9!K##Y&oAb&BnNy ado2a=#tSbykAzFSeE-b=@qVJg0001t`G67t literal 0 HcmV?d00001 diff --git a/assets/images/telematics/providers/samsara.webp b/assets/images/telematics/providers/samsara.webp new file mode 100644 index 0000000000000000000000000000000000000000..71ec4320b151cce2d22bd4ec18878244243d4476 GIT binary patch literal 1272 zcmVz00Dp++g2^< zI3$I-F^~g+kOPj0uve1I4FZk7-90n!Q}vFWj))1sg{gG4?T@=ndr7!o+VV$VP43Cl z3C?_-sFPH=s5;SlMHo(Xgj3O+ti=;?Io%Zx1#`j{jtr+f4pb*y{}87wzbP;MB|Gu_ zV=|in#HkC=xHVvLTS!hm6?Ubw>&i}zoo#m8zyAOF{~v&x-8Ocn?6kA%gS&h+&r8&P;JIc-nK*Rt5{`$6n901!F zF1IH3c`qc4v$RqGdy@li6TvisbYdAXM9SQ{Y;_;MnI!CNprA@tRmQ#h_co|lHG-)~z6i31@B~hao#XJz zInu`}oqAoly7U(oh;xf@pZ9=OJUl>c4F3^OXk93pBzBCsx#ZYKgy^3`8R^~J07QB5 zYXR*?N4G5!!gH1Mm-=neV$D<&HF)%SRrF#sKTF9kxC4=1=bJajVJckLgpvqmqx5iM z)-Wk);I`&Br0lAOf7!EfpG!qWuvB6?Q*eaHCUHbh&o#M(JPjMM&C5x>^^+37Qzc+Q z{B3=?49QAG-n36tO%o$#TLj2TR5!tNYaj?1RRF;r7tp z_#9-s^4=1Q(Y|Qjdq3L%kE5Wz!$5MbNOcIzCnmE0Vce2u;T|0lx5bu3$NpH+Z6QjkN4 'Flespi', 'type' => 'native', 'driver_class' => \Fleetbase\FleetOps\Support\Telematics\Providers\FlespiProvider::class, - 'icon' => 'https://flespi.com/favicon.ico', + 'icon' => '/engines-dist/images/telematics/providers/flespi.webp', 'description' => 'Flespi is a robust telematics platform offering device management, data processing, and API integration.', 'docs_url' => 'https://flespi.com/docs', 'required_fields' => [ @@ -40,7 +40,7 @@ 'label' => 'Geotab', 'type' => 'native', 'driver_class' => \Fleetbase\FleetOps\Support\Telematics\Providers\GeotabProvider::class, - 'icon' => 'https://www.geotab.com/favicon.ico', + 'icon' => '/engines-dist/images/telematics/providers/geotab.webp', 'description' => 'Geotab provides fleet management solutions with GPS tracking, driver safety, and compliance features.', 'docs_url' => 'https://developers.geotab.com/', 'required_fields' => [ @@ -79,7 +79,7 @@ 'label' => 'Samsara', 'type' => 'native', 'driver_class' => \Fleetbase\FleetOps\Support\Telematics\Providers\SamsaraProvider::class, - 'icon' => 'https://www.samsara.com/favicon.ico', + 'icon' => '/engines-dist/images/telematics/providers/samsara.webp', 'description' => 'Samsara offers IoT solutions for fleet operations, including GPS tracking, dashcams, and asset monitoring.', 'docs_url' => 'https://developers.samsara.com/', 'required_fields' => [ @@ -112,7 +112,7 @@ 'label' => 'AFAQY', 'type' => 'native', 'driver_class' => \Fleetbase\FleetOps\Support\Telematics\Providers\AfaqyProvider::class, - 'icon' => 'https://api.afaqy.sa/docs/img/favicon.ico', + 'icon' => '/engines-dist/images/telematics/providers/afaqy.webp', 'description' => 'AFAQY AVL REST integration for unit discovery, live position, speed, heading, odometer, fuel, and signal data.', 'docs_url' => 'https://api.afaqy.sa/docs/', 'required_fields' => [ @@ -163,6 +163,7 @@ 'label' => 'Safee Tracking', 'type' => 'native', 'driver_class' => \Fleetbase\FleetOps\Support\Telematics\Providers\SafeeProvider::class, + 'icon' => '/engines-dist/images/telematics/providers/safee.webp', 'description' => 'Safee Tracking REST integration for vehicle discovery, last state, live positions, odometer, fuel, and sensor data.', 'required_fields' => [ [ diff --git a/server/src/Contracts/TelematicProviderDescriptor.php b/server/src/Contracts/TelematicProviderDescriptor.php index 6951c4d59..9001e80ab 100644 --- a/server/src/Contracts/TelematicProviderDescriptor.php +++ b/server/src/Contracts/TelematicProviderDescriptor.php @@ -12,6 +12,8 @@ */ class TelematicProviderDescriptor { + public const DEFAULT_ICON = '/engines-dist/images/telematics/providers/default.webp'; + public string $key; public string $label; public string $type; // 'native' or 'custom' @@ -33,7 +35,7 @@ public function __construct(array $data) $this->label = $data['label']; $this->type = $data['type'] ?? 'native'; $this->driverClass = $data['driver_class'] ?? null; - $this->icon = $data['icon'] ?? null; + $this->icon = $data['icon'] ?? self::DEFAULT_ICON; $this->description = $data['description'] ?? null; $this->docsUrl = $data['docs_url'] ?? null; $this->requiredFields = $data['required_fields'] ?? []; diff --git a/server/src/Http/Controllers/Internal/v1/TelematicController.php b/server/src/Http/Controllers/Internal/v1/TelematicController.php index 6b9331eba..d58dc6d92 100644 --- a/server/src/Http/Controllers/Internal/v1/TelematicController.php +++ b/server/src/Http/Controllers/Internal/v1/TelematicController.php @@ -80,7 +80,16 @@ public function testCredentials(Request $request, string $key): JsonResponse if (!$provider) { return response()->error('Unable to resolve telematic provider.'); } - $result = $provider->testConnection($credentials); + $result = $provider->testConnection($credentials); + $telematicId = $request->input('telematic_id'); + + if ($telematicId) { + $telematic = $this->findTelematic($telematicId); + + if ($telematic->provider === $key) { + $this->telematicService->recordConnectionTest($telematic, $result); + } + } } catch (\Exception $e) { return response()->error($e->getMessage()); } diff --git a/server/tests/TelematicsHardeningTest.php b/server/tests/TelematicsHardeningTest.php index a396ba759..09ccaf8dd 100644 --- a/server/tests/TelematicsHardeningTest.php +++ b/server/tests/TelematicsHardeningTest.php @@ -1,5 +1,7 @@ toContain('last_sync_job_id') ->toContain('last_sync_error'); }); + +test('native telematics providers expose local provider icons with a descriptor fallback', function () { + $config = include __DIR__ . '/../config/telematics.php'; + $iconPath = '/engines-dist/images/telematics/providers/'; + $providers = array_filter($config['providers'], fn ($provider) => ($provider['type'] ?? 'native') === 'native'); + + foreach ($providers as $provider) { + $icon = $provider['icon'] ?? null; + + expect($icon) + ->not->toBeNull() + ->not->toContain('http://') + ->not->toContain('https://'); + + expect(str_starts_with($icon, $iconPath))->toBeTrue(); + expect(str_ends_with($icon, '.webp'))->toBeTrue(); + expect(file_exists(__DIR__ . '/../../assets/images/telematics/providers/' . basename($icon)))->toBeTrue(); + } + + $safee = collect($providers)->firstWhere('key', 'safee'); + + expect($safee['icon'])->toBe($iconPath . 'safee.webp'); + + $descriptor = new TelematicProviderDescriptor([ + 'key' => 'custom', + 'label' => 'Custom', + ]); + + expect($descriptor->icon)->toBe(TelematicProviderDescriptor::DEFAULT_ICON); + expect(file_exists(__DIR__ . '/../../assets/images/telematics/providers/default.webp'))->toBeTrue(); +}); From b156e368c30ffd5235b3b53010068860a05f3b9b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 8 Jun 2026 19:32:21 +0800 Subject: [PATCH 5/5] Improve telematics connectivity workflow --- addon/components/cell/telematic-provider.hbs | 14 ++- addon/components/telematic/details.hbs | 36 +++--- addon/components/telematic/details.js | 105 +++++++++++++++++- addon/components/telematic/hub.hbs | 76 +++++++------ addon/components/telematic/hub.js | 4 + .../connectivity/telematics/index/details.js | 57 ++++++++-- .../connectivity/telematics/index/details.hbs | 5 +- .../telematics/index/details/attachments.hbs | 53 ++++----- server/src/Jobs/SyncTelematicDevicesJob.php | 11 +- .../src/Jobs/TestTelematicConnectionJob.php | 1 - .../Support/Telematics/TelematicService.php | 11 ++ 11 files changed, 265 insertions(+), 108 deletions(-) diff --git a/addon/components/cell/telematic-provider.hbs b/addon/components/cell/telematic-provider.hbs index 1b3961fd1..b9c045977 100644 --- a/addon/components/cell/telematic-provider.hbs +++ b/addon/components/cell/telematic-provider.hbs @@ -1,12 +1,16 @@ -
- +
diff --git a/addon/components/telematic/details.hbs b/addon/components/telematic/details.hbs index dc52ffd30..abeb5423a 100644 --- a/addon/components/telematic/details.hbs +++ b/addon/components/telematic/details.hbs @@ -1,21 +1,29 @@ -
+
{{#each this.healthCards as |card|}} -
-
-
-
- -
+
+
+
-
{{card.label}}
-
{{n-a card.value}}
-
{{n-a (format-date-fns card.detail "dd MMM yyyy, HH:mm")}}
+
{{card.label}}
+
{{n-a card.value}}
+
{{card.help}}
+
+
+
- {{#if card.statusLabel}} - {{card.statusLabel}} - {{/if}} + +
+ {{card.detailLabel}} + + {{#if card.detailIsDate}} + {{n-a (format-date-fns card.detail "dd MMM yyyy, HH:mm")}} + {{else}} + {{n-a card.detail}} + {{/if}} + +
{{/each}} @@ -38,7 +46,7 @@ {{/each}}
{{else}} -
+
diff --git a/addon/components/telematic/details.js b/addon/components/telematic/details.js index 6614a7b6b..6a04bbfde 100644 --- a/addon/components/telematic/details.js +++ b/addon/components/telematic/details.js @@ -38,6 +38,13 @@ export default class TelematicDetailsComponent extends Component { } get lastSyncStatus() { + if (this.args.resource?.status === 'synchronizing') { + return { + status: 'info', + label: 'Syncing', + }; + } + const result = this.args.resource?.meta?.last_sync_result; if (result === 'success') { @@ -57,6 +64,66 @@ export default class TelematicDetailsComponent extends Component { return null; } + get connectionTestValue() { + return this.lastTestStatus?.label ?? 'Not tested'; + } + + get deviceSyncValue() { + if (this.args.resource?.status === 'synchronizing') { + return 'Syncing provider devices'; + } + + if (this.args.resource?.meta?.last_sync_result === 'success') { + return 'Synced'; + } + + if (this.args.resource?.meta?.last_sync_result === 'failed') { + return 'Failed'; + } + + return 'Not synced'; + } + + get deviceSyncDetail() { + if (this.args.resource?.status === 'synchronizing') { + return this.args.resource?.meta?.last_sync_started_at; + } + + return this.args.resource?.meta?.last_sync_completed_at; + } + + get connectionTestAccentClass() { + if (this.lastTestStatus?.status === 'danger') { + return 'fleetops-connectivity-kpi-accent-rose'; + } + + if (this.lastTestStatus?.status === 'success') { + return 'fleetops-connectivity-kpi-accent-green'; + } + + return 'fleetops-connectivity-kpi-accent-blue'; + } + + get deviceSyncAccentClass() { + if (this.lastSyncStatus?.status === 'danger') { + return 'fleetops-connectivity-kpi-accent-rose'; + } + + if (this.lastSyncStatus?.status === 'success') { + return 'fleetops-connectivity-kpi-accent-green'; + } + + if (this.args.resource?.status === 'synchronizing') { + return 'fleetops-connectivity-kpi-accent-blue'; + } + + return 'fleetops-connectivity-kpi-accent-amber'; + } + + get devicesSyncedAccentClass() { + return this.args.resource?.meta?.last_sync_total ? 'fleetops-connectivity-kpi-accent-green' : 'fleetops-connectivity-kpi-accent-blue'; + } + get healthCards() { const resource = this.args.resource; @@ -64,26 +131,38 @@ export default class TelematicDetailsComponent extends Component { { icon: 'plug', label: 'Connection test', - value: resource?.meta?.last_test_result ?? 'Not tested', + value: this.connectionTestValue, + help: 'Provider credentials', + detailLabel: 'Last test', detail: resource?.meta?.last_connection_test, + detailIsDate: true, status: this.lastTestStatus?.status, statusLabel: this.lastTestStatus?.label, + accentClass: this.connectionTestAccentClass, }, { icon: 'satellite-dish', label: 'Device sync', - value: resource?.meta?.last_sync_result ?? 'Not synced', - detail: resource?.meta?.last_sync_completed_at, + value: this.deviceSyncValue, + help: 'Provider device discovery', + detailLabel: this.args.resource?.status === 'synchronizing' ? 'Started' : 'Last sync', + detail: this.deviceSyncDetail, + detailIsDate: true, status: this.lastSyncStatus?.status, statusLabel: this.lastSyncStatus?.label, + accentClass: this.deviceSyncAccentClass, }, { icon: 'microchip', label: 'Devices synced', - value: resource?.meta?.last_sync_total ?? 'None', + value: resource?.meta?.last_sync_total ?? 0, + help: 'Devices from provider', + detailLabel: 'Sync job', detail: resource?.meta?.last_sync_job_id, + detailIsDate: false, status: resource?.meta?.last_sync_total ? 'success' : null, statusLabel: resource?.meta?.last_sync_total ? 'Available' : null, + accentClass: this.devicesSyncedAccentClass, }, ]; } @@ -96,7 +175,7 @@ export default class TelematicDetailsComponent extends Component { items.push({ icon: 'triangle-exclamation', title: 'Connection issue', - description: resource.meta.last_error, + description: this.userFacingIssueMessage(resource.meta.last_error, 'Connection test failed. Review the provider credentials and try again.'), status: 'warning', }); } @@ -105,7 +184,7 @@ export default class TelematicDetailsComponent extends Component { items.push({ icon: 'circle-exclamation', title: 'Sync issue', - description: resource.meta.last_sync_error, + description: this.userFacingIssueMessage(resource.meta.last_sync_error, 'Device sync failed. Review the provider connection and server logs, then try again.'), status: 'warning', }); } @@ -122,6 +201,20 @@ export default class TelematicDetailsComponent extends Component { return items; } + userFacingIssueMessage(message, fallback) { + if (!message || this.isSensitiveIssueMessage(message)) { + return fallback; + } + + return String(message); + } + + isSensitiveIssueMessage(message) { + const value = String(message).toLowerCase(); + + return ['sqlstate', 'insert into', 'update `', 'select ', 'schema', 'stack trace', 'connection:', 'pdoexception'].some((fragment) => value.includes(fragment)); + } + get hardwareFields() { const resource = this.args.resource; diff --git a/addon/components/telematic/hub.hbs b/addon/components/telematic/hub.hbs index 5f6b6d479..520abae98 100644 --- a/addon/components/telematic/hub.hbs +++ b/addon/components/telematic/hub.hbs @@ -53,6 +53,45 @@ {{/each}}
+ {{#if this.hasProviderConnections}} +
+
+
+

Provider Connections

+

Manage connected provider accounts, review sync status, and open a connection to sync devices or update settings.

+
+
+ +
+
+ +
+ {{/if}} +
@@ -92,43 +131,6 @@ {{/each}}
- -
-
-
-

Provider Connections

-

Manage connected provider accounts, review sync status, and open a connection to sync devices or update settings.

-
-
- -
-
- -
diff --git a/addon/components/telematic/hub.js b/addon/components/telematic/hub.js index a987851be..fd3989916 100644 --- a/addon/components/telematic/hub.js +++ b/addon/components/telematic/hub.js @@ -23,6 +23,10 @@ export default class TelematicHubComponent extends Component { return new Set(this.integrations.map((integration) => integration.provider).filter(Boolean)); } + get hasProviderConnections() { + return this.integrations.length > 0; + } + get providerCards() { const connected = this.connectedProviderKeys; diff --git a/addon/controllers/connectivity/telematics/index/details.js b/addon/controllers/connectivity/telematics/index/details.js index 66adb14aa..54125f8ed 100644 --- a/addon/controllers/connectivity/telematics/index/details.js +++ b/addon/controllers/connectivity/telematics/index/details.js @@ -66,27 +66,60 @@ export default class ConnectivityTelematicsIndexDetailsController extends Contro } get healthStatus() { - if (this.model?.meta?.last_error || this.model?.meta?.last_sync_error || ['error', 'degraded', 'disconnected'].includes(this.model?.status)) { - return 'warning'; + switch (this.model?.status) { + case 'active': + case 'connected': + return 'success'; + case 'synchronizing': + return 'info'; + case 'error': + case 'degraded': + case 'disconnected': + return 'warning'; + case 'initialized': + return 'default'; + default: + return 'default'; } + } - if (['active', 'connected'].includes(this.model?.status)) { - return 'success'; + get statusLabel() { + switch (this.model?.status) { + case 'initialized': + return 'Not tested'; + case 'connected': + return 'Connected'; + case 'synchronizing': + return 'Syncing'; + case 'active': + return 'Healthy'; + case 'error': + return 'Needs attention'; + case null: + case undefined: + return 'Unknown'; + default: + return 'Unknown'; } - - return 'default'; } - get statusLabel() { - if (this.healthStatus === 'success') { - return 'Healthy'; + get connectionTestLabel() { + switch (this.model?.meta?.last_test_result) { + case 'success': + return 'Verified'; + case 'failed': + return 'Failed'; + default: + return 'Not tested'; } + } - if (this.healthStatus === 'warning') { - return 'Needs attention'; + get lastSyncAt() { + if (this.model?.status === 'synchronizing') { + return this.model?.meta?.last_sync_started_at; } - return 'Not verified'; + return this.model?.meta?.last_sync_completed_at; } @action openConnectionTestDialog() { diff --git a/addon/templates/connectivity/telematics/index/details.hbs b/addon/templates/connectivity/telematics/index/details.hbs index acca628b1..068d3f3ba 100644 --- a/addon/templates/connectivity/telematics/index/details.hbs +++ b/addon/templates/connectivity/telematics/index/details.hbs @@ -17,8 +17,9 @@ {{/if}}
+ Connection test: {{this.connectionTestLabel}} Last test: {{n-a (format-date-fns @model.meta.last_connection_test "dd MMM HH:mm")}} - Last sync: {{n-a (format-date-fns @model.meta.last_sync_completed_at "dd MMM HH:mm")}} + Last sync: {{n-a (format-date-fns this.lastSyncAt "dd MMM HH:mm")}} Devices: {{n-a @model.meta.last_sync_total}}
@@ -45,7 +46,7 @@ -
+
{{outlet}}
diff --git a/addon/templates/connectivity/telematics/index/details/attachments.hbs b/addon/templates/connectivity/telematics/index/details/attachments.hbs index 372ade3e4..85d85a65c 100644 --- a/addon/templates/connectivity/telematics/index/details/attachments.hbs +++ b/addon/templates/connectivity/telematics/index/details/attachments.hbs @@ -1,33 +1,34 @@
-
-
-

Vehicle Attachments

-

Review synced devices and attach them to the vehicles they report from.

+
+
+
+

Vehicle Attachments

+

Review synced devices and attach them to the vehicles they report from.

+
+ + {{this.unattachedDevices.length}} unattached +
- - {{this.unattachedDevices.length}} unattached - -
- - {{#if this.unattachedDevices.length}} -
- {{#each this.unattachedDevices as |device|}} -
-
-
-
{{or device.displayName device.name device.device_id}}
-
{{n-a device.device_id}}
+ {{#if this.unattachedDevices.length}} +
+ {{#each this.unattachedDevices as |device|}} +
+
+
+
{{or device.displayName device.name device.device_id}}
+
{{n-a device.device_id}}
+
+ Needs vehicle +
+
+
- Needs vehicle -
-
-
-
- {{/each}} -
- {{/if}} + {{/each}} +
+ {{/if}} +
telematic = $telematic; $this->options = $options; $this->jobId = $jobId ?? \Illuminate\Support\Str::uuid()->toString(); - $this->queue = 'telematics-sync'; } /** @@ -110,14 +109,16 @@ public function handle(TelematicProviderRegistry $registry, TelematicService $se Log::error('Device discovery failed', [ 'correlation_id' => $correlationId, 'error' => $e->getMessage(), + 'exception' => get_class($e), ]); $this->telematic->status = 'error'; $this->telematic->meta = array_merge($this->telematic->meta ?? [], [ - 'last_sync_job_id' => $this->jobId, - 'last_sync_result' => 'failed', - 'last_sync_error' => $e->getMessage(), - 'last_sync_failed_at' => now()->toDateTimeString(), + 'last_sync_job_id' => $this->jobId, + 'last_sync_result' => 'failed', + 'last_sync_error' => 'Device sync failed. Review the provider connection and server logs, then try again.', + 'last_sync_error_type' => class_basename($e), + 'last_sync_failed_at' => now()->toDateTimeString(), ]); $this->telematic->save(); diff --git a/server/src/Jobs/TestTelematicConnectionJob.php b/server/src/Jobs/TestTelematicConnectionJob.php index 0aa7ac896..7ac33d53a 100644 --- a/server/src/Jobs/TestTelematicConnectionJob.php +++ b/server/src/Jobs/TestTelematicConnectionJob.php @@ -37,7 +37,6 @@ public function __construct(Telematic $telematic, ?string $jobId = null) { $this->telematic = $telematic; $this->jobId = $jobId ?? Str::uuid()->toString(); - $this->queue = 'telematics-priority'; } /** diff --git a/server/src/Support/Telematics/TelematicService.php b/server/src/Support/Telematics/TelematicService.php index 483da4bf1..3fa22ff6d 100644 --- a/server/src/Support/Telematics/TelematicService.php +++ b/server/src/Support/Telematics/TelematicService.php @@ -144,6 +144,8 @@ public function discoverDevices(Telematic $telematic, array $options = []): stri $telematic->meta = array_merge($telematic->meta ?? [], [ 'last_sync_job_id' => $jobId, 'last_sync_started_at' => now()->toDateTimeString(), + 'last_sync_result' => 'queued', + 'last_sync_error' => null, ]); $telematic->save(); @@ -195,6 +197,8 @@ public function linkDevice(Telematic $telematic, array $deviceData): Device $location = $this->normalizeLocation($deviceData['location'] ?? null); if ($location) { $device->last_position = $location; + } elseif (!$device->exists || !$device->last_position) { + $device->last_position = $this->defaultLocation(); } $device->save(); @@ -281,6 +285,8 @@ public function storeSensor(Telematic $telematic, array $sensorData, ?Device $de $location = $this->normalizeLocation($sensorData['location'] ?? null); if ($location) { $sensor->last_position = $location; + } elseif (!$sensor->exists || !$sensor->last_position) { + $sensor->last_position = $this->defaultLocation(); } $sensor->save(); @@ -464,6 +470,11 @@ protected function normalizeLocation(?array $location): mixed return ['latitude' => (float) $lat, 'longitude' => (float) $lng]; } + protected function defaultLocation(): array + { + return ['latitude' => 0, 'longitude' => 0]; + } + protected function resolveExternalId(array $payload): ?string { $value = $payload['device_id']