Merge pull request #197 from fleetbase/dashboard

customizable widget based dashboard
This commit is contained in:
Ron
2024-02-06 15:11:32 +08:00
committed by GitHub
31 changed files with 967 additions and 113 deletions

26
api/composer.lock generated
View File

@@ -192,16 +192,16 @@
},
{
"name": "aws/aws-sdk-php",
"version": "3.298.0",
"version": "3.298.1",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "55536f81006d8721c51e342d638e7ccc3529e754"
"reference": "7c7dd6f596e7f7ba22653a503ae76e8e11702028"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/55536f81006d8721c51e342d638e7ccc3529e754",
"reference": "55536f81006d8721c51e342d638e7ccc3529e754",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7c7dd6f596e7f7ba22653a503ae76e8e11702028",
"reference": "7c7dd6f596e7f7ba22653a503ae76e8e11702028",
"shasum": ""
},
"require": {
@@ -281,9 +281,9 @@
"support": {
"forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.298.0"
"source": "https://github.com/aws/aws-sdk-php/tree/3.298.1"
},
"time": "2024-01-31T19:06:05+00:00"
"time": "2024-02-01T19:07:41+00:00"
},
{
"name": "aws/aws-sdk-php-laravel",
@@ -2935,21 +2935,21 @@
},
{
"name": "google/auth",
"version": "v1.34.0",
"version": "v1.35.0",
"source": {
"type": "git",
"url": "https://github.com/googleapis/google-auth-library-php.git",
"reference": "155daeadfd2f09743f611ea493b828d382519575"
"reference": "6e9c9fd4e2bbd7042f50083076346e4a1eff4e4b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/155daeadfd2f09743f611ea493b828d382519575",
"reference": "155daeadfd2f09743f611ea493b828d382519575",
"url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/6e9c9fd4e2bbd7042f50083076346e4a1eff4e4b",
"reference": "6e9c9fd4e2bbd7042f50083076346e4a1eff4e4b",
"shasum": ""
},
"require": {
"firebase/php-jwt": "^6.0",
"guzzlehttp/guzzle": "^6.2.1|^7.0",
"guzzlehttp/guzzle": "^6.5.8||^7.4.5",
"guzzlehttp/psr7": "^2.4.5",
"php": "^7.4||^8.0",
"psr/cache": "^1.0||^2.0||^3.0",
@@ -2987,9 +2987,9 @@
"support": {
"docs": "https://googleapis.github.io/google-auth-library-php/main/",
"issues": "https://github.com/googleapis/google-auth-library-php/issues",
"source": "https://github.com/googleapis/google-auth-library-php/tree/v1.34.0"
"source": "https://github.com/googleapis/google-auth-library-php/tree/v1.35.0"
},
"time": "2024-01-03T20:45:15+00:00"
"time": "2024-02-01T20:41:08+00:00"
},
{
"name": "google/cloud-core",

View File

@@ -1,7 +1,95 @@
<div class="fleetbase-dashboard-grid">
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
{{#each this.dashboards as |dashboard index|}}
<Dashboard::Create @index={{index}} @dashboard={{dashboard}} />
{{/each}}
<div class="fleetbase-dashboard-grid flex items-center justify-between mb-4 mt-6 px-14">
<div class="left-section">
{{! Dashboard Title Text }}
<h1 class="text-lg font-bold">{{this.dashboard.currentDashboard.name}}</h1>
</div>
<div class="fleetbase-dashboard-actions right-section ml-4 flex items-center">
{{! Select Dropdown }}
<div class="fleetbase-model-select fleetbase-power-select ember-model-select h-10">
<DropdownButton
class="h-10"
@text={{if this.dashboard.currentDashboard.name this.dashboard.currentDashboard.name "Select Dashboard"}}
@textClass="text-sm mr-2"
@buttonClass="flex-row-reverse"
@icon="caret-down"
@iconClass="mr-0i"
@size="sm"
@iconPrefix="fas"
@triggerClass="hidden md:flex"
as |dd|
>
<div class="next-dd-menu mt-1 mx-0" aria-labelledby="user-menu">
<div class="p-1">
{{#each this.dashboard.dashboards as |dashboard|}}
<a href="javascript:;" class="next-dd-item" {{on "click" (dropdown-fn dd this.selectDashboard dashboard)}}>
<div class="w-6">
<FaIcon @icon="desktop" />
</div>
<span>{{dashboard.name}}</span>
</a>
{{/each}}
</div>
</div>
</DropdownButton>
</div>
{{! Button }}
<div class="ml-2 relative h-10">
<DropdownButton class="h-10" @icon="ellipsis-h" @size="sm" @iconPrefix="fas" @triggerClass="hidden md:flex" as |dd|>
<div class="next-dd-menu mt-1 mx-0" aria-labelledby="user-menu">
<div class="p-1">
<a href="javascript:;" class="next-dd-item" {{on "click" (dropdown-fn dd this.createDashboard)}}>
<div class="w-6">
<FaIcon @icon="add" />
</div>
<span>Create new Dashboard</span>
</a>
{{#unless (eq this.dashboard.currentDashboard.owner_uuid "system")}}
<a href="javascript:;" class="next-dd-item" {{on "click" (dropdown-fn dd this.onChangeEdit true)}}>
<div class="w-6">
<FaIcon @icon="edit" />
</div>
<span>Edit layout</span>
</a>
<a href="javascript:;" class="next-dd-item" {{on "click" (dropdown-fn dd this.onAddingWidget true)}}>
<div class="w-6">
<FaIcon @icon="add" />
</div>
<span>Add widgets</span>
</a>
<a href="javascript:;" class="next-dd-item" {{on "click" (dropdown-fn dd this.deleteDashboard this.dashboard.currentDashboard)}}>
<div class="w-6">
<FaIcon @icon="trash" />
</div>
<span>Delete dashboard</span>
</a>
{{/unless}}
</div>
</div>
</DropdownButton>
</div>
{{#if this.dashboard.isEditingDashboard}}
<div class="ml-2 h-10">
<Button @type="magic" @icon="save" @helpText={{"Save Dashboard"}} @onClick={{fn this.onChangeEdit false}} class="h-10" />
</div>
{{/if}}
</div>
</div>
<div class="px-10">
<Dashboard::Create @isEdit={{this.dashboard.isEditingDashboard}} @isAddingWidget={{this.dashboard.isAddingWidget}} @dashboard={{this.dashboard.currentDashboard}} />
{{#if this.dashboard.isAddingWidget}}
<EmberWormhole @to="console-home-wormhole">
<Dashboard::WidgetPanel
@isOpen={{this.dashboard.isAddingWidget}}
@onLoad={{this.setWidgetSelectorPanelContext}}
@dashboard={{this.dashboard.currentDashboard}}
@onClose={{fn this.onAddingWidget false}}
/>
</EmberWormhole>
{{/if}}
</div>

View File

@@ -1,48 +1,84 @@
import Component from '@glimmer/component';
import loadExtensions from '@fleetbase/ember-core/utils/load-extensions';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { inject as service } from '@ember/service';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency-decorators';
export default class DashboardComponent extends Component {
@service store;
@service notifications;
@service modalsManager;
@service fetch;
@tracked extensions;
@tracked dashboards = [];
@tracked isLoading;
@service dashboard;
constructor() {
super(...arguments);
this.loadExtensions();
this.loadDashboards.perform();
}
@action async loadExtensions() {
this.extensions = await loadExtensions();
this.loadDashboardBuilds.perform();
@task *loadDashboards() {
this.dashboard.loadDashboards.perform();
}
@task *loadDashboard(extension) {
this.isLoading = extension.extension;
let dashboardBuild;
try {
dashboardBuild = yield this.fetch.get(extension.fleetbase.dashboard, {}, { namespace: '' });
} catch {
return;
}
if (isArray(dashboardBuild)) {
this.dashboards = [...this.dashboards, ...dashboardBuild.map((build) => ({ ...build, extension }))];
}
@action selectDashboard(dashboard) {
this.dashboard.selectDashboard.perform(dashboard);
}
@task({ enqueue: true, maxConcurrency: 1 }) *loadDashboardBuilds() {
const extensionsWithDashboards = this.extensions.filter((extension) => typeof extension.fleetbase?.dashboard === 'string');
@action setWidgetSelectorPanelContext(widgetSelectorContext) {
this.widgetSelectorContext = widgetSelectorContext;
}
for (let i = 0; i < extensionsWithDashboards.length; i++) {
const extension = extensionsWithDashboards[i];
yield this.loadDashboard.perform(extension);
@action onChangeEdit(state = true) {
this.isEditingDashboard = state;
}
@action createDashboard(dashboard, options = {}) {
this.modalsManager.show('modals/create-dashboard', {
title: `Create a new dashboard`,
acceptButtonText: 'Save Changes',
confirm: async (modal, done) => {
modal.startLoading();
// Get the name from the modal options
const { name } = modal.getOptions();
await this.dashboard.createDashboard.perform(name);
done();
},
...options,
});
}
@action deleteDashboard(dashboard, options = {}) {
if (this.dashboard.dashboards?.length === 1) {
return this.notifications.error('You cannot delete the last dashboard.');
}
this.modalsManager.confirm({
title: `Are you sure to delete this ${dashboard.name}?`,
confirm: async (modal, done) => {
if (typeof options.onConfirm === 'function') {
options.onConfirm(model);
}
modal.startLoading();
await this.dashboard.deleteDashboard.perform(dashboard);
done();
},
...options,
});
}
setCurrentDashboard(dashboard) {
this.dashboard.setCurrentDashboard.perform(dashboard);
}
onChangeEdit(state = true) {
this.dashboard.onChangeEdit(state);
}
@action onAddingWidget(state = true) {
this.dashboard.onAddingWidget(state);
}
}

View File

@@ -1,4 +1,4 @@
<div class="dashboard-component-count lg:col-span-2">
<div class="dashboard-component-count lg:col-span-2 h-full">
<h3 class="text-sm dark:text-gray-100 text-black mb-4">{{@options.title}}</h3>
<h1 class="text-3xl font-bold dark:text-gray-100 text-black mb-4">
{{this.displayValue}}

View File

@@ -1,18 +1,14 @@
<div class="col-span-{{or @dashboard.size 12}}">
<div class="dashboard-title flex flex-col lg:flex-row lg:items-center">
<div class="flex flex-row items-center mb-2 lg:mb-0">
{{#if this.isLoading}}
<Spinner class="mr-2i" />
{{/if}}
<h2 class="text-sm font-bold dark:text-gray-100 text-black">{{@dashboard.title}}</h2>
</div>
<div>
<Dashboard::QueryParams @params={{@dashboard.queryParams}} @onChange={{this.onQueryParamsChanged}} />
</div>
</div>
<div class="grid grid-cols-2 lg:grid-cols-12 gap-4">
{{#each this.dashboard.widgets as |widget|}}
{{component (concat "dashboard/" widget.component) options=widget.options}}
<div class="fleetbase-dashboard-grid" ...attributes>
<GridStack @options={{this.gridOptions}} @onChange={{this.onChangeGrid}}>
{{#each @dashboard.widgets as |widget|}}
<GridStackItem @options={{spread-widget-options (hash id=widget.uuid options=widget.grid_options)}} class="relative">
{{component widget.component options=widget.options}}
{{#if @isEdit}}
<div class="absolute top-2 right-2">
<Button @type="default" @icon="trash" @helpText={{"Remove widget from the dashboard"}} @onClick={{fn this.removeWidget widget}} />
</div>
{{/if}}
</GridStackItem>
{{/each}}
</div>
</GridStack>
</div>

View File

@@ -1,41 +1,67 @@
import { action, computed } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { merge } from '@ember/object/internals';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { task } from 'ember-concurrency-decorators';
export default class DashboardCreateComponent extends Component {
@service fetch;
@tracked isLoading = false;
@tracked dashboard;
@service notifications;
constructor() {
constructor(owner, args) {
super(...arguments);
this.dashboard = this.args.dashboard;
}
@action onQueryParamsChanged(changedParams) {
this.reloadDashboard.perform(changedParams);
@action
toggleFloat() {
this.shouldFloat = !this.shouldFloat;
}
@task *reloadDashboard(params) {
const { extension } = this.args.dashboard;
const index = this.args.index;
let dashboards = [];
@action onChangeGrid(event) {
console.log('Grid Stack event: ', event);
console.log(
'dashboard: ',
this.args.dashboard.widgets.map((widget) => widget.serialize())
);
this.isLoading = true;
const updatedWidgets = [];
try {
dashboards = yield this.fetch.get(extension.fleetbase.dashboard, params, { namespace: '' });
} catch {
return;
}
event.detail.forEach((currentWidgetEvent, index) => {
const alreadyUpdated = updatedWidgets.find((item) => item.uuid === currentWidgetEvent.id);
if (alreadyUpdated) {
return; // Skip updating if already updated
}
this.isLoading = false;
const changedWidget = this.args.dashboard.widgets.find((widget) => widget.id === currentWidgetEvent.id);
if (!changedWidget) {
return;
}
if (isArray(dashboards)) {
this.dashboard = dashboards.objectAt(index);
}
const { id, x, y, w, h } = currentWidgetEvent;
changedWidget.grid_options = { x, y, w, h };
const response = changedWidget.updatePosition({ id, x, y, h, w });
if (response) {
updatedWidgets.push(changedWidget);
}
});
}
@action removeWidget(widget) {
this.args.dashboard.removeWidget(widget.id).catch((error) => {
this.notifications.serverError(error);
});
}
@computed('args.isEdit')
get gridOptions() {
return {
float: true,
animate: true,
acceptWidgets: true,
alwaysShowResizeHandle: this.args.isEdit,
disableDrag: !this.args.isEdit,
disableResize: !this.args.isEdit,
resizable: { handles: 'all' },
cellHeight: 30,
};
}
}

View File

@@ -0,0 +1,18 @@
<div class="col-span-{{or @dashboard.size 12}}">
<div class="dashboard-title flex flex-col lg:flex-row lg:items-center">
<div class="flex flex-row items-center mb-2 lg:mb-0">
{{#if this.isLoading}}
<Spinner class="mr-2i" />
{{/if}}
<h2 class="text-sm font-bold dark:text-gray-100 text-black">{{this.dashboard.title}}</h2>
</div>
<div>
<Dashboard::QueryParams @params={{this.dashboard.queryParams}} @onChange={{this.onQueryParamsChanged}} />
</div>
</div>
<div class="grid grid-cols-2 lg:grid-cols-12 gap-4">
{{#each this.dashboard.widgets as |widget|}}
{{component (concat "dashboard/" widget.component) options=widget.options}}
{{/each}}
</div>
</div>

View File

@@ -0,0 +1,41 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { task } from 'ember-concurrency-decorators';
import { ar } from 'date-fns/locale';
export default class MetricComponent extends Component {
@service fetch;
@tracked isLoading = false;
@tracked dashboard;
constructor() {
super(...arguments);
console.log('Dashboard in Metric: ', this.args.options);
this.loadDashboard.perform();
}
@action onQueryParamsChanged(changedParams) {
this.loadDashboard.perform(changedParams);
}
@task *loadDashboard(params) {
let dashboards = [];
this.isLoading = true;
try {
dashboards = yield this.fetch.get(this.args.options.endpoint, params, { namespace: '' });
} catch {
return;
}
this.isLoading = false;
if (isArray(dashboards)) {
this.dashboard = dashboards.objectAt(0);
}
}
}

View File

@@ -0,0 +1,32 @@
<Overlay @isOpen={{@isOpen}} @onLoad={{this.setOverlayContext}} @position="right" @noBackdrop={{true}} @fullHeight={{true}} @width={{or this.width @width "400px"}}>
<Overlay::Header @title={{"Select widgets"}} @hideStatusDot={{true}} @titleWrapperClass="leading-5">
<div class="flex flex-1 justify-end">
<Button @type="default" @icon="times" @helpText={{"Close and Save"}} @onClick={{this.onPressClose}} />
</div>
</Overlay::Header>
<Overlay::Body @wrapperClass="new-service-rate-overlay-body px-4 space-y-4 pt-4" @increaseInnerBodyHeightBy={{1000}}>
<div class="grid grid-cols-1 gap-4 text-xs dark:text-gray-100">
{{#each this.availableWidgets as |widget|}}
<div
class="rounded-lg border border-gray-300 bg-white dark:border-gray-700 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-300 ease-in-out shadow-md px-4 py-2 cursor-pointer"
{{on "click" (fn this.addWidgetToDashboard widget)}}
>
<div class="flex flex-row items-center leading-6 mb-2">
<div class="w-8 flex items-center justify-start">
<FaIcon @icon={{widget.icon}} class="text-2xl text-gray-600 dark:text-gray-300" />
</div>
<p class="text-sm truncate font-semibold dark:text-gray-100 text-gray-800">
{{widget.name}}
widget
</p>
</div>
<div>
<p class="text-xs dark:text-gray-100 text-gray-800">{{widget.description}}</p>
</div>
</div>
{{/each}}
</div>
</Overlay::Body>
</Overlay>

View File

@@ -0,0 +1,56 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
export default class DashboardWidgetPanelComponent extends Component {
@service universe;
@tracked availableWidgets = [];
@tracked dashboard;
@tracked isOpen = true;
@service notifications;
/**
* Constructs the component and applies initial state.
*/
constructor(owner, { dashboard }) {
super(...arguments);
this.availableWidgets = this.universe.getDashboardWidgets();
this.dashboard = dashboard;
console.log(this.availableWidgets, this.dashboard);
}
/**
* Sets the overlay context.
*
* @action
* @param {OverlayContextObject} overlayContext
*/
@action setOverlayContext(overlayContext) {
this.context = overlayContext;
if (typeof this.args.onLoad === 'function') {
this.args.onLoad(...arguments);
}
}
@action addWidgetToDashboard(widget) {
console.log('Adding widget to dashboard: ', widget);
this.args.dashboard.addWidget(widget).catch((error) => {
this.notifications.serverError(error);
});
}
/**
* Handles cancel button press.
*
* @action
*/
@action onPressClose() {
this.isOpen = false;
console.log(this.args);
this.args.onClose();
}
}

View File

@@ -0,0 +1,5 @@
<Modal::Default @modalIsOpened={{@modalIsOpened}} @options={{@options}} @confirm={{@onConfirm}} @decline={{@onDecline}}>
<div class="modal-body-container">
<InputGroup @name="Dashboard name" @value={{@options.name}} @helpText="Enter the name of your dashboard" />
</div>
</Modal::Default>

View File

@@ -0,0 +1,7 @@
import { helper } from '@ember/component/helper';
export default helper(function spreadWidgetOptions([params]) {
const { id, options } = params;
const gridOptions = { id, ...options };
return gridOptions;
});

View File

@@ -0,0 +1,36 @@
import { faGithub } from '@fortawesome/free-brands-svg-icons';
export function initialize(application) {
const universe = application.lookup('service:universe');
const defaultWidgets = [
{
did: 'fleetbase-blog',
name: 'Fleetbase Blog',
description: 'Lists latest news and events from the Fleetbase official team.',
icon: 'newspaper',
component: 'fleetbase-blog',
grid_options: { w: 8, h: 9, minW: 8, minH: 9 },
options: {
title: 'Fleetbase Blog',
},
},
{
did: 'github-card',
name: 'Github Card',
description: 'Displays current Github stats from the official Fleetbase repo.',
icon: faGithub,
component: 'github-card',
grid_options: { w: 4, h: 8, minW: 4, minH: 8 },
options: {
title: 'Github Card',
},
},
];
universe.registerDefaultDashboardWidgets(defaultWidgets);
universe.registerDashboardWidgets(defaultWidgets);
}
export default {
initialize,
};

View File

@@ -0,0 +1,54 @@
import Model, { attr, belongsTo } from '@ember-data/model';
import { computed } from '@ember/object';
import { format, formatDistanceToNow } from 'date-fns';
import { getOwner } from '@ember/application';
export default class DashboardWidgetModel extends Model {
/** @ids */
@attr('string') uuid;
@attr('string') dashboard_uuid;
/** @relationships */
@belongsTo('dashboard') dashboard;
/** @attributes */
@attr('string') name;
@attr('string') component;
@attr('object') grid_options;
@attr('object') options;
/** @dates */
@attr('date') created_at;
@attr('date') updated_at;
/** @computed */
@computed('updated_at') get updatedAgo() {
return formatDistanceToNow(this.updated_at);
}
@computed('updated_at') get updatedAt() {
return format(this.updated_at, 'PPP p');
}
@computed('updated_at') get updatedAtShort() {
return format(this.updated_at, 'PP');
}
@computed('created_at') get createdAgo() {
return formatDistanceToNow(this.created_at);
}
@computed('created_at') get createdAt() {
return format(this.created_at, 'PPP p');
}
@computed('created_at') get createdAtShort() {
return format(this.created_at, 'PP');
}
updatePosition() {
this.save().then((response) => {
console.log('Widget updated successfully.', response);
});
}
}

View File

@@ -0,0 +1,89 @@
import Model, { attr, hasMany } from '@ember-data/model';
import { computed } from '@ember/object';
import { format, formatDistanceToNow } from 'date-fns';
import { getOwner } from '@ember/application';
export default class DashboardModel extends Model {
/** @ids */
@attr('string') owner_uuid;
/** @relationships */
@hasMany('dashboard-widget', { async: false }) widgets;
/** @attributes */
@attr('string') name;
@attr('boolean') is_default;
/** @dates */
@attr('date') created_at;
@attr('date') updated_at;
/** @computed */
@computed('updated_at') get updatedAgo() {
return formatDistanceToNow(this.updated_at);
}
@computed('updated_at') get updatedAt() {
return format(this.updated_at, 'PPP p');
}
@computed('updated_at') get updatedAtShort() {
return format(this.updated_at, 'PP');
}
@computed('created_at') get createdAgo() {
return formatDistanceToNow(this.created_at);
}
@computed('created_at') get createdAt() {
return format(this.created_at, 'PPP p');
}
@computed('created_at') get createdAtShort() {
return format(this.created_at, 'PP');
}
/** @methods */
addWidget(widget) {
const owner = getOwner(this);
const store = owner.lookup('service:store');
const widgetRecord = store.createRecord('dashboard-widget', widget);
widgetRecord.dashboard = this;
console.log(widgetRecord);
return new Promise((resolve, reject) => {
widgetRecord
.save()
.then((widgetRecord) => {
this.widgets.pushObject(widgetRecord);
resolve(widgetRecord);
})
.catch((error) => {
store.unloadRecord(widgetRecord);
reject(error);
});
});
}
removeWidget(widget) {
const owner = getOwner(this);
const store = owner.lookup('service:store');
const widgetRecord = store.peekRecord('dashboard-widget', widget);
if (widgetRecord) {
return new Promise((resolve, reject) => {
widgetRecord
.destroyRecord()
.then(() => {
this.widgets.removeObject(widgetRecord);
resolve();
})
.catch((error) => {
reject(error);
});
});
}
}
}

View File

@@ -0,0 +1,3 @@
import ApplicationSerializer from '@fleetbase/ember-core/serializers/application';
export default class DashboardWidgetSerializer extends ApplicationSerializer {}

View File

@@ -0,0 +1,18 @@
import ApplicationSerializer from '@fleetbase/ember-core/serializers/application';
import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest';
export default class DashboardSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) {
attrs = {
widgets: { embedded: 'always' },
};
serializeHasMany(snapshot, json, relationship) {
let key = relationship.key;
if (key === 'widgets') {
return;
}
return super.serializeHasMany(...arguments);
}
}

View File

@@ -0,0 +1,126 @@
// app/services/dashboard.js
import Service from '@ember/service';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency-decorators';
import { action } from '@ember/object';
export default class DashboardService extends Service {
@service store;
@service fetch;
@service notifications;
@service universe;
@tracked dashboards = [];
@tracked currentDashboard;
@tracked isEditingDashboard = false;
@tracked isAddingWidget = false;
@task *loadDashboards() {
try {
const dashboards = yield this.store.findAll('dashboard');
this.dashboards = dashboards.toArray();
if (dashboards.some((dashboard) => dashboard.owner_uuid !== 'system')) {
this.dashboards.unshiftObject(this._createDefaultDashboard());
}
// Set the current dashboard
this.currentDashboard = this.dashboards.find((dashboard) => dashboard.is_default) || this.dashboards[0];
if (this.currentDashboard?.widgets?.length === 0) {
this.onAddingWidget(true);
}
} catch (error) {
console.error('Error loading dashboards:', error);
}
}
@task *selectDashboard(dashboard) {
try {
if (dashboard.owner_uuid === 'system') {
this.currentDashboard = dashboard;
return;
}
const response = yield this.fetch.post('dashboards/switch', { dashboard_uuid: dashboard.id });
this.store.pushPayload(response);
const selectedDashboardId = response.uuid;
this.currentDashboard = this.store.peekRecord('dashboard', selectedDashboardId);
if (this.currentDashboard?.widgets?.length === 0) {
this.onChangeEdit(true);
}
} catch (error) {
this.notifications.error(`Error switching dashboard: ${error}`);
}
}
@task *createDashboard(name, options = {}) {
try {
const newDashboard = this.store.createRecord('dashboard', { name, is_default: true });
const response = yield newDashboard.save();
if (typeof options.successNotification === 'function') {
this.notifications.success(options.successNotification(response));
} else {
this.notifications.success(options.successNotification || `${response.name} created.`);
}
this.selectDashboard.perform(response);
this.isEditingDashboard = true;
} catch (error) {
this.notifications.serverError(error);
}
}
@task *deleteDashboard(dashboard, options = {}) {
try {
yield dashboard.destroyRecord();
this.notifications.success(options.successNotification || `${dashboard.name} has been deleted.`);
this.loadDashboards.perform();
} catch (error) {
this.notifications.serverError(error);
if (options.onError) {
options.onError(error, dashboard);
}
} finally {
if (options.callback) {
options.callback(this.currentDashboard);
}
}
}
@task *setCurrentDashboard(dashboard) {
try {
const response = yield this.fetch.post('dashboards/switch', { dashboard_uuid: dashboard.id });
this.currentDashboard = response;
} catch (error) {
this.notifications.error(`Error setting current dashboard: ${error}`);
}
}
@action onChangeEdit(state = true) {
this.isEditingDashboard = state;
}
@action onAddingWidget(state = true) {
this.isAddingWidget = state;
}
_createDefaultDashboard() {
const defaultDashboard = this.store.createRecord('dashboard', {
name: 'Default Dashboard',
is_default: false,
owner_uuid: 'system',
widgets: this._createDefaultDashboardWidgets(),
});
return defaultDashboard;
}
_createDefaultDashboardWidgets() {
const widgets = this.universe.getDefaultDashboardWidgets().map((defaultWidget) => {
return this.store.createRecord('dashboard-widget', defaultWidget);
});
return widgets;
}
}

View File

@@ -18,4 +18,5 @@
{{!-- Add Locale Selector to Header --}}
<EmberWormhole @to="view-header-actions">
<LocaleSelector class="mr-0.5" />
</EmberWormhole>
</EmberWormhole>
<div id="console-wormhole" />

View File

@@ -1,11 +1,7 @@
{{page-title "Dashboard"}}
<Layout::Section::Body class="overflow-y-scroll h-full pt-6">
<div class="console-home-container mx-auto h-screen space-y-4" {{increase-height-by 300}}>
<TwoFaEnforcementAlert />
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
<GithubCard class="lg:col-span-4" />
<FleetbaseBlog class="lg:col-span-8" />
</div>
<Dashboard @sidebar={{this.sidebarContext}} />
</div>
</Layout::Section::Body>
<Layout::Section::Body class="overflow-y-scroll h-full">
<TwoFaEnforcementAlert />
<Dashboard @sidebar={{this.sidebarContext}} />
<Spacer @height="300px" />
</Layout::Section::Body>
<div id="console-home-wormhole" />

View File

@@ -21,6 +21,7 @@
"lint:hbs:fix": "ember-template-lint . --fix",
"lint:js": "eslint . --cache",
"lint:js:fix": "eslint . --fix",
"postinstall": "patch-package",
"lint:intl": "fleetbase-intl-lint",
"start": "pnpm run prebuild && ember serve",
"test": "concurrently \"npm:lint\" \"npm:test:*\" --names \"lint,test:\"",
@@ -42,6 +43,7 @@
"ember-composable-helpers": "^5.0.0",
"ember-concurrency": "^3.0.0",
"ember-concurrency-decorators": "^2.0.3",
"ember-gridstack": "^4.0.0",
"ember-intl": "6.3.2",
"ember-math-helpers": "^2.18.2",
"ember-power-select": "^6.0.1",
@@ -49,6 +51,8 @@
"ember-radio-button": "3.0.0-beta.1",
"ember-tag-input": "^3.1.0",
"fleetbase-extensions-indexer": "^0.0.4",
"gridstack": "^7.2.2",
"patch-package": "^8.0.0",
"postcss-at-rules-variables": "^0.3.0",
"postcss-custom-properties": "^12.1.9",
"postcss-nth-list": "^1.0.2"

View File

@@ -0,0 +1,11 @@
diff --git a/node_modules/ember-gridstack/addon/components/grid-stack.js b/node_modules/ember-gridstack/addon/components/grid-stack.js
index fa51392..fdabb2a 100644
--- a/node_modules/ember-gridstack/addon/components/grid-stack.js
+++ b/node_modules/ember-gridstack/addon/components/grid-stack.js
@@ -133,5 +133,6 @@ export default class GridStackComponent extends Component {
removeWidget(element, removeDOM = false, triggerEvent = true) {
triggerEvent = triggerEvent && !this.isDestroying && !this.isDestroyed;
this.gridStack?.removeWidget(element, removeDOM, triggerEvent);
+ this.gridStack?.compact();
}
}

86
console/pnpm-lock.yaml generated
View File

@@ -51,6 +51,9 @@ dependencies:
ember-concurrency-decorators:
specifier: ^2.0.3
version: 2.0.3(@babel/core@7.23.2)
ember-gridstack:
specifier: ^4.0.0
version: 4.0.0(@babel/core@7.23.2)(ember-source@5.4.0)(webpack@5.89.0)
ember-intl:
specifier: 6.3.2
version: 6.3.2(@babel/core@7.23.2)(webpack@5.89.0)
@@ -72,6 +75,12 @@ dependencies:
fleetbase-extensions-indexer:
specifier: ^0.0.4
version: 0.0.4
gridstack:
specifier: ^7.2.2
version: 7.2.2
patch-package:
specifier: ^8.0.0
version: 8.0.0
postcss-at-rules-variables:
specifier: ^0.3.0
version: 0.3.0(postcss@8.4.21)
@@ -4102,6 +4111,10 @@ packages:
/@xtuc/long@4.2.2:
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
/@yarnpkg/lockfile@1.1.0:
resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==}
dev: false
/@zestia/ember-dragula@12.1.0(@babel/core@7.23.2)(ember-source@5.4.0)(webpack@5.89.0):
resolution: {integrity: sha512-iDc0qgdHobvMuoXB0tij+cVR8xmaEdBml97XwUkykbu7st7W0E6waWo65Qei+d8wHlCoFxchd9/7HOXHT0+73Q==}
engines: {node: 16.* || >= 18}
@@ -6149,7 +6162,6 @@ packages:
/ci-info@3.9.0:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
engines: {node: '>=8'}
dev: true
/cipher-base@1.0.4:
resolution: {integrity: sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==}
@@ -8519,6 +8531,26 @@ packages:
- supports-color
dev: false
/ember-gridstack@4.0.0(@babel/core@7.23.2)(ember-source@5.4.0)(webpack@5.89.0):
resolution: {integrity: sha512-qcl3E+k7wWO7D/zqTzU4jtu6ruVYFT/SbBoqaOBZR017aBDOo3zcavU80z6w8zNYqUG6gVWSXFKNTtbE/D2wlg==}
engines: {node: 14.* || 16.* || >= 18}
peerDependencies:
ember-source: ^4.0.0
dependencies:
'@ember/render-modifiers': 2.1.0(@babel/core@7.23.2)(ember-source@5.4.0)
ember-auto-import: 2.7.2(webpack@5.89.0)
ember-cli-babel: 7.26.11
ember-cli-htmlbars: 6.3.0
ember-modifier: 4.1.0(ember-source@5.4.0)
ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2)(rsvp@4.8.5)(webpack@5.89.0)
gridstack: 7.2.2
transitivePeerDependencies:
- '@babel/core'
- '@glint/template'
- supports-color
- webpack
dev: false
/ember-in-element-polyfill@1.0.1:
resolution: {integrity: sha512-eHs+7D7PuQr8a1DPqsJTsEyo3FZ1XuH6WEZaEBPDa9s0xLlwByCNKl8hi1EbXOgvgEZNHHi9Rh0vjxyfakrlgg==}
engines: {node: 10.* || >= 12}
@@ -10691,6 +10723,10 @@ packages:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
dev: true
/gridstack@7.2.2:
resolution: {integrity: sha512-9swEjbisKhtZlbmNiTCxOarp/9NWit5mLg6Z73sUhd4LKur5ZptMH16CUJu7HjMHxgI86FbQI5ZfMM/2TuMqdw==}
dev: false
/growly@1.3.0:
resolution: {integrity: sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==}
dev: true
@@ -11243,7 +11279,6 @@ packages:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
hasBin: true
dev: true
/is-extendable@0.1.1:
resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
@@ -11450,7 +11485,6 @@ packages:
engines: {node: '>=8'}
dependencies:
is-docker: 2.2.1
dev: true
/isarray@0.0.1:
resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==}
@@ -11639,6 +11673,12 @@ packages:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
/klaw-sync@6.0.0:
resolution: {integrity: sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==}
dependencies:
graceful-fs: 4.2.11
dev: false
/klaw@1.3.1:
resolution: {integrity: sha512-TED5xi9gGQjGpNnvRWknrwAB1eL5GciPfVFOt3Vk1OJCVDQbzuSfrF3hkUQKlsgKrG1F+0t5W0m+Fje1jIt8rw==}
optionalDependencies:
@@ -12761,6 +12801,14 @@ packages:
dependencies:
mimic-fn: 2.1.0
/open@7.4.2:
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
engines: {node: '>=8'}
dependencies:
is-docker: 2.2.1
is-wsl: 2.2.0
dev: false
/optionator@0.9.3:
resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==}
engines: {node: '>= 0.8.0'}
@@ -12996,6 +13044,28 @@ packages:
resolution: {integrity: sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==}
engines: {node: '>=0.10.0'}
/patch-package@8.0.0:
resolution: {integrity: sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==}
engines: {node: '>=14', npm: '>5'}
hasBin: true
dependencies:
'@yarnpkg/lockfile': 1.1.0
chalk: 4.1.2
ci-info: 3.9.0
cross-spawn: 7.0.3
find-yarn-workspace-root: 2.0.0
fs-extra: 9.1.0
json-stable-stringify: 1.1.1
klaw-sync: 6.0.0
minimist: 1.2.8
open: 7.4.2
rimraf: 2.7.1
semver: 7.5.4
slash: 2.0.0
tmp: 0.0.33
yaml: 2.3.4
dev: false
/path-browserify@0.0.1:
resolution: {integrity: sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==}
dev: false
@@ -14985,6 +15055,11 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
/slash@2.0.0:
resolution: {integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==}
engines: {node: '>=6'}
dev: false
/slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
@@ -16711,6 +16786,11 @@ packages:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}
/yaml@2.3.4:
resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==}
engines: {node: '>= 14'}
dev: false
/yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}

View File

@@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from '@fleetbase/console/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | dashboard/widget-panel', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<Dashboard::WidgetPanel />`);
assert.dom().hasText('');
// Template block usage:
await render(hbs`
<Dashboard::WidgetPanel>
template block text
</Dashboard::WidgetPanel>
`);
assert.dom().hasText('template block text');
});
});

View File

@@ -0,0 +1,17 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from '@fleetbase/console/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Helper | spread-widget-options', function (hooks) {
setupRenderingTest(hooks);
// TODO: Replace this with your real tests.
test('it renders', async function (assert) {
this.set('inputValue', '1234');
await render(hbs`{{spread-widget-options this.inputValue}}`);
assert.dom().hasText('1234');
});
});

View File

@@ -0,0 +1,14 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Model | dashboard', 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('dashboard', {});
assert.ok(model);
});
});

View File

@@ -0,0 +1,14 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Model | dashboard widget', 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('dashboard-widget', {});
assert.ok(model);
});
});

View File

@@ -0,0 +1,24 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Serializer | dashboard', 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('dashboard');
assert.ok(serializer);
});
test('it serializes records', function (assert) {
let store = this.owner.lookup('service:store');
let record = store.createRecord('dashboard', {});
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 | dashboard widget', 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('dashboard-widget');
assert.ok(serializer);
});
test('it serializes records', function (assert) {
let store = this.owner.lookup('service:store');
let record = store.createRecord('dashboard-widget', {});
let serializedRecord = record.serialize();
assert.ok(serializedRecord);
});
});

View File

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

View File

@@ -23,16 +23,16 @@ services:
SOCKETCLUSTER_WORKERS: 10
SOCKETCLUSTER_BROKERS: 10
console:
ports:
- "4200:4200"
volumes:
- ./console:/app/console
build:
context: .
dockerfile: console/Dockerfile
args:
ENVIRONMENT: development
# console:
# ports:
# - "4200:4200"
# volumes:
# - ./console:/app/console
# build:
# context: .
# dockerfile: console/Dockerfile
# args:
# ENVIRONMENT: development
application:
build: