diff --git a/package.json b/package.json index 83b4a342b..7a5abd4c2 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "vue-class-component": "^7.0.2", "vue-click-outside": "^1.1.0", "vue-clipboard2": "^0.3.1", + "vue-grid-layout": "2.4.0", "vue-i18n": "^8.11.2", "vue-json-tree-view": "^2.1.6", "vue-markdown": "^2.2.4", @@ -131,6 +132,7 @@ "raw-loader": "^4.0.2", "sass": "~1.32.13", "sass-loader": "^8.0.2", + "ts-node": "10.9.2", "typeorm-uml": "^1.6.4", "typescript": "^4.9.5", "vue-cli-plugin-electron-builder": "^2.1.1", diff --git a/src/assets/scss/mixins.scss b/src/assets/scss/mixins.scss index 2aa97b1fe..fc816fafd 100644 --- a/src/assets/scss/mixins.scss +++ b/src/assets/scss/mixins.scss @@ -191,3 +191,21 @@ font-weight: bold; } } + +@mixin color-picker-item { + .color-picker-item { + .el-form-item__content { + display: flex !important; + align-items: center !important; + line-height: normal !important; + height: 43px !important; + justify-content: flex-start !important; + } + + .el-color-picker { + vertical-align: middle !important; + margin: 0 !important; + align-self: center !important; + } + } +} diff --git a/src/components/ThresholdEditor.vue b/src/components/ThresholdEditor.vue new file mode 100644 index 000000000..fda0a888e --- /dev/null +++ b/src/components/ThresholdEditor.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/src/components/TimeRangeSelect.vue b/src/components/TimeRangeSelect.vue index 1dbfbd1af..085160c15 100644 --- a/src/components/TimeRangeSelect.vue +++ b/src/components/TimeRangeSelect.vue @@ -6,7 +6,7 @@ :end-placeholder="$t('common.endTime')" :picker-options="pickerOptions" value-format="yyyy-MM-dd HH:mm:ss:SSS" - size="small" + :size="size" popper-class="time-range-picker-popper" > @@ -18,18 +18,61 @@ import { Component, Vue, Prop, Watch } from 'vue-property-decorator' @Component export default class TimeRangeSelect extends Vue { @Prop({ required: true }) public value!: [string, string] + @Prop({ default: 'small' }) public size!: string + @Prop({ default: false }) public showLiveMode!: boolean + @Prop({ default: 'static' }) public timeRangeType!: 'live' | 'static' + @Prop({ default: 24 * 60 }) public duration!: number private modelValue = this.value + private currentDuration: number | null = null + private isLiveMode: boolean = false + private isFromShortcut: boolean = false + private shortcutLiveMode: boolean = false + private shortcutDuration: number | null = null + + // Initialize with props + private mounted() { + this.isLiveMode = this.timeRangeType === 'live' + this.currentDuration = this.duration * 60 * 1000 // Convert minutes to milliseconds + } @Watch('value') private onValueChange(newVal: [string, string]) { this.modelValue = newVal } + @Watch('timeRangeType') + private onTimeRangeTypeChange() { + this.isLiveMode = this.timeRangeType === 'live' + } + + @Watch('duration') + private onDurationChange() { + this.currentDuration = this.duration * 60 * 1000 // Convert minutes to milliseconds + } + @Watch('modelValue') private onModelValueChange(newVal: [string, string] | null) { this.$emit('input', newVal) this.$emit('change', newVal) + + // Only emit range-relative for shortcuts, not for external changes (like dashboard switches) + if (this.isFromShortcut) { + // Use shortcut-specific values + const finalIsLive = this.shortcutLiveMode + const finalDuration = this.shortcutDuration + + this.$emit('range-relative', { + timeRange: newVal, + duration: finalDuration || 24 * 60 * 60 * 1000, // Default to 24 hours in milliseconds + isLive: finalIsLive, + }) + + // Reset shortcut flags after emitting + this.isFromShortcut = false + this.shortcutLiveMode = false + this.shortcutDuration = null + } } private pickerOptions = { @@ -39,7 +82,8 @@ export default class TimeRangeSelect extends Vue { shortcuts: [ { text: this.$t('common.last5Minutes'), - onClick(picker: any) { + onClick: (picker: any) => { + this.setShortcutValues(true, 5 * 60 * 1000) const end = new Date() const start = new Date() start.setTime(start.getTime() - 5 * 60 * 1000) @@ -48,7 +92,8 @@ export default class TimeRangeSelect extends Vue { }, { text: this.$t('common.last30Minutes'), - onClick(picker: any) { + onClick: (picker: any) => { + this.setShortcutValues(true, 30 * 60 * 1000) const end = new Date() const start = new Date() start.setTime(start.getTime() - 30 * 60 * 1000) @@ -57,7 +102,8 @@ export default class TimeRangeSelect extends Vue { }, { text: this.$t('common.lastHour'), - onClick(picker: any) { + onClick: (picker: any) => { + this.setShortcutValues(true, 60 * 60 * 1000) const end = new Date() const start = new Date() start.setTime(start.getTime() - 60 * 60 * 1000) @@ -66,7 +112,8 @@ export default class TimeRangeSelect extends Vue { }, { text: this.$t('common.lastDay'), - onClick(picker: any) { + onClick: (picker: any) => { + this.setShortcutValues(true, 24 * 60 * 60 * 1000) const end = new Date() const start = new Date() start.setTime(start.getTime() - 24 * 60 * 60 * 1000) @@ -75,7 +122,8 @@ export default class TimeRangeSelect extends Vue { }, { text: this.$t('common.lastWeek'), - onClick(picker: any) { + onClick: (picker: any) => { + this.setShortcutValues(true, 7 * 24 * 60 * 60 * 1000) const end = new Date() const start = new Date() start.setTime(start.getTime() - 7 * 24 * 60 * 60 * 1000) @@ -84,6 +132,12 @@ export default class TimeRangeSelect extends Vue { }, ], } + + private setShortcutValues(isLive: boolean, duration: number) { + this.isFromShortcut = true + this.shortcutLiveMode = isLive + this.shortcutDuration = duration + } } diff --git a/src/components/charts/BigNumber.vue b/src/components/charts/BigNumber.vue new file mode 100644 index 000000000..fa088af85 --- /dev/null +++ b/src/components/charts/BigNumber.vue @@ -0,0 +1,553 @@ + + + + + diff --git a/src/components/charts/Gauge.vue b/src/components/charts/Gauge.vue new file mode 100644 index 000000000..33a2d81f7 --- /dev/null +++ b/src/components/charts/Gauge.vue @@ -0,0 +1,414 @@ + + + + + diff --git a/src/components/charts/LineChart.vue b/src/components/charts/LineChart.vue new file mode 100644 index 000000000..b6b576776 --- /dev/null +++ b/src/components/charts/LineChart.vue @@ -0,0 +1,337 @@ + + + + + diff --git a/src/components/widget-configs/BigNumberConfig.vue b/src/components/widget-configs/BigNumberConfig.vue new file mode 100644 index 000000000..4d6c63386 --- /dev/null +++ b/src/components/widget-configs/BigNumberConfig.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/src/components/widget-configs/GaugeConfig.vue b/src/components/widget-configs/GaugeConfig.vue new file mode 100644 index 000000000..d420cb77a --- /dev/null +++ b/src/components/widget-configs/GaugeConfig.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/src/components/widget-configs/LineConfig.vue b/src/components/widget-configs/LineConfig.vue new file mode 100644 index 000000000..55f7acc51 --- /dev/null +++ b/src/components/widget-configs/LineConfig.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/src/database/database.config.ts b/src/database/database.config.ts index 81e5b2375..52520944b 100644 --- a/src/database/database.config.ts +++ b/src/database/database.config.ts @@ -12,6 +12,8 @@ import HistoryConnectionEntity from './models/HistoryConnectionEntity' import WillEntity from './models/WillEntity' import CopilotEntity from './models/CopilotEntity' import TopicNodeEntity from './models/TopicNodeEntity' +import DashboardEntity from './models/DashboardEntity' +import WidgetEntity from './models/WidgetEntity' import { ConnectionOptions } from 'typeorm' import { initTable1629476510574 } from './migration/1629476510574-initTable' import { messages1630403733964 } from './migration/1630403733964-messages' @@ -49,10 +51,16 @@ import { ignoreQoS0Message1724839386732 } from './migration/1724839386732-ignore import { changeDefaultLLMModel1727111519962 } from './migration/1727111519962-changeDefaultLLMModel' import { topicNodeTables1729246737362 } from './migration/1729246737362-topicNodeTables' import { reasonModelSupport1742835643809 } from './migration/1742835643809-reasonModelSupport' +import { dashboardAndWidgetModels1759069904761 } from './migration/1759069904761-dashboardAndWidgetModels' -// Get the unified store path using Electron's native API -// This fixes the Windows %APPDATA% redirect issue -const STORE_PATH = getUnifiedAppDataPath('MQTTX') +// Get store path; fall back when running in CLI (no Electron app) +let STORE_PATH: string +try { + STORE_PATH = getUnifiedAppDataPath('MQTTX') +} catch (e) { + const home = process.env.HOME || require('os').homedir() + STORE_PATH = process.env.MQTTX_DATA_PATH || join(home, '.mqttx') +} // Ensure the directory exists ensureAppDataDir(STORE_PATH) @@ -105,6 +113,7 @@ const ORMConfig = { changeDefaultLLMModel1727111519962, topicNodeTables1729246737362, reasonModelSupport1742835643809, + dashboardAndWidgetModels1759069904761, ], migrationsTableName: 'temp_migration_table', entities: [ @@ -120,6 +129,8 @@ const ORMConfig = { HistoryConnectionEntity, CopilotEntity, TopicNodeEntity, + DashboardEntity, + WidgetEntity, ], cli: { migrationsDir: 'src/database/migration', diff --git a/src/database/migration/1759069904761-dashboardAndWidgetModels.ts b/src/database/migration/1759069904761-dashboardAndWidgetModels.ts new file mode 100644 index 000000000..c82f2bcbe --- /dev/null +++ b/src/database/migration/1759069904761-dashboardAndWidgetModels.ts @@ -0,0 +1,240 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class dashboardAndWidgetModels1759069904761 implements MigrationInterface { + name = 'dashboardAndWidgetModels1759069904761' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "WidgetEntity" ( + "id" varchar PRIMARY KEY NOT NULL, + "dashboard_id" varchar NOT NULL, + "x" integer NOT NULL, + "y" integer NOT NULL, + "w" integer NOT NULL, + "h" integer NOT NULL, + "static" boolean NOT NULL DEFAULT (0), + "minW" integer, + "minH" integer, + "maxW" integer, + "maxH" integer, + "type" varchar NOT NULL, + "title" varchar, + "connectionId" varchar, + "topicPattern" varchar, + "valueField" varchar, + "fallbackValue" float NOT NULL DEFAULT (0), + "schemaType" varchar CHECK(schemaType IN ('protobuf', 'avro')), + "schemaId" varchar, + "schemaMessageName" varchar, + "schemaValidationState" varchar CHECK(schemaValidationState IN ('valid', 'invalid')), + "schemaValidationError" text, + "widgetOptions" text, + "createAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "updateAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP) + ) + `) + await queryRunner.query(` + CREATE TABLE "DashboardEntity" ( + "id" varchar PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "description" varchar, + "orderId" integer, + "globalSettings" text, + "createAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "updateAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP) + ) + `) + await queryRunner.query(` + CREATE TABLE "temporary_WidgetEntity" ( + "id" varchar PRIMARY KEY NOT NULL, + "dashboard_id" varchar NOT NULL, + "x" integer NOT NULL, + "y" integer NOT NULL, + "w" integer NOT NULL, + "h" integer NOT NULL, + "static" boolean NOT NULL DEFAULT (0), + "minW" integer, + "minH" integer, + "maxW" integer, + "maxH" integer, + "type" varchar NOT NULL, + "title" varchar, + "connectionId" varchar, + "topicPattern" varchar, + "valueField" varchar, + "fallbackValue" float NOT NULL DEFAULT (0), + "schemaType" varchar CHECK(schemaType IN ('protobuf', 'avro')), + "schemaId" varchar, + "schemaMessageName" varchar, + "schemaValidationState" varchar CHECK(schemaValidationState IN ('valid', 'invalid')), + "schemaValidationError" text, + "widgetOptions" text, + "createAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "updateAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT "FK_0edfec3287926ebbc0a301bfd13" FOREIGN KEY ("dashboard_id") REFERENCES "DashboardEntity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `) + await queryRunner.query(` + INSERT INTO "temporary_WidgetEntity"( + "id", + "dashboard_id", + "x", + "y", + "w", + "h", + "static", + "minW", + "minH", + "maxW", + "maxH", + "type", + "title", + "connectionId", + "topicPattern", + "valueField", + "fallbackValue", + "schemaType", + "schemaId", + "schemaMessageName", + "schemaValidationState", + "schemaValidationError", + "widgetOptions", + "createAt", + "updateAt" + ) + SELECT "id", + "dashboard_id", + "x", + "y", + "w", + "h", + "static", + "minW", + "minH", + "maxW", + "maxH", + "type", + "title", + "connectionId", + "topicPattern", + "valueField", + "fallbackValue", + "schemaType", + "schemaId", + "schemaMessageName", + "schemaValidationState", + "schemaValidationError", + "widgetOptions", + "createAt", + "updateAt" + FROM "WidgetEntity" + `) + await queryRunner.query(` + DROP TABLE "WidgetEntity" + `) + await queryRunner.query(` + ALTER TABLE "temporary_WidgetEntity" + RENAME TO "WidgetEntity" + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "WidgetEntity" + RENAME TO "temporary_WidgetEntity" + `) + await queryRunner.query(` + CREATE TABLE "WidgetEntity" ( + "id" varchar PRIMARY KEY NOT NULL, + "dashboard_id" varchar NOT NULL, + "x" integer NOT NULL, + "y" integer NOT NULL, + "w" integer NOT NULL, + "h" integer NOT NULL, + "static" boolean NOT NULL DEFAULT (0), + "minW" integer, + "minH" integer, + "maxW" integer, + "maxH" integer, + "type" varchar NOT NULL, + "title" varchar, + "connectionId" varchar, + "topicPattern" varchar, + "valueField" varchar, + "fallbackValue" float NOT NULL DEFAULT (0), + "schemaType" varchar CHECK(schemaType IN ('protobuf', 'avro')), + "schemaId" varchar, + "schemaMessageName" varchar, + "schemaValidationState" varchar CHECK(schemaValidationState IN ('valid', 'invalid')), + "schemaValidationError" text, + "widgetOptions" text, + "createAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "updateAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP) + ) + `) + await queryRunner.query(` + INSERT INTO "WidgetEntity"( + "id", + "dashboard_id", + "x", + "y", + "w", + "h", + "static", + "minW", + "minH", + "maxW", + "maxH", + "type", + "title", + "connectionId", + "topicPattern", + "valueField", + "fallbackValue", + "schemaType", + "schemaId", + "schemaMessageName", + "schemaValidationState", + "schemaValidationError", + "widgetOptions", + "createAt", + "updateAt" + ) + SELECT "id", + "dashboard_id", + "x", + "y", + "w", + "h", + "static", + "minW", + "minH", + "maxW", + "maxH", + "type", + "title", + "connectionId", + "topicPattern", + "valueField", + "fallbackValue", + "schemaType", + "schemaId", + "schemaMessageName", + "schemaValidationState", + "schemaValidationError", + "widgetOptions", + "createAt", + "updateAt" + FROM "temporary_WidgetEntity" + `) + await queryRunner.query(` + DROP TABLE "temporary_WidgetEntity" + `) + await queryRunner.query(` + DROP TABLE "DashboardEntity" + `) + await queryRunner.query(` + DROP TABLE "WidgetEntity" + `) + } +} diff --git a/src/database/models/DashboardEntity.ts b/src/database/models/DashboardEntity.ts new file mode 100644 index 000000000..a90563a09 --- /dev/null +++ b/src/database/models/DashboardEntity.ts @@ -0,0 +1,29 @@ +import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm' +import WidgetEntity from './WidgetEntity' + +@Entity('DashboardEntity') +export default class DashboardEntity { + @PrimaryGeneratedColumn('uuid') + id!: string + + @Column({ type: 'varchar' }) + name!: string + + @Column({ type: 'varchar', nullable: true }) + description?: string + + @Column({ type: 'integer', nullable: true }) + orderId?: number + + @Column({ type: 'simple-json', nullable: true }) + globalSettings?: any + + @OneToMany(() => WidgetEntity, (widget: WidgetEntity) => widget.dashboard, { cascade: true }) + widgets!: WidgetEntity[] + + @CreateDateColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + createAt!: string + + @UpdateDateColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + updateAt!: string +} diff --git a/src/database/models/WidgetEntity.ts b/src/database/models/WidgetEntity.ts new file mode 100644 index 000000000..6ea0d759a --- /dev/null +++ b/src/database/models/WidgetEntity.ts @@ -0,0 +1,93 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm' +import DashboardEntity from './DashboardEntity' + +@Entity('WidgetEntity') +export default class WidgetEntity { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => DashboardEntity, (dashboard) => dashboard.widgets, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'dashboard_id', referencedColumnName: 'id' }) + dashboard?: DashboardEntity + + @Column({ name: 'dashboard_id' }) + dashboardId!: string + + @Column({ type: 'integer' }) + x!: number + + @Column({ type: 'integer' }) + y!: number + + @Column({ type: 'integer' }) + w!: number + + @Column({ type: 'integer' }) + h!: number + + @Column({ type: 'boolean', default: false }) + 'static'!: boolean + + @Column({ type: 'integer', nullable: true }) + minW?: number + + @Column({ type: 'integer', nullable: true }) + minH?: number + + @Column({ type: 'integer', nullable: true }) + maxW?: number + + @Column({ type: 'integer', nullable: true }) + maxH?: number + + @Column({ type: 'varchar' }) + type!: string + + @Column({ type: 'varchar', nullable: true }) + title?: string + + @Column({ type: 'varchar', nullable: true }) + connectionId?: string + + @Column({ type: 'varchar', nullable: true }) + topicPattern?: string + + @Column({ type: 'varchar', nullable: true }) + valueField?: string + + @Column({ type: 'float', default: 0 }) + fallbackValue!: number + + // Schema support (updated for integration) + @Column({ type: 'simple-enum', enum: ['protobuf', 'avro'], nullable: true }) + schemaType?: 'protobuf' | 'avro' + + @Column({ type: 'varchar', nullable: true }) + schemaId?: string + + @Column({ type: 'varchar', nullable: true }) + schemaMessageName?: string + + @Column({ type: 'simple-enum', enum: ['valid', 'invalid'], nullable: true }) + schemaValidationState?: 'valid' | 'invalid' + + @Column({ type: 'text', nullable: true }) + schemaValidationError?: string + + @Column({ type: 'simple-json', nullable: true }) + widgetOptions?: {} + + @CreateDateColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + createAt!: string + + @UpdateDateColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + updateAt!: string +} diff --git a/src/database/services/DashboardService.ts b/src/database/services/DashboardService.ts new file mode 100644 index 000000000..97a2e5706 --- /dev/null +++ b/src/database/services/DashboardService.ts @@ -0,0 +1,175 @@ +import { Service } from 'typedi' +import { InjectRepository } from 'typeorm-typedi-extensions' +import { Repository } from 'typeorm' +import DashboardEntity from '@/database/models/DashboardEntity' +import WidgetEntity from '@/database/models/WidgetEntity' +import time from '@/utils/time' + +/** + * Service for managing dashboards and their widgets. + * Provides CRUD operations and utility methods for dashboard ordering and cascading fetches. + */ +@Service() +export default class DashboardService { + constructor( + // @ts-ignore + @InjectRepository(DashboardEntity) + private dashboardRepository: Repository, + ) {} + + public static entityToModel(entity: DashboardEntity): DashboardModel { + return { + ...entity, + widgets: (entity.widgets || []).map((w) => DashboardService.widgetEntityToModel(w)), + } + } + + public static modelToEntity(model: Partial): Partial { + const { widgets, ...rest } = model + const entity: Partial = { + ...rest, + widgets: widgets?.map((w) => DashboardService.widgetModelToEntity(w as WidgetModel) as WidgetEntity), + } + return entity + } + + public static widgetEntityToModel(entity: WidgetEntity): WidgetModel { + return { + ...entity, + } as WidgetModel + } + + public static widgetModelToEntity(model: Partial): Partial { + return { + ...model, + } as Partial + } + + /** + * Creates a new dashboard in the database. + * If orderId is not provided, it will be set to the next available value. + * @param data - The DashboardModel data to create. + * @returns The created DashboardModel, or undefined if creation failed. + */ + public async create(data: DashboardModel): Promise { + // Auto-assign orderId if not provided + let orderId = data.orderId + if (orderId === undefined) { + const maxOrderResult = await this.dashboardRepository + .createQueryBuilder('db') + .select('MAX(db.orderId)', 'maxOrder') + .getRawOne() + orderId = (maxOrderResult?.maxOrder || 0) + 1 + } + + const toSave = DashboardService.modelToEntity({ + ...data, + orderId, + createAt: time.getNowDate(), + updateAt: time.getNowDate(), + }) as DashboardEntity + const saved = await this.dashboardRepository.save(toSave) + return DashboardService.entityToModel(saved) + } + + /** + * Updates an existing dashboard by its ID. + * Widgets are excluded from the update (handled separately). + * @param id - The ID of the dashboard to update. + * @param data - Partial DashboardModel data to update. + * @returns The updated DashboardModel, or undefined if not found. + */ + public async update(id: string, data: Partial): Promise { + const { widgets, ...rest } = data // Exclude widgets if present + const toUpdate = { + ...rest, + updateAt: time.getNowDate(), + } + const result = await this.dashboardRepository.update(id, toUpdate) + if (result.affected === 0) return undefined + return this.get(id) // Use existing get which includes widgets + } + + /** + * Deletes a dashboard by its ID. + * @param id - The ID of the dashboard to delete. + * @returns The deleted DashboardModel, or undefined if not found. + */ + public async delete(id: string): Promise { + const exist = await this.dashboardRepository.findOne(id) + if (!exist) return + await this.dashboardRepository.delete(id) + return DashboardService.entityToModel(exist) + } + + /** + * Retrieves a dashboard by its ID, including its widgets. + * @param id - The ID of the dashboard to retrieve. + * @returns The DashboardModel if found, otherwise undefined. + */ + public async get(id: string): Promise { + const entity = await this.dashboardRepository + .createQueryBuilder('db') + .where('db.id = :id', { id }) + .leftJoinAndSelect('db.widgets', 'wg') + .getOne() + return entity ? DashboardService.entityToModel(entity) : undefined + } + + /** + * Retrieves all dashboards, ordered by orderId (or fallback), then by creation time. + * Widgets are not included in this method. + * @returns An array of DashboardModels. + */ + public async getAll(): Promise { + const entities = await this.dashboardRepository + .createQueryBuilder('db') + .orderBy('COALESCE(db.orderId, 999999)', 'ASC') + .addOrderBy('db.createAt', 'ASC') + .getMany() + return entities.map((e) => DashboardService.entityToModel(e)) + } + + /** + * Retrieves all dashboards (or a specific one if id is provided), including their widgets. + * Results are ordered by orderId (or fallback), then by creation time. + * @param id - (Optional) The dashboard ID to filter by. + * @returns An array of DashboardModels. + */ + public async cascadeGetAll(id?: string): Promise { + const qb = this.dashboardRepository + .createQueryBuilder('db') + .leftJoinAndSelect('db.widgets', 'wg') + .orderBy('COALESCE(db.orderId, 999999)', 'ASC') + .addOrderBy('db.createAt', 'ASC') + + if (id) { + qb.where('db.id = :id', { id }) + } + + const entities = await qb.getMany() + return entities.map((e) => DashboardService.entityToModel(e)) + } + + /** + * Batch updates the orderId of multiple dashboards for reordering. + * @param dashboardOrderUpdates - Array of objects containing id and new orderId for each dashboard. + * @returns True if successful, false if an error occurred. + */ + public async updateOrders(dashboardOrderUpdates: { id: string; orderId: number }[]): Promise { + try { + await this.dashboardRepository.manager.transaction(async (transactionalEntityManager) => { + for (const update of dashboardOrderUpdates) { + await transactionalEntityManager.update(DashboardEntity, update.id, { + orderId: update.orderId, + updateAt: time.getNowDate(), + }) + } + }) + return true + } catch (error) { + console.error('Error updating dashboard orders:', error) + return false + } + } +} diff --git a/src/database/services/WidgetService.ts b/src/database/services/WidgetService.ts new file mode 100644 index 000000000..3f4f57733 --- /dev/null +++ b/src/database/services/WidgetService.ts @@ -0,0 +1,130 @@ +import { Service } from 'typedi' +import { InjectRepository } from 'typeorm-typedi-extensions' +import { Repository, UpdateResult } from 'typeorm' +import WidgetEntity from '@/database/models/WidgetEntity' +import time from '@/utils/time' + +@Service() +export default class WidgetService { + constructor( + // @ts-ignore + @InjectRepository(WidgetEntity) + private widgetRepository: Repository, + ) {} + + public static entityToModel(entity: WidgetEntity): WidgetModel { + return { + ...entity, + } as WidgetModel + } + + public static modelToEntity(model: Partial): Partial { + return { + ...model, + } as Partial + } + + /** + * Creates a new widget in the database. + * @param data - The WidgetModel data to create. + * @returns The created WidgetModel, or undefined if creation failed. + */ + public async create(data: WidgetModel): Promise { + const toSave = WidgetService.modelToEntity({ + ...data, + createAt: time.getNowDate(), + updateAt: time.getNowDate(), + }) as WidgetEntity + const saved = await this.widgetRepository.save(toSave) + return WidgetService.entityToModel(saved) + } + + /** + * Updates an existing widget by its ID. + * @param id - The ID of the widget to update. + * @param data - Partial WidgetModel data to update. + * @returns The updated WidgetModel, or undefined if not found. + */ + public async update(id: string, data: Partial): Promise { + const toUpdate = WidgetService.modelToEntity({ ...data, updateAt: time.getNowDate() }) + const result = await this.widgetRepository.update(id, toUpdate) + if (result.affected === 0) return undefined + return this.get(id) + } + + /** + * Deletes a widget by its ID. + * @param id - The ID of the widget to delete. + * @returns The deleted WidgetModel, or undefined if not found. + */ + public async delete(id: string): Promise { + const exist = await this.widgetRepository.findOne(id) + if (!exist) return + await this.widgetRepository.delete(id) + return WidgetService.entityToModel(exist) + } + + /** + * Retrieves a widget by its ID. + * @param id - The ID of the widget to retrieve. + * @returns The WidgetModel if found, otherwise undefined. + */ + public async get(id: string): Promise { + const entity = await this.widgetRepository.createQueryBuilder('wg').where('wg.id = :id', { id }).getOne() + return entity ? WidgetService.entityToModel(entity) : undefined + } + + /** + * Retrieves all widgets, optionally filtered by dashboardId. + * Results are ordered by dashboardId, y, and x. + * @param dashboardId - (Optional) The dashboard ID to filter widgets. + * @returns An array of WidgetModels. + */ + public async getAll(dashboardId?: string): Promise { + const qb = this.widgetRepository.createQueryBuilder('wg') + if (dashboardId) { + qb.where('wg.dashboardId = :dashboardId', { dashboardId }) + } + qb.orderBy('wg.dashboardId', 'ASC').addOrderBy('wg.y', 'ASC').addOrderBy('wg.x', 'ASC') + const entities = await qb.getMany() + return entities.map((e) => WidgetService.entityToModel(e)) + } + + /** + * Retrieves all widgets for a dashboard, or all widgets if no dashboardId is provided. + * This is an alias for getAll. + * @param dashboardId - (Optional) The dashboard ID to filter widgets. + * @returns An array of WidgetModels. + */ + public async cascadeGetAll(dashboardId?: string): Promise { + return this.getAll(dashboardId) + } + + /** + * Batch updates the position and size of multiple widgets. + * @param updates - Array of objects containing id, x, y, w, h for each widget. + * @returns True if successful, false if an error occurred. + */ + public async updateMany( + updates: Array<{ id: string; x: number; y: number; w: number; h: number }>, + ): Promise { + if (!updates || updates.length === 0) return true + try { + await this.widgetRepository.manager.transaction(async (transactionalEntityManager) => { + for (const update of updates) { + await transactionalEntityManager.update(WidgetEntity, update.id, { + x: update.x, + y: update.y, + w: update.w, + h: update.h, + updateAt: time.getNowDate(), + }) + } + }) + return true + } catch (e) { + console.error('Error updating widgets:', e) + return false + } + } +} diff --git a/src/database/useServices.ts b/src/database/useServices.ts index 5290ab1b3..f0ed244a9 100644 --- a/src/database/useServices.ts +++ b/src/database/useServices.ts @@ -10,6 +10,8 @@ import MessageService from './services/MessageService' import ScriptService from './services/ScriptService' import CopilotService from './services/CopilotService' import TopicNodeService from './services/TopicNodeService' +import DashboardService from './services/DashboardService' +import WidgetService from './services/WidgetService' export default function useServices() { const connectionService = Container.get(ConnectionService) @@ -23,6 +25,8 @@ export default function useServices() { const scriptService = Container.get(ScriptService) const copilotService = Container.get(CopilotService) const topicNodeService = Container.get(TopicNodeService) + const dashboardService = Container.get(DashboardService) + const widgetService = Container.get(WidgetService) return { connectionService, @@ -36,5 +40,7 @@ export default function useServices() { scriptService, copilotService, topicNodeService, + dashboardService, + widgetService, } } diff --git a/src/lang/viewer.ts b/src/lang/viewer.ts index f1971101a..5d8296188 100644 --- a/src/lang/viewer.ts +++ b/src/lang/viewer.ts @@ -6,6 +6,55 @@ export default { ja: 'ビューア', hu: 'Megjelenítő', }, + dashboard: { + zh: '仪表盘', + en: 'Dashboard', + tr: 'Gösterge Paneli', + ja: 'ダッシュボード', + hu: 'Irányítópult', + }, + dashboards: { + zh: '仪表盘', + en: 'Dashboards', + tr: 'Gösterge Panelleri', + ja: 'ダッシュボード', + hu: 'Irányítópultok', + }, + createDashboard: { + zh: '新建仪表盘', + en: 'Create Dashboard', + tr: 'Gösterge Paneli Oluştur', + ja: 'ダッシュボードを作成', + hu: 'Irányítópult létrehozása', + }, + hideDashboards: { + zh: '隐藏仪表盘', + en: 'Hide Dashboards', + tr: 'Gösterge Panellerini Gizle', + ja: 'ダッシュボードを非表示', + hu: 'Irányítópultok elrejtése', + }, + noWidgetsTitle: { + zh: '添加可视化组件,开始使用仪表盘', + en: 'Start your new dashboard by adding a visualization', + tr: 'Yeni gösterge panelinizi bir görselleştirme ekleyerek başlatın', + ja: '新しいダッシュボードを可視化の追加から始めましょう', + hu: 'Kezdje az új irányítópultot egy vizualizáció hozzáadásával', + }, + noWidgetsHint: { + zh: '选择一个数据源,然后通过图表、统计和其他小部件查询并可视化你的数据', + en: 'Select a data source and then query and visualize your data with charts, stats and other widgets', + tr: "Bir veri kaynağı seçin, ardından verilerinizi grafikler, istatistikler ve diğer widget'larla sorgulayın ve görselleştirin", + ja: 'データソースを選択し、チャートや統計、その他のウィジェットでデータをクエリして可視化しましょう', + hu: 'Válasszon ki egy adatforrást, majd lekérdezze és vizualizálja adatait diagramokkal, statisztikákkal és egyéb widgetekkel', + }, + addVisualization: { + zh: '添加可视化组件', + en: 'Add Visualization', + tr: 'Görselleştirme Ekle', + ja: '可視化を追加', + hu: 'Vizualizáció hozzáadása', + }, topicsTree: { zh: '主题树', en: 'Topics Tree', @@ -312,4 +361,221 @@ export default { ja: 'メッセージの読み込みに失敗しました', hu: 'Nem sikerült betölteni az üzeneteket', }, + general: { + zh: '常规', + en: 'General', + tr: 'Genel', + ja: '一般', + hu: 'Általános', + }, + preview: { + zh: '预览', + en: 'Preview', + tr: 'Önizleme', + ja: 'プレビュー', + hu: 'Előnézet', + }, + dataSource: { + zh: '数据源', + en: 'Data Source', + tr: 'Veri Kaynağı', + ja: 'データソース', + hu: 'Adatforrás', + }, + connection: { + zh: '连接', + en: 'Connection', + tr: 'Bağlantı', + ja: '接続', + hu: 'Kapcsolat', + }, + topicPattern: { + zh: '主题模式', + en: 'Topic Pattern', + tr: 'Konu Deseni', + ja: 'トピックパターン', + hu: 'Téma minta', + }, + valueField: { + zh: '字段', + en: 'Value Field', + tr: 'Değer Alanı', + ja: '値フィールド', + hu: 'Érték mező', + }, + messageName: { + zh: '消息名称', + en: 'Message Name', + tr: 'Mesaj Adı', + ja: 'メッセージ名', + hu: 'Üzenet név', + }, + fallbackValue: { + zh: '回退值', + en: 'Fallback Value', + tr: 'Yedek Değer', + ja: 'フォールバック値', + hu: 'Tartalék érték', + }, + color: { + zh: '颜色', + en: 'Color', + tr: 'Renk', + ja: '色', + hu: 'Szín', + }, + smoothLines: { + zh: '平滑线条', + en: 'Smooth Lines', + tr: 'Düz Çizgiler', + ja: 'スムーズライン', + hu: 'Sima vonalak', + }, + areaFill: { + zh: '区域填充', + en: 'Area Fill', + tr: 'Alan Doldurma', + ja: 'エリア塗りつぶし', + hu: 'Terület kitöltés', + }, + min: { + zh: '最小值', + en: 'Min', + tr: 'Min', + ja: '最小', + hu: 'Min', + }, + max: { + zh: '最大值', + en: 'Max', + tr: 'Max', + ja: '最大', + hu: 'Max', + }, + decimals: { + zh: '小数位数', + en: 'Decimals', + tr: 'Ondalık', + ja: '小数点', + hu: 'Tizedesjegyek', + }, + unit: { + zh: '单位', + en: 'Unit', + tr: 'Birim', + ja: '単位', + hu: 'Egység', + }, + type: { + zh: '类型', + en: 'Type', + tr: 'Tür', + ja: 'タイプ', + hu: 'Típus', + }, + title: { + zh: '标题', + en: 'Title', + tr: 'Başlık', + ja: 'タイトル', + hu: 'Cím', + }, + options: { + zh: '选项', + en: 'Options', + tr: 'Seçenekler', + ja: 'オプション', + hu: 'Beállítások', + }, + description: { + zh: '描述', + en: 'Description', + tr: 'Açıklama', + ja: '説明', + hu: 'Leírás', + }, + noDashboards: { + zh: '无仪表盘', + en: 'No Dashboards', + tr: 'Gösterge Paneli Yok', + ja: 'ダッシュボードなし', + hu: 'Nincs irányítópult', + }, + createDashboardToGetStarted: { + zh: '创建新仪表盘开始使用', + en: 'Create a new dashboard to get started', + tr: 'Başlamak için yeni bir gösterge paneli oluşturun', + ja: '新しいダッシュボードを作成して開始', + hu: 'Hozzon létre egy új irányítópultot a kezdéshez', + }, + confirmLeaveEditing: { + zh: '放弃当前编辑并切换仪表盘?', + en: 'Discard current edits and switch dashboard?', + tr: 'Mevcut düzenlemeleri iptal et ve gösterge paneline geç?', + ja: '現在の編集を破棄してダッシュボードを切り替えますか?', + hu: 'Elveti a jelenlegi szerkesztéseket és vált az irányítópultra?', + }, + inputValidationFailed: { + zh: '请检查必填字段', + en: 'Please check required fields', + tr: 'Lütfen gerekli alanları kontrol edin', + ja: '必須フィールドを確認してください', + hu: 'Kérjük ellenőrizze a kötelező mezőket', + }, + noDashboardSelected: { + zh: '未选择仪表盘', + en: 'No dashboard selected', + tr: 'Gösterge paneli seçilmedi', + ja: 'ダッシュボードが選択されていません', + hu: 'Nincs irányítópult kiválasztva', + }, + widgetTypeRequired: { + zh: '需要小部件类型', + en: 'Widget type is required', + tr: 'Widget türü gerekli', + ja: 'ウィジェットタイプが必要です', + hu: 'Widget típus szükséges', + }, + unknownWidgetType: { + zh: '未知的小部件类型', + en: 'Unknown widget type', + tr: 'Bilinmeyen widget türü', + ja: '不明なウィジェットタイプ', + hu: 'Ismeretlen widget típus', + }, + fallbackValueTip: { + zh: '当没有数据时,显示的值', + en: 'The value displayed when no data is available', + tr: 'Veri yokken gösterilecek değer', + ja: 'データがない場合に表示される値', + hu: 'Az adatok esetén megjelenített érték', + }, + connectionRequired: { + zh: '需要连接', + en: 'Connection is required', + tr: 'Bağlantı gerekli', + ja: '接続が必要です', + hu: 'Kapcsolat szükséges', + }, + topicPatternRequired: { + zh: '需要主题模式', + en: 'Topic pattern is required', + tr: 'Konu deseni gerekli', + ja: 'トピックパターンが必要です', + hu: 'Téma minta szükséges', + }, + failedToSaveTimeRangeSettings: { + zh: '保存时间范围设置失败', + en: 'Failed to save time range settings', + tr: 'Zaman aralığı ayarları kaydedilemedi', + ja: '時間範囲設定の保存に失敗しました', + hu: 'Nem sikerült menteni az időtartam beállításokat', + }, + failedToSaveDashboardOrder: { + zh: '保存仪表盘顺序失败', + en: 'Failed to save dashboard order', + tr: 'Gösterge paneli sırası kaydedilemedi', + ja: 'ダッシュボードの順序の保存に失敗しました', + hu: 'Nem sikerült menteni az irányítópult sorrendet', + }, } diff --git a/src/router/routes.ts b/src/router/routes.ts index ff5d39cc3..720c69815 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -24,6 +24,11 @@ const routes: Routes[] = [ name: 'TopicTree', component: () => import('@/views/viewer/TopicTree.vue'), }, + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/viewer/dashboard/index.vue'), + }, { path: 'traffic_monitor', name: 'TrafficMonitor', diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 5b931c2bc..a23b3f4c5 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -468,4 +468,117 @@ declare global { data?: TopicTreeNode children?: EChartsTreeNode[] } + interface DashboardModel { + id: string + name: string + description?: string + orderId?: number + globalSettings?: any + widgets: WidgetModel[] + createAt: string + updateAt: string + } + + type WidgetType = 'Big Number' | 'Gauge' | 'Line' + interface WidgetModel { + id?: string + type: WidgetType + title?: string + + x: number + y: number + w: number + h: number + static?: boolean + minW?: number + minH?: number + maxW?: number + maxH?: number + + dashboardId: string + + connectionId?: string + topicPattern?: string + valueField?: string + fallbackValue: number + + schemaType?: 'protobuf' | 'avro' + schemaId?: string + schemaMessageName?: string + + // Schema validation state tracking + schemaValidationState?: 'valid' | 'invalid' + schemaValidationError?: string + + widgetOptions?: GaugeWidgetOptions | BigNumberWidgetOptions | LineWidgetOptions + + createAt?: string + updateAt?: string + } + interface GaugeWidgetOptions { + thresholdsType?: 'Absolute' | 'Percentage' + min?: number + max?: number + thresholds?: Threshold[] + decimals?: number + unit?: string + color?: string + } + interface BigNumberWidgetOptions { + thresholdsType?: 'Absolute' | 'Percentage' + thresholds?: Threshold[] + min?: number + max?: number + decimals?: number + unit?: string + color?: string + } + interface LineWidgetOptions { + thresholdsType?: 'Absolute' | 'Percentage' + thresholds?: Threshold[] + smooth?: boolean + area?: boolean + color?: string + } + interface Threshold { + value: number + color: string + } + + interface TimeSeriesDataPoint { + timestamp: string + values: { [fieldName: string]: any } + topic: string + connectionId: string + metadata: { qos: QoS; retain: boolean } + } + + interface BigNumberData { + value: number | null + fieldName?: string + chartData: { + xData: string[] + seriesData: [ + { + name: string + data: number[] + }, + ] + } + } + interface GaugeData { + value: number | null + fieldName?: string + } + interface LineData { + chartData: { + xData: string[] + seriesData: [ + { + name: string + data: number[] + }, + ] + } + } } diff --git a/src/types/shims-vue-grid-layout.d.ts b/src/types/shims-vue-grid-layout.d.ts new file mode 100644 index 000000000..02146b788 --- /dev/null +++ b/src/types/shims-vue-grid-layout.d.ts @@ -0,0 +1,6 @@ +declare module 'vue-grid-layout' { + import { Component } from 'vue' + export const GridLayout: Component + export const GridItem: Component + export default {} as any +} diff --git a/src/views/viewer/dashboard/DashboardContent.vue b/src/views/viewer/dashboard/DashboardContent.vue new file mode 100644 index 000000000..1874741e0 --- /dev/null +++ b/src/views/viewer/dashboard/DashboardContent.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/src/views/viewer/dashboard/DashboardHeader.vue b/src/views/viewer/dashboard/DashboardHeader.vue new file mode 100644 index 000000000..4f4ff7731 --- /dev/null +++ b/src/views/viewer/dashboard/DashboardHeader.vue @@ -0,0 +1,331 @@ + + + + + diff --git a/src/views/viewer/dashboard/DashboardView.vue b/src/views/viewer/dashboard/DashboardView.vue new file mode 100644 index 000000000..8fedbb0c0 --- /dev/null +++ b/src/views/viewer/dashboard/DashboardView.vue @@ -0,0 +1,1191 @@ + + + + + + + diff --git a/src/views/viewer/dashboard/DashboardsList.vue b/src/views/viewer/dashboard/DashboardsList.vue new file mode 100644 index 000000000..6177a5857 --- /dev/null +++ b/src/views/viewer/dashboard/DashboardsList.vue @@ -0,0 +1,327 @@ + + + + + diff --git a/src/views/viewer/dashboard/WidgetConfig.vue b/src/views/viewer/dashboard/WidgetConfig.vue new file mode 100644 index 000000000..6f2c2df03 --- /dev/null +++ b/src/views/viewer/dashboard/WidgetConfig.vue @@ -0,0 +1,884 @@ + + + + + + + diff --git a/src/views/viewer/dashboard/index.vue b/src/views/viewer/dashboard/index.vue new file mode 100644 index 000000000..c593c29cc --- /dev/null +++ b/src/views/viewer/dashboard/index.vue @@ -0,0 +1,667 @@ + + + + + diff --git a/src/views/viewer/index.vue b/src/views/viewer/index.vue index 78cbf8771..ceea7cd21 100644 --- a/src/views/viewer/index.vue +++ b/src/views/viewer/index.vue @@ -7,6 +7,7 @@
+ diff --git a/src/widgets/WidgetRenderer.vue b/src/widgets/WidgetRenderer.vue new file mode 100644 index 000000000..856db4412 --- /dev/null +++ b/src/widgets/WidgetRenderer.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/src/widgets/widgetRegistry.ts b/src/widgets/widgetRegistry.ts new file mode 100644 index 000000000..bece295b3 --- /dev/null +++ b/src/widgets/widgetRegistry.ts @@ -0,0 +1,89 @@ +import { AsyncComponent } from 'vue' + +const GaugeChart = () => import('@/components/charts/Gauge.vue') +const BigNumberChart = () => import('@/components/charts/BigNumber.vue') +const LineChart = () => import('@/components/charts/LineChart.vue') + +interface WidgetConfig { + component: AsyncComponent + defaultOptions?: any + minSize?: { w: number; h: number } + maxSize?: { w: number; h: number } +} + +class WidgetRegistry { + private widgets = new Map() + + constructor() { + this.registerDefaults() + } + + private registerDefaults() { + this.register('Gauge', { + component: GaugeChart, + defaultOptions: { + min: undefined, + max: undefined, + thresholds: [], + thresholdsType: 'Absolute', + decimals: 1, + unit: '', + color: '#00B572', + }, + minSize: { w: 2, h: 2 }, + maxSize: { w: 6, h: 6 }, + }) + + this.register('Big Number', { + component: BigNumberChart, + defaultOptions: { + min: undefined, + max: undefined, + thresholds: [], + thresholdsType: 'Absolute', + decimals: 1, + unit: '', + color: '#00B572', + }, + minSize: { w: 2, h: 1 }, + maxSize: { w: 8, h: 4 }, + }) + + this.register('Line', { + component: LineChart, + defaultOptions: { smooth: true, area: true, color: '#00B572' }, + minSize: { w: 4, h: 3 }, + maxSize: { w: 12, h: 8 }, + }) + } + + register(type: WidgetType, config: WidgetConfig) { + this.widgets.set(type, config) + } + + getComponent(type: WidgetType): AsyncComponent | null { + return this.widgets.get(type)?.component || null + } + + getConfig(type: WidgetType): WidgetConfig | null { + return this.widgets.get(type) || null + } + + getAvailableTypes(): WidgetType[] { + return Array.from(this.widgets.keys()) + } + + getDefaultOptions(type: WidgetType): any { + return this.widgets.get(type)?.defaultOptions || {} + } + + getMinSize(type: WidgetType): { w: number; h: number } { + return this.widgets.get(type)?.minSize || { w: 1, h: 1 } + } + + getMaxSize(type: WidgetType): { w: number; h: number } { + return this.widgets.get(type)?.maxSize || { w: 12, h: 12 } + } +} + +export const widgetRegistry = new WidgetRegistry() diff --git a/tsconfig.json b/tsconfig.json index 343ae6e97..a08815218 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -43,6 +43,7 @@ "src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", + "src/**/*.d.ts", "tests/**/*.ts", "tests/**/*.tsx", "main/**/*.ts"