v0.7.11 ~ Maintenance, Reports, Telematics Upgrade/Features

This commit is contained in:
Ronald A. Richardson
2025-09-30 13:49:34 +08:00
parent f8fd9f76fa
commit 284c62cd06
20 changed files with 1211 additions and 19 deletions

View File

@@ -5,6 +5,7 @@ import config from '@fleetbase/console/config/environment';
import loadExtensions from '@fleetbase/ember-core/utils/load-extensions';
import mapEngines from '@fleetbase/ember-core/utils/map-engines';
import loadRuntimeConfig from '@fleetbase/console/utils/runtime-config';
import applyRouterFix from './utils/router-refresh-patch';
export default class App extends Application {
modulePrefix = config.modulePrefix;
@@ -14,6 +15,7 @@ export default class App extends Application {
engines = {};
async ready() {
applyRouterFix(this);
const extensions = await loadExtensions();
this.extensions = extensions;

304
console/app/models/alert.js Normal file
View File

@@ -0,0 +1,304 @@
import Model, { attr, belongsTo } from '@ember-data/model';
import { computed } from '@ember/object';
import { format, formatDistanceToNow, differenceInMinutes } from 'date-fns';
export default class AlertModel extends Model {
/** @attributes */
@attr('string') type;
@attr('string') severity;
@attr('string') status;
@attr('string') subject_type;
@attr('string') subject_uuid;
@attr('string') message;
/** @json attributes */
@attr() rule;
@attr() context;
@attr() meta;
/** @dates */
@attr('date') triggered_at;
@attr('date') acknowledged_at;
@attr('date') resolved_at;
@attr('date') created_at;
@attr('date') updated_at;
@attr('date') deleted_at;
/** @relationships */
@belongsTo('company') company;
@belongsTo('user', { inverse: null }) acknowledgedBy;
@belongsTo('user', { inverse: null }) resolvedBy;
/** @computed - Date formatting */
@computed('triggered_at') get triggeredAgo() {
if (!this.triggered_at) return 'Unknown';
return formatDistanceToNow(this.triggered_at) + ' ago';
}
@computed('triggered_at') get triggeredAt() {
if (!this.triggered_at) return 'Unknown';
return format(this.triggered_at, 'PPP p');
}
@computed('acknowledged_at') get acknowledgedAgo() {
if (!this.acknowledged_at) return null;
return formatDistanceToNow(this.acknowledged_at) + ' ago';
}
@computed('acknowledged_at') get acknowledgedAt() {
if (!this.acknowledged_at) return 'Not acknowledged';
return format(this.acknowledged_at, 'PPP p');
}
@computed('resolved_at') get resolvedAgo() {
if (!this.resolved_at) return null;
return formatDistanceToNow(this.resolved_at) + ' ago';
}
@computed('resolved_at') get resolvedAt() {
if (!this.resolved_at) return 'Not resolved';
return format(this.resolved_at, 'PPP p');
}
@computed('updated_at') get updatedAgo() {
return formatDistanceToNow(this.updated_at) + ' ago';
}
@computed('updated_at') get updatedAt() {
return format(this.updated_at, 'PPP p');
}
@computed('created_at') get createdAgo() {
return formatDistanceToNow(this.created_at) + ' ago';
}
@computed('created_at') get createdAt() {
return format(this.created_at, 'PPP p');
}
/** @computed - Status checks */
@computed('acknowledged_at') get isAcknowledged() {
return !!this.acknowledged_at;
}
@computed('resolved_at') get isResolved() {
return !!this.resolved_at;
}
@computed('isAcknowledged', 'isResolved') get isPending() {
return !this.isAcknowledged && !this.isResolved;
}
@computed('isAcknowledged', 'isResolved') get isActive() {
return this.isAcknowledged && !this.isResolved;
}
/** @computed - Duration calculations */
@computed('triggered_at', 'acknowledged_at') get acknowledgmentDurationMinutes() {
if (!this.triggered_at || !this.acknowledged_at) return null;
return differenceInMinutes(new Date(this.acknowledged_at), new Date(this.triggered_at));
}
@computed('triggered_at', 'resolved_at') get resolutionDurationMinutes() {
if (!this.triggered_at || !this.resolved_at) return null;
return differenceInMinutes(new Date(this.resolved_at), new Date(this.triggered_at));
}
@computed('triggered_at') get ageMinutes() {
if (!this.triggered_at) return 0;
return differenceInMinutes(new Date(), new Date(this.triggered_at));
}
@computed('acknowledgmentDurationMinutes') get acknowledgmentDurationFormatted() {
if (!this.acknowledgmentDurationMinutes) return null;
const minutes = this.acknowledgmentDurationMinutes;
if (minutes < 60) return `${minutes}m`;
if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`;
}
@computed('resolutionDurationMinutes') get resolutionDurationFormatted() {
if (!this.resolutionDurationMinutes) return null;
const minutes = this.resolutionDurationMinutes;
if (minutes < 60) return `${minutes}m`;
if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`;
}
@computed('ageMinutes') get ageFormatted() {
const minutes = this.ageMinutes;
if (minutes < 60) return `${minutes}m`;
if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`;
}
/** @computed - Severity styling */
@computed('severity') get severityBadgeClass() {
const severityClasses = {
'critical': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
'high': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
'medium': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
'low': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
'info': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
};
return severityClasses[this.severity] || severityClasses['info'];
}
@computed('severity') get severityIcon() {
const severityIcons = {
'critical': 'fas fa-exclamation-circle',
'high': 'fas fa-exclamation-triangle',
'medium': 'fas fa-exclamation',
'low': 'fas fa-info-circle',
'info': 'fas fa-info'
};
return severityIcons[this.severity] || severityIcons['info'];
}
@computed('severity') get severityColor() {
const severityColors = {
'critical': 'text-red-600 dark:text-red-400',
'high': 'text-orange-600 dark:text-orange-400',
'medium': 'text-yellow-600 dark:text-yellow-400',
'low': 'text-blue-600 dark:text-blue-400',
'info': 'text-gray-600 dark:text-gray-400'
};
return severityColors[this.severity] || severityColors['info'];
}
/** @computed - Status styling */
@computed('status', 'isAcknowledged', 'isResolved') get statusBadgeClass() {
if (this.isResolved) {
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
}
if (this.isAcknowledged) {
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
}
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
}
@computed('status', 'isAcknowledged', 'isResolved') get statusText() {
if (this.isResolved) return 'Resolved';
if (this.isAcknowledged) return 'Acknowledged';
return 'Pending';
}
@computed('status', 'isAcknowledged', 'isResolved') get statusIcon() {
if (this.isResolved) return 'fas fa-check-circle';
if (this.isAcknowledged) return 'fas fa-eye';
return 'fas fa-bell';
}
/** @computed - Type styling */
@computed('type') get typeIcon() {
const typeIcons = {
'maintenance': 'fas fa-wrench',
'temperature': 'fas fa-thermometer-half',
'fuel': 'fas fa-gas-pump',
'speed': 'fas fa-tachometer-alt',
'location': 'fas fa-map-marker-alt',
'system': 'fas fa-cog',
'security': 'fas fa-shield-alt',
'performance': 'fas fa-chart-line',
'compliance': 'fas fa-clipboard-check'
};
return typeIcons[this.type] || 'fas fa-bell';
}
@computed('type') get typeBadgeClass() {
const typeClasses = {
'maintenance': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
'temperature': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
'fuel': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
'speed': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
'location': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
'system': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
'security': 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
'performance': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300',
'compliance': 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300'
};
return typeClasses[this.type] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
/** @computed - Subject information */
@computed('subject_type') get subjectTypeFormatted() {
if (!this.subject_type) return 'Unknown';
// Convert from model class name to human readable
const typeMap = {
'vehicle': 'Vehicle',
'driver': 'Driver',
'order': 'Order',
'device': 'Device',
'asset': 'Asset',
'maintenance': 'Maintenance',
'fuel_report': 'Fuel Report'
};
return typeMap[this.subject_type] || this.subject_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
/** @computed - Priority and urgency */
@computed('severity', 'ageMinutes') get urgencyLevel() {
const severityWeight = {
'critical': 4,
'high': 3,
'medium': 2,
'low': 1,
'info': 0
};
const weight = severityWeight[this.severity] || 0;
const ageHours = this.ageMinutes / 60;
// Calculate urgency based on severity and age
if (weight >= 3 && ageHours > 1) return 'urgent';
if (weight >= 2 && ageHours > 4) return 'urgent';
if (weight >= 3) return 'high';
if (weight >= 2) return 'medium';
return 'low';
}
@computed('urgencyLevel') get urgencyBadgeClass() {
const urgencyClasses = {
'urgent': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300 animate-pulse',
'high': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
'medium': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
'low': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
};
return urgencyClasses[this.urgencyLevel] || urgencyClasses['low'];
}
/** @computed - Context information */
@computed('context') get hasContext() {
return !!(this.context && Object.keys(this.context).length > 0);
}
@computed('rule') get hasRule() {
return !!(this.rule && Object.keys(this.rule).length > 0);
}
@computed('context.location') get hasLocation() {
return !!(this.context?.location);
}
@computed('context.value', 'rule.threshold') get thresholdExceeded() {
if (!this.context?.value || !this.rule?.threshold) return null;
const value = parseFloat(this.context.value);
const threshold = parseFloat(this.rule.threshold);
const operator = this.rule.operator || '>';
switch (operator) {
case '>': return value > threshold;
case '<': return value < threshold;
case '>=': return value >= threshold;
case '<=': return value <= threshold;
case '==': return value === threshold;
case '!=': return value !== threshold;
default: return null;
}
}
}

View File

@@ -30,6 +30,14 @@ export default class CustomFieldModel extends Model {
@attr('date') deleted_at;
/** @computed */
@computed('type') get valueType() {
if (this.type === 'file-upload') return 'file';
if (this.type === 'date-time-input') return 'date';
if (this.type === 'model-select') return 'model';
return 'text';
}
@computed('created_at') get createdAgo() {
return formatDistanceToNow(this.created_at);
}

View File

@@ -0,0 +1,510 @@
import Model, { attr, belongsTo, hasMany } from '@ember-data/model';
import { computed } from '@ember/object';
import { isArray } from '@ember/array';
import { getOwner } from '@ember/application';
import { isPresent, isEmpty } from '@ember/utils';
export default class ReportModel extends Model {
/** @ids */
@attr('string') public_id;
@attr('string') company_uuid;
@attr('string') created_by_uuid;
@attr('string') category_uuid;
@attr('string') subject_uuid;
/** @attributes */
@attr('string') subject_type;
@attr('string') title;
@attr('string') description;
@attr('date') period_start;
@attr('date') period_end;
@attr('date') last_executed_at;
@attr('number') execution_time;
@attr('number') row_count;
@attr('boolean') is_scheduled;
@attr('boolean') is_generated;
@attr('string') status;
@attr('string') type;
@attr('raw') export_formats;
@attr('raw') schedule_config;
@attr('raw') data;
@attr('raw') result_columns;
@attr('raw') query_config;
@attr('raw') tags;
@attr('raw') options;
@attr('raw') meta;
@attr('string') status;
/** @relationships */
// @belongsTo('company') company;
// @belongsTo('user') createdBy;
// @hasMany('report-execution') executions;
// @hasMany('report-audit-log') auditLogs;
fillResult(result = {}) {
this.setProperties({
result_columns: result?.columns ?? [],
data: result?.data ?? [],
meta: result?.meta ?? {},
row_count: result?.meta?.total_rows ?? 0,
execution_time: result?.meta?.execution_time_ms ?? -1,
last_executed_at: new Date(),
is_generated: true,
});
}
/** @computed */
@computed('query_config')
get hasValidConfig() {
return (
isPresent(this.query_config) &&
isPresent(this.query_config.table) &&
isPresent(this.query_config.table.name) &&
isArray(this.query_config.columns) &&
this.query_config.columns.length > 0
);
}
@computed('query_config.table.name')
get tableName() {
return this.query_config?.table?.name || '';
}
@computed('query_config.table.label')
get tableLabel() {
return this.query_config?.table?.label || this.tableName;
}
@computed('query_config.columns.[]')
get selectedColumns() {
return this.query_config?.columns || [];
}
@computed('selectedColumns.[]', 'query_config.joins.[]')
get totalSelectedColumns() {
let count = this.selectedColumns.length;
// Add columns from joins
if (isArray(this.query_config?.joins)) {
this.query_config.joins.forEach((join) => {
if (isArray(join.selectedColumns)) {
count += join.selectedColumns.length;
}
});
}
return count;
}
@computed('query_config.joins.[]')
get hasJoins() {
return isArray(this.query_config?.joins) && this.query_config.joins.length > 0;
}
@computed('query_config.joins.[]')
get joinedTables() {
if (!this.hasJoins) {
return [];
}
return this.query_config.joins.map((join) => ({
table: join.table,
label: join.label || join.table,
type: join.type,
columnsCount: join.selectedColumns?.length || 0,
}));
}
@computed('query_config.conditions.[]')
get hasConditions() {
return isArray(this.query_config?.conditions) && this.query_config.conditions.length > 0;
}
@computed('query_config.conditions.[]')
get conditionsCount() {
if (!this.hasConditions) {
return 0;
}
return this.countConditionsRecursively(this.query_config.conditions);
}
@computed('query_config.groupBy.[]')
get hasGrouping() {
return isArray(this.query_config?.groupBy) && this.query_config.groupBy.length > 0;
}
@computed('query_config.sortBy.[]')
get hasSorting() {
return isArray(this.query_config?.sortBy) && this.query_config.sortBy.length > 0;
}
@computed('query_config.limit')
get hasLimit() {
return isPresent(this.query_config?.limit) && this.query_config.limit > 0;
}
@computed('totalSelectedColumns', 'hasJoins', 'conditionsCount', 'hasGrouping')
get complexity() {
let score = 0;
score += this.totalSelectedColumns;
score += this.hasJoins ? this.joinedTables.length * 3 : 0;
score += this.conditionsCount * 2;
score += this.hasGrouping ? 5 : 0;
if (score < 10) {
return 'simple';
} else if (score < 25) {
return 'moderate';
} else {
return 'complex';
}
}
@computed('complexity', 'totalSelectedColumns', 'joinedTables.length')
get estimatedPerformance() {
if (this.complexity === 'simple' && this.totalSelectedColumns <= 10) {
return 'fast';
} else if (this.complexity === 'moderate' && this.joinedTables.length <= 2) {
return 'moderate';
} else {
return 'slow';
}
}
@computed('last_executed_at')
get lastExecutedDisplay() {
if (!this.last_executed_at) {
return 'Never executed';
}
return this.last_executed_at.toLocaleDateString() + ' ' + this.last_executed_at.toLocaleTimeString();
}
@computed('average_execution_time')
get averageExecutionTimeDisplay() {
if (!this.average_execution_time) {
return 'N/A';
}
if (this.average_execution_time < 1000) {
return `${Math.round(this.average_execution_time)}ms`;
} else {
return `${(this.average_execution_time / 1000).toFixed(2)}s`;
}
}
@computed('execution_count')
get executionCountDisplay() {
return this.execution_count || 0;
}
@computed('last_result_count')
get lastResultCountDisplay() {
if (this.last_result_count === null || this.last_result_count === undefined) {
return 'N/A';
}
return this.last_result_count.toLocaleString();
}
@computed('export_formats.[]')
get availableExportFormats() {
return this.export_formats || ['csv', 'excel', 'json'];
}
@computed('tags.[]')
get tagsList() {
return this.tags || [];
}
@computed('shared_with.[]')
get sharedWithList() {
return this.shared_with || [];
}
@computed('is_scheduled', 'schedule_frequency', 'next_scheduled_run')
get scheduleInfo() {
if (!this.is_scheduled) {
return null;
}
return {
frequency: this.schedule_frequency,
nextRun: this.next_scheduled_run,
timezone: this.schedule_timezone || 'UTC',
};
}
@computed('query_config.conditions.[]')
get conditionsSummary() {
if (!this.hasConditions) {
return [];
}
return this.extractConditionsSummary(this.query_config.conditions);
}
@computed('status')
get statusDisplay() {
const statusMap = {
draft: 'Draft',
active: 'Active',
archived: 'Archived',
error: 'Error',
};
return statusMap[this.status] || this.status;
}
@computed('status')
get statusClass() {
const statusClasses = {
draft: 'status-draft',
active: 'status-active',
archived: 'status-archived',
error: 'status-error',
};
return statusClasses[this.status] || 'status-unknown';
}
// Helper methods
countConditionsRecursively(conditions) {
let count = 0;
if (!isArray(conditions)) {
return count;
}
conditions.forEach((condition) => {
if (condition.conditions) {
count += this.countConditionsRecursively(condition.conditions);
} else {
count++;
}
});
return count;
}
extractConditionsSummary(conditions, summary = []) {
if (!isArray(conditions)) {
return summary;
}
conditions.forEach((condition) => {
if (condition.conditions) {
this.extractConditionsSummary(condition.conditions, summary);
} else if (condition.field && condition.operator) {
summary.push({
field: condition.field.label || condition.field.name,
operator: condition.operator.label || condition.operator.value,
value: condition.value,
table: condition.field.table || this.tableName,
});
}
});
return summary;
}
// API methods for interacting with the new backend
async execute() {
const owner = getOwner(this);
const fetch = owner.lookup('service:fetch');
try {
const response = await fetch.post(this.id ? `reports/${this.id}/execute` : 'reports/execute-query', { query_config: this.query_config });
return response;
} catch (error) {
throw error;
}
}
async export(format = 'csv', options = {}) {
const owner = getOwner(this);
const fetch = owner.lookup('service:fetch');
try {
const response = await fetch.post(`reports/${this.id}/export`, {
format,
options,
});
return response;
} catch (error) {
throw error;
}
}
async validate() {
const owner = getOwner(this);
const fetch = owner.lookup('service:fetch');
try {
const response = await fetch.post('reports/validate-query', {
query_config: this.query_config,
});
return response;
} catch (error) {
throw error;
}
}
async analyze() {
const owner = getOwner(this);
const fetch = owner.lookup('service:fetch');
try {
const response = await fetch.post('reports/analyze-query', {
query_config: this.query_config,
});
return response;
} catch (error) {
throw error;
}
}
// Static methods for direct query operations
static async executeQuery(queryConfig) {
const owner = getOwner(this);
const fetch = owner.lookup('service:fetch');
try {
const response = await fetch.post('reports/execute-query', {
query_config: queryConfig,
});
return response;
} catch (error) {
throw error;
}
}
static async exportQuery(queryConfig, format = 'csv', options = {}) {
const owner = getOwner(this);
const fetch = owner.lookup('service:fetch');
try {
const response = await fetch('reports/export-query', {
query_config: queryConfig,
format,
options,
});
return response;
} catch (error) {
throw error;
}
}
static async validateQuery(queryConfig) {
const owner = getOwner(this);
const fetch = owner.lookup('service:fetch');
try {
const response = await fetch.post('reports/validate-query', { query_config: queryConfig });
return response;
} catch (error) {
throw error;
}
}
static async analyzeQuery(queryConfig) {
const owner = getOwner(this);
const fetch = owner.lookup('service:fetch');
try {
const response = await fetch.post('reports/analyze-query', { query_config: queryConfig });
return response;
} catch (error) {
throw error;
}
}
static async getTables() {
try {
const { tables } = await fetch.get('reports/tables');
return tables;
} catch (error) {
throw error;
}
}
static async getTableSchema(tableName) {
const owner = getOwner(this);
const fetch = owner.lookup('service:fetch');
try {
const { schema } = await fetch.get(`reports/tables/${tableName}/schema`);
return schema;
} catch (error) {
throw error;
}
}
static async getExportFormats() {
const owner = getOwner(this);
const fetch = owner.lookup('service:fetch');
try {
const { formats } = await fetch.get('reports/export-formats');
return formats;
} catch (error) {
throw error;
}
}
// Utility methods for frontend display
getComplexityBadgeClass() {
const complexityClasses = {
simple: 'badge-success',
moderate: 'badge-warning',
complex: 'badge-danger',
};
return complexityClasses[this.complexity] || 'badge-secondary';
}
getPerformanceBadgeClass() {
const performanceClasses = {
fast: 'badge-success',
moderate: 'badge-warning',
slow: 'badge-danger',
};
return performanceClasses[this.estimatedPerformance] || 'badge-secondary';
}
getQuerySummary() {
const parts = [];
parts.push(`${this.totalSelectedColumns} columns from ${this.tableLabel}`);
if (this.hasJoins) {
parts.push(`${this.joinedTables.length} joins`);
}
if (this.hasConditions) {
parts.push(`${this.conditionsCount} conditions`);
}
if (this.hasGrouping) {
parts.push('grouped');
}
if (this.hasSorting) {
parts.push('sorted');
}
if (this.hasLimit) {
parts.push(`limited to ${this.query_config.limit} rows`);
}
return parts.join(', ');
}
}

View File

@@ -0,0 +1,5 @@
import ApplicationSerializer from '@fleetbase/ember-core/serializers/application';
import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest';
export default class AlertSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) {
}

View File

@@ -0,0 +1,4 @@
import ApplicationSerializer from '@fleetbase/ember-core/serializers/application';
import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest';
export default class ReportSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) {}

View File

@@ -67,17 +67,3 @@ body.console-admin-organizations-index-index .next-table-wrapper > table {
body[data-theme='dark'] #boot-loader > .loader-container > .loading-message {
color: #fff;
}
/** hotfix: ember-power-select-trigger broken padding after upgrade - @todo move to ember-ui */
body.fleetbase-console .ember-power-select-trigger {
padding-top: 0.5rem;
padding-right: 2.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
}
/** hotfix sidebar-panel-toggle light mode - @todo move to ember-ui */
body[data-theme="light"] .next-sidebar .next-content-panel-wrapper .next-content-panel-container .next-content-panel > .next-content-panel-header.next-sidebar-panel-toggle:hover {
background-color: rgba(59, 130, 246, 1);
background-color: rgba(59, 130, 246, var(--tw-bg-opacity));
}

View File

@@ -1,6 +1,15 @@
{{page-title (t "app.name")}}
<Layout::Container>
<Layout::Header @brand={{@model}} @menuItems={{this.menuItems}} @organizationMenuItems={{this.organizationMenuItems}} @userMenuItems={{this.userMenuItems}} @onAction={{this.onAction}} @showSidebarToggle={{true}} @sidebarToggleEnabled={{this.sidebarToggleEnabled}} @onSidebarToggle={{this.onSidebarToggle}} />
<Layout::Header
@brand={{@model}}
@menuItems={{this.menuItems}}
@organizationMenuItems={{this.organizationMenuItems}}
@userMenuItems={{this.userMenuItems}}
@onAction={{this.onAction}}
@showSidebarToggle={{true}}
@sidebarToggleEnabled={{this.sidebarToggleEnabled}}
@onSidebarToggle={{this.onSidebarToggle}}
/>
<Layout::Main>
<Layout::Sidebar @onSetup={{this.setSidebarContext}}>
<div class="next-sidebar-content-inner">
@@ -11,13 +20,21 @@
<Layout::Section>
{{outlet}}
</Layout::Section>
<ResourceContextPanel />
</Layout::Main>
<Layout::MobileNavbar @brand={{@model}} @user={{this.user}} @organizations={{this.organizations}} @menuItems={{this.menuItems}} @extensions={{this.extensions}} @onAction={{this.onAction}} />
<Layout::MobileNavbar
@brand={{@model}}
@user={{this.user}}
@organizations={{this.organizations}}
@menuItems={{this.menuItems}}
@extensions={{this.extensions}}
@onAction={{this.onAction}}
/>
</Layout::Container>
<ChatContainer />
<ConsoleWormhole />
<ImpersonatorTray />
{{!-- template-lint-disable no-potential-path-strings --}}
{{! template-lint-disable no-potential-path-strings }}
<RegistryYield @registry="@fleetbase/console" as |RegistryComponent|>
<RegistryComponent @controller={{this}} />
</RegistryYield>
</RegistryYield>

View File

@@ -0,0 +1,46 @@
import Transform from '@ember-data/serializer/transform';
import { isArray } from '@ember/array';
export default class ArrayTransform extends Transform {
deserialize(serialized) {
if (serialized === null || serialized === undefined) {
return [];
}
if (isArray(serialized)) {
return serialized;
}
if (typeof serialized !== 'string') {
return Array.from(serialized);
}
try {
return JSON.parse(serialized);
} catch (e) {
// Fallback: return empty array if parsing fails
return [];
}
}
serialize(deserialized) {
if (deserialized === null || deserialized === undefined) {
return [];
}
if (isArray(deserialized)) {
return deserialized;
}
if (typeof deserialized !== 'string') {
return Array.from(deserialized);
}
// Fallback: attempt to parse if its a string
try {
return JSON.parse(deserialized);
} catch (e) {
return [];
}
}
}

View File

@@ -0,0 +1,36 @@
import Transform from '@ember-data/serializer/transform';
import isObject from '@fleetbase/ember-core/utils/is-object';
export default class ObjectTransform extends Transform {
deserialize(serialized) {
if (!serialized) {
return {};
}
if (isObject(serialized)) {
return serialized;
}
try {
return JSON.parse(serialized);
} catch {
return {};
}
}
serialize(deserialized) {
if (!deserialized) {
return {};
}
if (isObject(deserialized)) {
return deserialized;
}
try {
return JSON.parse(deserialized);
} catch {
return {};
}
}
}

View File

@@ -0,0 +1,160 @@
import { debug } from '@ember/debug';
/**
* Fleetbase Router Refresh Bug Fix Utility
*
* This utility patches the Ember.js router refresh bug that causes
* "missing params" errors when transitioning to nested routes with
* dynamic segments while query parameters with refreshModel: true
* are present.
*
* Bug: https://github.com/emberjs/ember.js/issues/19260
*
* @author Fleetbase Pte Ltd <hello@fleetbase.io>
* @version 1.0.0
*/
/**
* Applies the router refresh bug fix patch
* @param {Application} application - The Ember application instance
*/
export function patchRouterRefresh(application) {
if (!application || typeof application.lookup !== 'function') {
debug('[Fleetbase Router Patch] Invalid application instance provided');
return;
}
try {
const router = application.lookup('router:main');
if (!router || !router._routerMicrolib) {
debug('[Fleetbase Router Patch] Router not found or invalid');
return;
}
// Check if already patched
if (router._routerMicrolib._fleetbaseRefreshPatched) {
debug('[Fleetbase Router Patch] Already applied, skipping');
return;
}
const originalRefresh = router._routerMicrolib.refresh.bind(router._routerMicrolib);
router._routerMicrolib.refresh = function (pivotRoute) {
const previousTransition = this.activeTransition;
const state = previousTransition ? previousTransition[this.constructor.STATE_SYMBOL] : this.state;
const routeInfos = state.routeInfos;
if (pivotRoute === undefined) {
pivotRoute = routeInfos[0].route;
}
const name = routeInfos[routeInfos.length - 1].name;
const currentRouteInfo = routeInfos[routeInfos.length - 1];
// Extract current dynamic segment parameters
const contexts = [];
if (currentRouteInfo && currentRouteInfo.params) {
const handlers = this.recognizer.handlersFor(name);
const targetHandler = handlers[handlers.length - 1];
if (targetHandler && targetHandler.names && targetHandler.names.length > 0) {
targetHandler.names.forEach((paramName) => {
if (currentRouteInfo.params[paramName] !== undefined) {
contexts.push(currentRouteInfo.params[paramName]);
}
});
}
}
const NamedTransitionIntent = this.constructor.NamedTransitionIntent;
const intent = new NamedTransitionIntent(
this,
name,
pivotRoute,
contexts, // Preserve dynamic segments instead of empty array
this._changedQueryParams || state.queryParams
);
const newTransition = this.transitionByIntent(intent, false);
if (previousTransition && previousTransition.urlMethod === 'replace') {
newTransition.method(previousTransition.urlMethod);
}
return newTransition;
};
// Mark as patched
router._routerMicrolib._fleetbaseRefreshPatched = true;
debug('[Fleetbase Router Patch] Successfully applied router refresh bug fix');
} catch (error) {
debug('[Fleetbase Router Patch] Failed to apply patch: ' + error.message);
}
}
/**
* Alternative error suppression approach for cases where patching fails
* @param {Application} application - The Ember application instance
*/
export function suppressRouterRefreshErrors(application) {
if (!application) {
debug('[Fleetbase Router Patch] Invalid application instance for error suppression');
return;
}
try {
// Global error handler for unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason;
if (error?.message?.includes("You didn't provide enough string/numeric parameters to satisfy all of the dynamic segments")) {
debug('[Fleetbase Router Patch] Suppressed known Ember route refresh bug:', error.message);
event.preventDefault(); // Prevent the error from being logged
}
});
// Ember.js error handler
if (window.Ember) {
const originalEmberError = window.Ember.onerror;
window.Ember.onerror = function (error) {
if (error?.message?.includes("You didn't provide enough string/numeric parameters to satisfy all of the dynamic segments")) {
debug('[Fleetbase Router Patch] Suppressed known Ember route refresh bug:', error.message);
return; // Suppress the error
}
// Let other errors through
if (originalEmberError) {
return originalEmberError(error);
}
throw error;
};
}
debug('[Fleetbase Router Patch] Error suppression handlers installed');
} catch (error) {
debug('[Fleetbase Router Patch] Failed to install error suppression: ' + error.message);
}
}
/**
* Main function to apply the complete router fix
* @param {Application} application - The Ember application instance
* @param {Object} options - Configuration options
* @param {boolean} options.suppressErrors - Whether to also install error suppression (default: true)
*/
export default function applyRouterFix(application, options = {}) {
const { suppressErrors = true } = options;
debug('[Fleetbase Router Patch] Applying Ember router refresh bug fix...');
// Apply the main patch
patchRouterRefresh(application);
// Optionally install error suppression as fallback
if (suppressErrors) {
suppressRouterRefreshErrors(application);
}
debug('[Fleetbase Router Patch] Router fix application complete');
}

View File

@@ -9,7 +9,7 @@ module.exports = {
'./node_modules/@fleetbase+*/addon/**/*.{hbs,js}',
'./node_modules/@fleetbase/ember-ui/addon/templates/**/*.{hbs,js}',
'./node_modules/@fleetbase/ember-ui/addon/components/**/*.{hbs,js}',
'./node_modules/**/*-engine/addon/**/*.{hbs,js}'
'./node_modules/**/*-engine/addon/**/*.{hbs,js}',
],
},
safelist: [

View File

@@ -0,0 +1,14 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Model | alert', function (hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function (assert) {
let store = this.owner.lookup('service:store');
let model = store.createRecord('alert', {});
assert.ok(model);
});
});

View File

@@ -0,0 +1,14 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Model | report', function (hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function (assert) {
let store = this.owner.lookup('service:store');
let model = store.createRecord('report', {});
assert.ok(model);
});
});

View File

@@ -0,0 +1,24 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Serializer | alert', function (hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function (assert) {
let store = this.owner.lookup('service:store');
let serializer = store.serializerFor('alert');
assert.ok(serializer);
});
test('it serializes records', function (assert) {
let store = this.owner.lookup('service:store');
let record = store.createRecord('alert', {});
let serializedRecord = record.serialize();
assert.ok(serializedRecord);
});
});

View File

@@ -0,0 +1,24 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Serializer | report', function (hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function (assert) {
let store = this.owner.lookup('service:store');
let serializer = store.serializerFor('report');
assert.ok(serializer);
});
test('it serializes records', function (assert) {
let store = this.owner.lookup('service:store');
let record = store.createRecord('report', {});
let serializedRecord = record.serialize();
assert.ok(serializedRecord);
});
});

View File

@@ -0,0 +1,13 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Transform | array', function (hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function (assert) {
let transform = this.owner.lookup('transform:array');
assert.ok(transform);
});
});

View File

@@ -0,0 +1,13 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Transform | object', function (hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function (assert) {
let transform = this.owner.lookup('transform:object');
assert.ok(transform);
});
});

View File

@@ -0,0 +1,10 @@
import routerRefreshPatch from '@fleetbase/console/utils/router-refresh-patch';
import { module, test } from 'qunit';
module('Unit | Utility | router-refresh-patch', function () {
// TODO: Replace this with your real tests.
test('it works', function (assert) {
let result = routerRefreshPatch();
assert.ok(result);
});
});

View File

@@ -76,6 +76,8 @@ common:
push-notifications: Push Notifications
role: Role
timezone: Timezone
bulk-delete: Bulk Delete
bulk-delete-resource: Delete {resource}
component:
file:
dropdown-label: File actions