@fleetbase/fleetops-engine 0.6.48 → 0.6.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/addon/components/driver/details.hbs +5 -1
- package/addon/components/driver/form.hbs +11 -1
- package/addon/components/fleet-listing-panel.hbs +2 -2
- package/addon/components/fleet-listing-panel.js +10 -0
- package/addon/components/map/google-live-map.js +12 -73
- package/addon/components/map/leaflet-live-map.hbs +149 -33
- package/addon/components/orchestrator/phase-builder.hbs +21 -0
- package/addon/components/orchestrator/phase-builder.js +13 -0
- package/addon/components/orchestrator-workbench.js +1 -0
- package/addon/components/order/form.hbs +2 -2
- package/addon/components/widget/fleet-ops-key-metrics.hbs +19 -17
- package/addon/components/widget/fleet-ops-key-metrics.js +26 -0
- package/addon/components/widget/fuel-efficiency.hbs +26 -0
- package/addon/components/widget/fuel-efficiency.js +43 -0
- package/addon/components/widget/geofence-violations.hbs +44 -0
- package/addon/components/widget/geofence-violations.js +55 -0
- package/addon/components/widget/issues-insights.hbs +43 -0
- package/addon/components/widget/issues-insights.js +62 -0
- package/addon/components/widget/kpi-active-orders.hbs +10 -0
- package/addon/components/widget/kpi-active-orders.js +5 -0
- package/addon/components/widget/kpi-aov.hbs +10 -0
- package/addon/components/widget/kpi-aov.js +5 -0
- package/addon/components/widget/kpi-distance.hbs +10 -0
- package/addon/components/widget/kpi-distance.js +5 -0
- package/addon/components/widget/kpi-drivers-online.hbs +10 -0
- package/addon/components/widget/kpi-drivers-online.js +5 -0
- package/addon/components/widget/kpi-earnings.hbs +10 -0
- package/addon/components/widget/kpi-earnings.js +5 -0
- package/addon/components/widget/kpi-open-issues.hbs +11 -0
- package/addon/components/widget/kpi-open-issues.js +5 -0
- package/addon/components/widget/kpi-tile.hbs +59 -0
- package/addon/components/widget/kpi-tile.js +168 -0
- package/addon/components/widget/live-fleet.hbs +122 -0
- package/addon/components/widget/live-fleet.js +365 -0
- package/addon/components/widget/maintenance-overview.hbs +45 -0
- package/addon/components/widget/maintenance-overview.js +40 -0
- package/addon/components/widget/on-time-delivery.hbs +27 -0
- package/addon/components/widget/on-time-delivery.js +73 -0
- package/addon/components/widget/operations-pulse.hbs +51 -0
- package/addon/components/widget/operations-pulse.js +173 -0
- package/addon/components/widget/orders-by-status.hbs +26 -0
- package/addon/components/widget/orders-by-status.js +54 -0
- package/addon/components/widget/revenue-trend.hbs +34 -0
- package/addon/components/widget/revenue-trend.js +67 -0
- package/addon/components/widget/top-drivers.hbs +44 -0
- package/addon/components/widget/top-drivers.js +54 -0
- package/addon/controllers/settings/routing.js +26 -0
- package/addon/extension.js +172 -3
- package/addon/services/map-adapter/google.js +13 -2
- package/addon/styles/fleetops-engine.css +114 -87
- package/addon/templates/maintenance/equipment/index.hbs +1 -1
- package/addon/templates/maintenance/parts/index.hbs +1 -1
- package/addon/templates/maintenance/schedules/index.hbs +1 -1
- package/addon/templates/management/vehicles/index.hbs +1 -1
- package/addon/templates/settings/avatars.hbs +1 -1
- package/addon/templates/settings/custom-fields.hbs +1 -1
- package/addon/templates/settings/map.hbs +1 -1
- package/addon/templates/settings/navigator-app.hbs +1 -1
- package/addon/templates/settings/notifications.hbs +1 -1
- package/addon/templates/settings/orchestrator.hbs +1 -1
- package/addon/templates/settings/payments/index.hbs +1 -1
- package/addon/templates/settings/payments/onboard.hbs +1 -1
- package/addon/templates/settings/routing.hbs +22 -2
- package/addon/templates/settings/scheduling.hbs +1 -1
- package/addon/utils/live-map-card-content.js +171 -0
- package/app/components/widget/fuel-efficiency.js +1 -0
- package/app/components/widget/geofence-violations.js +1 -0
- package/app/components/widget/issues-insights.js +1 -0
- package/app/components/widget/kpi-active-orders.js +1 -0
- package/app/components/widget/kpi-aov.js +1 -0
- package/app/components/widget/kpi-distance.js +1 -0
- package/app/components/widget/kpi-drivers-online.js +1 -0
- package/app/components/widget/kpi-earnings.js +1 -0
- package/app/components/widget/kpi-open-issues.js +1 -0
- package/app/components/widget/kpi-tile.js +1 -0
- package/app/components/widget/live-fleet.js +1 -0
- package/app/components/widget/maintenance-overview.js +1 -0
- package/app/components/widget/on-time-delivery.js +1 -0
- package/app/components/widget/operations-pulse.js +1 -0
- package/app/components/widget/orders-by-status.js +1 -0
- package/app/components/widget/revenue-trend.js +1 -0
- package/app/components/widget/top-drivers.js +1 -0
- package/app/utils/live-map-card-content.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +3 -3
- package/pnpm-workspace.yaml +1 -0
- package/server/config/telematics.php +111 -1
- package/server/migrations/2026_05_18_000001_add_license_expiry_to_drivers_table.php +25 -0
- package/server/seeders/Testing/Concerns/SeedsTestingData.php +183 -0
- package/server/seeders/Testing/ConnectivitySeeder.php +212 -0
- package/server/seeders/Testing/FleetSeeder.php +341 -0
- package/server/seeders/Testing/MaintenanceSeeder.php +238 -0
- package/server/seeders/Testing/NetworkSeeder.php +250 -0
- package/server/seeders/Testing/OrdersSeeder.php +214 -0
- package/server/seeders/Testing/TestingSeeder.php +40 -0
- package/server/seeders/{VroomOrchestrationSeeder.php → Testing/VroomOrchestrationSeeder.php} +8 -1
- package/server/src/Console/Commands/ProcessOperationalAlerts.php +279 -0
- package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +119 -0
- package/server/src/Http/Controllers/Internal/v1/MetricsController.php +82 -3
- package/server/src/Http/Controllers/Internal/v1/OrderController.php +12 -6
- package/server/src/Http/Controllers/Internal/v1/SettingController.php +46 -1
- package/server/src/Http/Resources/v1/Driver.php +2 -0
- package/server/src/Http/Resources/v1/Fleet.php +4 -0
- package/server/src/Http/Resources/v1/Index/Driver.php +48 -0
- package/server/src/Http/Resources/v1/Index/Vehicle.php +74 -1
- package/server/src/Models/Driver.php +2 -0
- package/server/src/Models/OrderConfig.php +14 -11
- package/server/src/Notifications/LateDeparture.php +48 -0
- package/server/src/Notifications/ProlongedStoppage.php +48 -0
- package/server/src/Notifications/RouteDeviation.php +48 -0
- package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +28 -1
- package/server/src/Providers/FleetOpsServiceProvider.php +5 -0
- package/server/src/Providers/NotificationServiceProvider.php +3 -0
- package/server/src/Support/Analytics/AbstractAnalytics.php +77 -0
- package/server/src/Support/Analytics/FuelEfficiency.php +83 -0
- package/server/src/Support/Analytics/GeofenceViolations.php +63 -0
- package/server/src/Support/Analytics/IssuesInsights.php +67 -0
- package/server/src/Support/Analytics/LiveFleet.php +127 -0
- package/server/src/Support/Analytics/MaintenanceOverview.php +70 -0
- package/server/src/Support/Analytics/OnTimeDelivery.php +75 -0
- package/server/src/Support/Analytics/OperationsPulse.php +86 -0
- package/server/src/Support/Analytics/OrdersByStatus.php +64 -0
- package/server/src/Support/Analytics/RevenueTrend.php +79 -0
- package/server/src/Support/Analytics/TopDrivers.php +92 -0
- package/server/src/Support/Metrics/AbstractMetric.php +170 -0
- package/server/src/Support/Metrics/ActiveLiveOrdersMetric.php +28 -0
- package/server/src/Support/Metrics/AvgOrderValueMetric.php +53 -0
- package/server/src/Support/Metrics/DriversOnlineMetric.php +30 -0
- package/server/src/Support/Metrics/EarningsMetric.php +30 -0
- package/server/src/Support/Metrics/FuelCostsMetric.php +30 -0
- package/server/src/Support/Metrics/MoneyMetric.php +30 -0
- package/server/src/Support/Metrics/OpenIssuesMetric.php +35 -0
- package/server/src/Support/Metrics/OrdersCanceledMetric.php +35 -0
- package/server/src/Support/Metrics/OrdersCompletedMetric.php +35 -0
- package/server/src/Support/Metrics/OrdersInProgressMetric.php +51 -0
- package/server/src/Support/Metrics/OrdersScheduledMetric.php +37 -0
- package/server/src/Support/Metrics/Registry.php +46 -0
- package/server/src/Support/Metrics/ResolvedIssuesMetric.php +35 -0
- package/server/src/Support/Metrics/TotalCustomersMetric.php +29 -0
- package/server/src/Support/Metrics/TotalDistanceTraveledMetric.php +35 -0
- package/server/src/Support/Metrics/TotalDriversMetric.php +28 -0
- package/server/src/Support/Metrics/TotalTimeTraveledMetric.php +35 -0
- package/server/src/Support/Metrics.php +46 -249
- package/server/src/Support/Telematics/Providers/AfaqyProvider.php +290 -0
- package/server/src/Support/Telematics/Providers/SafeeProvider.php +333 -0
- package/server/src/routes.php +16 -0
- package/server/tests/AnalyticsRoutesTest.php +41 -0
- package/server/tests/MetricsRegistryTest.php +88 -0
- package/server/tests/VroomOrchestrationEngineTest.php +91 -0
- package/translations/en-us.yaml +37 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import Component from '@glimmer/component';
|
|
2
|
+
import { tracked } from '@glimmer/tracking';
|
|
3
|
+
import { inject as service } from '@ember/service';
|
|
4
|
+
import { task } from 'ember-concurrency';
|
|
5
|
+
|
|
6
|
+
export default class WidgetFuelEfficiencyComponent extends Component {
|
|
7
|
+
static widgetId = 'fleet-ops-fuel-efficiency-widget';
|
|
8
|
+
|
|
9
|
+
@service fetch;
|
|
10
|
+
|
|
11
|
+
@tracked data = null;
|
|
12
|
+
@tracked error = null;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
super(...arguments);
|
|
16
|
+
this.load.perform();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get chartOptions() {
|
|
20
|
+
return {
|
|
21
|
+
responsive: true,
|
|
22
|
+
maintainAspectRatio: false,
|
|
23
|
+
interaction: { mode: 'index', intersect: false },
|
|
24
|
+
plugins: {
|
|
25
|
+
legend: { position: 'bottom', labels: { boxWidth: 12, padding: 8 } },
|
|
26
|
+
},
|
|
27
|
+
scales: {
|
|
28
|
+
x: { grid: { display: false } },
|
|
29
|
+
y1: { position: 'left', beginAtZero: true, title: { display: false } },
|
|
30
|
+
y2: { position: 'right', beginAtZero: true, grid: { drawOnChartArea: false } },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@task *load() {
|
|
36
|
+
try {
|
|
37
|
+
this.data = yield this.fetch.get('fleet-ops/analytics/fuel-efficiency', { period: '90d' });
|
|
38
|
+
this.error = null;
|
|
39
|
+
} catch (e) {
|
|
40
|
+
this.error = e?.message ?? 'Failed to load fuel data';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<div class="fleet-ops-widget fleet-ops-geofence-violations flex flex-col h-full {{@class}}" ...attributes>
|
|
2
|
+
<div class="flex items-center justify-between px-3 py-1.5 border dark:border-gray-700 border-gray-200 dark:bg-gray-800 bg-gray-50 rounded-t-md shadow-sm">
|
|
3
|
+
<span class="text-xs font-bold text-black dark:text-gray-100">{{t "widget.geofence-violations.title"}}</span>
|
|
4
|
+
<FaIcon @icon="location-crosshairs" class="text-gray-400" />
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div class="flex-1 flex flex-col p-2.5 border border-t-0 dark:border-gray-700 border-gray-200 rounded-b-md bg-white dark:bg-gray-900 min-h-0">
|
|
8
|
+
{{#if this.load.isRunning}}
|
|
9
|
+
<div class="flex items-center justify-center h-full"><Spinner /></div>
|
|
10
|
+
{{else if this.error}}
|
|
11
|
+
<div class="flex items-center justify-center h-full text-xs text-red-500">{{this.error}}</div>
|
|
12
|
+
{{else if this.data}}
|
|
13
|
+
<div class="grid grid-cols-2 gap-1.5 mb-2">
|
|
14
|
+
<div class="rounded-md border dark:border-gray-700 border-gray-200 p-1.5 text-center">
|
|
15
|
+
<div class="text-[9px] uppercase text-gray-500 dark:text-gray-400 leading-none">Today</div>
|
|
16
|
+
<div class="text-lg font-bold leading-tight mt-0.5 {{if this.data.violations_today 'text-amber-500' 'text-gray-400'}}">{{this.data.violations_today}}</div>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="rounded-md border dark:border-gray-700 border-gray-200 p-1.5 text-center">
|
|
19
|
+
<div class="text-[9px] uppercase text-gray-500 dark:text-gray-400 leading-none">Last 7d</div>
|
|
20
|
+
<div class="text-lg font-bold text-black dark:text-gray-100 leading-tight mt-0.5">{{this.data.violations_period}}</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="grid grid-cols-2 gap-2 flex-1 min-h-0">
|
|
24
|
+
<div class="min-h-0">
|
|
25
|
+
{{#if this.zoneDatasets}}
|
|
26
|
+
<Chart @type="bar" @labels={{this.zoneLabels}} @datasets={{this.zoneDatasets}} @options={{this.zoneOptions}} />
|
|
27
|
+
{{else}}
|
|
28
|
+
<div class="flex items-center justify-center h-full text-[10px] text-gray-500">No zone activity.</div>
|
|
29
|
+
{{/if}}
|
|
30
|
+
</div>
|
|
31
|
+
<div class="min-h-0 overflow-auto divide-y dark:divide-gray-700">
|
|
32
|
+
{{#each this.data.top_dwells as |dwell|}}
|
|
33
|
+
<div class="py-1">
|
|
34
|
+
<div class="text-[11px] font-medium text-black dark:text-gray-100 truncate">{{or dwell.driver_name "Driver"}}</div>
|
|
35
|
+
<div class="text-[10px] text-gray-500 dark:text-gray-400 truncate">{{or dwell.zone_name "Zone"}} · {{dwell.duration_minutes}}m</div>
|
|
36
|
+
</div>
|
|
37
|
+
{{else}}
|
|
38
|
+
<div class="text-center text-[10px] text-gray-500 py-3">No dwell outliers.</div>
|
|
39
|
+
{{/each}}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
{{/if}}
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Component from '@glimmer/component';
|
|
2
|
+
import { tracked } from '@glimmer/tracking';
|
|
3
|
+
import { inject as service } from '@ember/service';
|
|
4
|
+
import { task } from 'ember-concurrency';
|
|
5
|
+
|
|
6
|
+
export default class WidgetGeofenceViolationsComponent extends Component {
|
|
7
|
+
static widgetId = 'fleet-ops-geofence-violations-widget';
|
|
8
|
+
|
|
9
|
+
@service fetch;
|
|
10
|
+
|
|
11
|
+
@tracked data = null;
|
|
12
|
+
@tracked error = null;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
super(...arguments);
|
|
16
|
+
this.load.perform();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get zoneDatasets() {
|
|
20
|
+
if (!this.data?.by_zone?.data?.length) return null;
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
label: 'Events',
|
|
24
|
+
data: this.data.by_zone.data,
|
|
25
|
+
backgroundColor: '#f59e0b',
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get zoneLabels() {
|
|
31
|
+
return this.data?.by_zone?.labels ?? [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get zoneOptions() {
|
|
35
|
+
return {
|
|
36
|
+
responsive: true,
|
|
37
|
+
maintainAspectRatio: false,
|
|
38
|
+
indexAxis: 'y',
|
|
39
|
+
plugins: { legend: { display: false } },
|
|
40
|
+
scales: {
|
|
41
|
+
x: { beginAtZero: true, ticks: { precision: 0 } },
|
|
42
|
+
y: { grid: { display: false } },
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@task *load() {
|
|
48
|
+
try {
|
|
49
|
+
this.data = yield this.fetch.get('fleet-ops/analytics/geofence-violations', { period: '7d' });
|
|
50
|
+
this.error = null;
|
|
51
|
+
} catch (e) {
|
|
52
|
+
this.error = e?.message ?? 'Failed to load geofence data';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<div class="fleet-ops-widget fleet-ops-issues-insights flex flex-col h-full {{@class}}" ...attributes>
|
|
2
|
+
<div class="flex items-center justify-between px-3 py-1.5 border dark:border-gray-700 border-gray-200 dark:bg-gray-800 bg-gray-50 rounded-t-md shadow-sm">
|
|
3
|
+
<span class="text-xs font-bold text-black dark:text-gray-100">{{t "widget.issues-insights.title"}}</span>
|
|
4
|
+
<FaIcon @icon="triangle-exclamation" class="text-gray-400" />
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div class="flex-1 p-2.5 border border-t-0 dark:border-gray-700 border-gray-200 rounded-b-md bg-white dark:bg-gray-900 min-h-0">
|
|
8
|
+
{{#if this.load.isRunning}}
|
|
9
|
+
<div class="flex items-center justify-center h-full"><Spinner /></div>
|
|
10
|
+
{{else if this.error}}
|
|
11
|
+
<div class="flex items-center justify-center h-full text-xs text-red-500">{{this.error}}</div>
|
|
12
|
+
{{else if this.data}}
|
|
13
|
+
<div class="grid grid-cols-2 gap-2 h-full">
|
|
14
|
+
<div class="min-h-0">
|
|
15
|
+
{{#if this.categoryDatasets}}
|
|
16
|
+
<Chart @type="doughnut" @labels={{this.categoryLabels}} @datasets={{this.categoryDatasets}} @options={{this.categoryOptions}} />
|
|
17
|
+
{{else}}
|
|
18
|
+
<div class="flex items-center justify-center h-full text-[10px] text-gray-500">No issues yet.</div>
|
|
19
|
+
{{/if}}
|
|
20
|
+
</div>
|
|
21
|
+
<div class="flex flex-col justify-around gap-1.5">
|
|
22
|
+
<div>
|
|
23
|
+
<div class="text-[9px] uppercase text-gray-500 dark:text-gray-400 leading-none">Open</div>
|
|
24
|
+
<div class="text-lg font-bold text-amber-500 leading-tight">{{this.data.open}}</div>
|
|
25
|
+
</div>
|
|
26
|
+
<div>
|
|
27
|
+
<div class="text-[9px] uppercase text-gray-500 dark:text-gray-400 leading-none">Resolved (30d)</div>
|
|
28
|
+
<div class="text-lg font-bold text-green-500 leading-tight">{{this.data.resolved_this_period}}</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div>
|
|
31
|
+
<div class="text-[9px] uppercase text-gray-500 dark:text-gray-400 leading-none">Avg Resolve</div>
|
|
32
|
+
<div class="text-sm font-bold text-black dark:text-gray-100 leading-tight">{{this.resolutionDisplay}}</div>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="flex flex-wrap gap-1">
|
|
35
|
+
<Badge @status="danger" @text={{concat "H " this.data.by_priority.high}} @hideIcon={{true}} @disableHumanize={{true}} @wrapperClass="text-[9px] px-1 py-0" />
|
|
36
|
+
<Badge @status="warning" @text={{concat "M " this.data.by_priority.medium}} @hideIcon={{true}} @disableHumanize={{true}} @wrapperClass="text-[9px] px-1 py-0" />
|
|
37
|
+
<Badge @status="info" @text={{concat "L " this.data.by_priority.low}} @hideIcon={{true}} @disableHumanize={{true}} @wrapperClass="text-[9px] px-1 py-0" />
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
{{/if}}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import Component from '@glimmer/component';
|
|
2
|
+
import { tracked } from '@glimmer/tracking';
|
|
3
|
+
import { inject as service } from '@ember/service';
|
|
4
|
+
import { task } from 'ember-concurrency';
|
|
5
|
+
|
|
6
|
+
const CATEGORY_PALETTE = ['#3485e2', '#f59e0b', '#22c55e', '#ef4444', '#8b5cf6', '#a3a3a3', '#0ea5e9', '#ec4899'];
|
|
7
|
+
|
|
8
|
+
export default class WidgetIssuesInsightsComponent extends Component {
|
|
9
|
+
static widgetId = 'fleet-ops-issues-insights-widget';
|
|
10
|
+
|
|
11
|
+
@service fetch;
|
|
12
|
+
|
|
13
|
+
@tracked data = null;
|
|
14
|
+
@tracked error = null;
|
|
15
|
+
|
|
16
|
+
constructor() {
|
|
17
|
+
super(...arguments);
|
|
18
|
+
this.load.perform();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get categoryDatasets() {
|
|
22
|
+
if (!this.data?.by_category?.data?.length) return null;
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
data: this.data.by_category.data,
|
|
26
|
+
backgroundColor: this.data.by_category.data.map((_, i) => CATEGORY_PALETTE[i % CATEGORY_PALETTE.length]),
|
|
27
|
+
borderWidth: 0,
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get categoryLabels() {
|
|
33
|
+
return this.data?.by_category?.labels ?? [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get categoryOptions() {
|
|
37
|
+
return {
|
|
38
|
+
responsive: true,
|
|
39
|
+
maintainAspectRatio: false,
|
|
40
|
+
plugins: {
|
|
41
|
+
legend: { position: 'bottom', labels: { boxWidth: 10, padding: 6, font: { size: 10 } } },
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get resolutionDisplay() {
|
|
47
|
+
const hours = this.data?.avg_resolution_hours;
|
|
48
|
+
if (typeof hours !== 'number') return '—';
|
|
49
|
+
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
|
50
|
+
if (hours < 48) return `${hours}h`;
|
|
51
|
+
return `${(hours / 24).toFixed(1)}d`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@task *load() {
|
|
55
|
+
try {
|
|
56
|
+
this.data = yield this.fetch.get('fleet-ops/analytics/issues-insights', { period: '30d' });
|
|
57
|
+
this.error = null;
|
|
58
|
+
} catch (e) {
|
|
59
|
+
this.error = e?.message ?? 'Failed to load issues';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{{!--
|
|
2
|
+
KPI tile. Layout strategy:
|
|
3
|
+
- Title + icon at the top.
|
|
4
|
+
- Value + delta directly under the title.
|
|
5
|
+
- Sparkline absolutely positioned at the bottom edge of the card so every
|
|
6
|
+
tile lines up regardless of value-string length, and a flat-zero
|
|
7
|
+
sparkline (all-zero data, fill:'origin', y.min=0) sits flush along the
|
|
8
|
+
bottom rather than floating in the middle.
|
|
9
|
+
--}}
|
|
10
|
+
<div class="fleet-ops-widget fleet-ops-kpi-tile fleet-ops-kpi-tile-pop {{this.cardAccentClass}} relative flex flex-col h-full rounded-md border shadow-sm overflow-hidden {{@class}}" ...attributes>
|
|
11
|
+
<div class="flex items-center justify-between px-2.5 pt-2 relative z-10">
|
|
12
|
+
<span class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-400 font-bold truncate">
|
|
13
|
+
{{@title}}
|
|
14
|
+
</span>
|
|
15
|
+
{{#if @icon}}
|
|
16
|
+
<FaIcon @icon={{@icon}} @prefix={{or @iconPrefix "fas"}} class="kpi-tile-icon text-gray-400 dark:text-gray-500 text-[11px]" />
|
|
17
|
+
{{/if}}
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<div class="flex-1 flex flex-col px-2.5 pt-1 min-h-0 relative z-10">
|
|
21
|
+
{{#if this.load.isRunning}}
|
|
22
|
+
<div class="flex-1 flex items-center justify-center">
|
|
23
|
+
<Spinner />
|
|
24
|
+
</div>
|
|
25
|
+
{{else if this.error}}
|
|
26
|
+
<div class="flex-1 flex items-center justify-center text-[10px] text-red-500 dark:text-red-400">
|
|
27
|
+
{{this.error}}
|
|
28
|
+
</div>
|
|
29
|
+
{{else if this.data}}
|
|
30
|
+
<div class="flex items-baseline gap-1.5 kpi-tile-value-row">
|
|
31
|
+
<span class="text-2xl font-bold leading-none truncate {{this.valueTextClass}}">
|
|
32
|
+
{{#if (eq this.format "money")}}
|
|
33
|
+
{{format-currency this.data.value this.currency}}
|
|
34
|
+
{{else if (eq this.format "meters")}}
|
|
35
|
+
{{format-meters this.data.value}}
|
|
36
|
+
{{else if (eq this.format "duration")}}
|
|
37
|
+
{{format-duration this.data.value}}
|
|
38
|
+
{{else}}
|
|
39
|
+
{{this.data.value}}
|
|
40
|
+
{{/if}}
|
|
41
|
+
</span>
|
|
42
|
+
{{#if this.hasDelta}}
|
|
43
|
+
<Badge @status={{this.deltaStatus}} @text={{this.deltaText}} @hideIcon={{true}} @disableHumanize={{true}} @wrapperClass="text-[9px] px-1 py-0 font-bold" />
|
|
44
|
+
{{/if}}
|
|
45
|
+
</div>
|
|
46
|
+
{{/if}}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{{#if this.sparklineDatasets}}
|
|
50
|
+
<div class="kpi-tile-sparkline absolute inset-x-0 bottom-0">
|
|
51
|
+
<Chart
|
|
52
|
+
@type="line"
|
|
53
|
+
@labels={{this.sparklineLabels}}
|
|
54
|
+
@datasets={{this.sparklineDatasets}}
|
|
55
|
+
@options={{this.sparklineOptions}}
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
{{/if}}
|
|
59
|
+
</div>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import Component from '@glimmer/component';
|
|
2
|
+
import { tracked } from '@glimmer/tracking';
|
|
3
|
+
import { action } from '@ember/object';
|
|
4
|
+
import { inject as service } from '@ember/service';
|
|
5
|
+
import { task } from 'ember-concurrency';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* <Widget::KpiTile @slug="earnings" @title="..." @format="money" @showDelta={{true}}
|
|
9
|
+
* @showSparkline={{true}} @deltaInverse={{false}} @period="30d" />
|
|
10
|
+
*
|
|
11
|
+
* Renders a single KPI tile backed by `/fleet-ops/metrics/{slug}`. Hosts the
|
|
12
|
+
* format-specific value rendering, the optional period-over-period delta
|
|
13
|
+
* badge (with `deltaInverse` to flip semantics for inverse-good metrics like
|
|
14
|
+
* open_issues), and the optional sparkline line chart.
|
|
15
|
+
*/
|
|
16
|
+
export default class WidgetKpiTileComponent extends Component {
|
|
17
|
+
@service fetch;
|
|
18
|
+
@tracked data = null;
|
|
19
|
+
@tracked error = null;
|
|
20
|
+
|
|
21
|
+
get period() {
|
|
22
|
+
return this.args.period ?? '30d';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get showDelta() {
|
|
26
|
+
return this.args.showDelta ?? true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get showSparkline() {
|
|
30
|
+
return this.args.showSparkline ?? true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get deltaInverse() {
|
|
34
|
+
return this.args.deltaInverse ?? false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get format() {
|
|
38
|
+
return this.args.format ?? this.data?.format ?? 'count';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get currency() {
|
|
42
|
+
return this.data?.currency ?? 'USD';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get deltaPct() {
|
|
46
|
+
return this.data?.delta_pct;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get hasDelta() {
|
|
50
|
+
return this.showDelta && typeof this.deltaPct === 'number';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get deltaStatus() {
|
|
54
|
+
const pct = this.deltaPct;
|
|
55
|
+
if (typeof pct !== 'number' || pct === 0) return 'info';
|
|
56
|
+
const positive = pct > 0;
|
|
57
|
+
const isGood = this.deltaInverse ? !positive : positive;
|
|
58
|
+
return isGood ? 'success' : 'danger';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get deltaText() {
|
|
62
|
+
const pct = this.deltaPct;
|
|
63
|
+
const sign = pct > 0 ? '+' : '';
|
|
64
|
+
return `${sign}${pct}%`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Tints the card subtly based on trend direction so the dashboard pops. */
|
|
68
|
+
get cardAccentClass() {
|
|
69
|
+
const pct = this.deltaPct;
|
|
70
|
+
if (typeof pct !== 'number' || pct === 0) {
|
|
71
|
+
return 'kpi-accent-neutral';
|
|
72
|
+
}
|
|
73
|
+
const positive = pct > 0;
|
|
74
|
+
const isGood = this.deltaInverse ? !positive : positive;
|
|
75
|
+
return isGood ? 'kpi-accent-good' : 'kpi-accent-bad';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get valueTextClass() {
|
|
79
|
+
const pct = this.deltaPct;
|
|
80
|
+
if (typeof pct !== 'number' || pct === 0) {
|
|
81
|
+
return 'text-black dark:text-gray-100';
|
|
82
|
+
}
|
|
83
|
+
const positive = pct > 0;
|
|
84
|
+
const isGood = this.deltaInverse ? !positive : positive;
|
|
85
|
+
return isGood ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Neon palette per delta direction. Drives line + fill + value highlight. */
|
|
89
|
+
get accentColors() {
|
|
90
|
+
// Bright neon-ish trio that pops on the dark theme without being garish in light.
|
|
91
|
+
const good = { line: '#22c55e', fill: 'rgba(34, 197, 94, 0.22)', glow: 'rgba(34, 197, 94, 0.35)' };
|
|
92
|
+
const bad = { line: '#ef4444', fill: 'rgba(239, 68, 68, 0.22)', glow: 'rgba(239, 68, 68, 0.35)' };
|
|
93
|
+
const neutral = { line: '#3485e2', fill: 'rgba(52, 133, 226, 0.18)', glow: 'rgba(52, 133, 226, 0.30)' };
|
|
94
|
+
|
|
95
|
+
const pct = this.deltaPct;
|
|
96
|
+
if (typeof pct !== 'number' || pct === 0) return neutral;
|
|
97
|
+
const positive = pct > 0;
|
|
98
|
+
const isGood = this.deltaInverse ? !positive : positive;
|
|
99
|
+
return isGood ? good : bad;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get sparklineDatasets() {
|
|
103
|
+
const points = this.data?.sparkline?.data;
|
|
104
|
+
if (!points || points.length === 0) return null;
|
|
105
|
+
|
|
106
|
+
const { line, fill } = this.accentColors;
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
data: points,
|
|
111
|
+
borderColor: line,
|
|
112
|
+
backgroundColor: fill,
|
|
113
|
+
pointRadius: 0,
|
|
114
|
+
borderWidth: 1.75,
|
|
115
|
+
tension: 0.4,
|
|
116
|
+
// Anchor the area fill to the chart's y=0 axis so the sparkline
|
|
117
|
+
// visually sits at the BOTTOM of the card regardless of the data range.
|
|
118
|
+
fill: 'origin',
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
get sparklineLabels() {
|
|
124
|
+
return this.data?.sparkline?.labels ?? [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get sparklineOptions() {
|
|
128
|
+
return {
|
|
129
|
+
responsive: true,
|
|
130
|
+
maintainAspectRatio: false,
|
|
131
|
+
animation: { duration: 600, easing: 'easeOutQuart' },
|
|
132
|
+
plugins: {
|
|
133
|
+
legend: { display: false },
|
|
134
|
+
tooltip: { enabled: false },
|
|
135
|
+
},
|
|
136
|
+
scales: {
|
|
137
|
+
x: { display: false },
|
|
138
|
+
// Lock min to 0 so flat-zero data sits flush along the bottom edge.
|
|
139
|
+
// suggestedMin/Max keep the line off the very top/bottom for non-zero data.
|
|
140
|
+
y: { display: false, min: 0, beginAtZero: true, grace: '5%' },
|
|
141
|
+
},
|
|
142
|
+
elements: { line: { borderJoinStyle: 'round' } },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
constructor() {
|
|
147
|
+
super(...arguments);
|
|
148
|
+
this.load.perform();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@task *load() {
|
|
152
|
+
try {
|
|
153
|
+
this.data = yield this.fetch.get(`fleet-ops/metrics/${this.args.slug}`, {
|
|
154
|
+
period: this.period,
|
|
155
|
+
sparkline: this.showSparkline,
|
|
156
|
+
compare: this.showDelta,
|
|
157
|
+
});
|
|
158
|
+
this.error = null;
|
|
159
|
+
} catch (e) {
|
|
160
|
+
this.error = e?.message ?? 'Failed to load metric';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
@action
|
|
165
|
+
refresh() {
|
|
166
|
+
this.load.perform();
|
|
167
|
+
}
|
|
168
|
+
}
|