UI: add api usage widget and update dashboard

This commit is contained in:
ArtemDzhereleiko 2025-08-29 12:46:39 +03:00
parent 7ee8f11a65
commit 7b9f6c76cf
16 changed files with 14153 additions and 7601 deletions

View File

@ -24,6 +24,7 @@
"cards.html_value_card",
"cards.markdown_card",
"cards.simple_card",
"unread_notifications"
"unread_notifications",
"api_usage"
]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,54 @@
<!--
Copyright © 2016-2025 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-api-usage-panel" [style.padding]="padding" [style]="backgroundStyle$ | async">
<div class="tb-api-usage-overlay" [style]="overlayStyle"></div>
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
<div class="tb-api-usage-content">
<div class="api-items-list">
@for (api of apiUsages; track api){
<div class="api-item {{api.status.value}}"
[class.cursor-pointer]="api.state"
[class.active]="api.state && api.state === currentState"
(click)="updateState($event, api.state)">
<div class="api-item-content">
<div class="api-item-title">{{ api.label }}</div>
<div class="api-item-statistic">
<div class="api-item-statistic-count">{{ api.current.value }} / {{ api.maxLimit.value }}</div>
<div class="api-item-statistic-progress">
<mat-progress-bar mode="determinate"
[value]="api.progress"
></mat-progress-bar>
</div>
<div class="api-item-statistic-status">
<div class="statistic-status">{{ ('api-usage.status.' + api.status.value) | translate }}</div>
<div class="statistic-icon">
@if (api.status.value === 'enabled') {
<tb-icon class="tb-mat-18">check_circle</tb-icon>
} @else {
<tb-icon class="tb-mat-18">warning</tb-icon>
}
</div>
</div>
</div>
</div>
<mat-divider></mat-divider>
</div>
}
</div>
</div>
</div>

View File

@ -0,0 +1,124 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import "../../../../../../../scss/constants";
$enabled-color: #198038;
$disabled-color: #D12730;
$warning-color: #FAA405;
.tb-no-notification-svg-color {
color: $tb-primary-color;
}
.tb-api-usage-panel {
> div:not(.tb-api-usage-overlay) {
z-index: 1;
}
.tb-api-usage-overlay {
position: absolute;
top: 12px;
left: 12px;
bottom: 12px;
right: 12px;
}
.tb-api-usage-content {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
.api-items-list {
display: flex;
flex-direction: column;
width: 100%;
.api-item {
&.enabled {
.api-item-statistic-status {
color: $enabled-color;
}
.mat-mdc-progress-bar {
--mdc-linear-progress-active-indicator-color: #{$enabled-color};
}
}
&.disabled {
.api-item-statistic-status {
color: $disabled-color;
}
.mat-mdc-progress-bar {
--mdc-linear-progress-active-indicator-color: #{$disabled-color};
}
}
&.warning {
.api-item-statistic-status {
color: $warning-color;
}
.mat-mdc-progress-bar {
--mdc-linear-progress-active-indicator-color: #{$warning-color};
}
}
&:hover {
background-color: rgba(0, 0, 0, 0.06);
}
&.active {
background-color: rgba($tb-primary-color, 0.06);
.mat-divider {
--mat-divider-color: #{$tb-primary-color};
}
}
.api-item-content {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 5px 16px;
.api-item-title {
display: flex;
flex: 1;
font-size: 14px;
font-weight: 500;
color: $tb-primary-color;
}
.api-item-statistic {
display: flex;
flex: 1;
flex-direction: column;
&-count {
font-size: 14px;
color: rgba(0, 0, 0, 0.54);
}
&-status {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
font-size: 14px;
}
&-progress {
--mdc-linear-progress-track-height: 8px;
--mdc-linear-progress-active-indicator-height: 8px;
padding: 4px 0;
.mat-mdc-progress-bar {
border-radius: 6px;
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,178 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit, TemplateRef, ViewEncapsulation } from '@angular/core';
import { WidgetContext } from '@home/models/widget-component.models';
import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models';
import { Observable } from 'rxjs';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser';
import { DataKey, DatasourceType, widgetType } from "@shared/models/widget.models";
import { WidgetSubscriptionOptions } from "@core/api/widget-api.models";
import { formattedDataFormDatasourceData } from "@core/utils";
import { UtilsService } from "@core/services/utils.service";
import {
ApiUsageDataKeysSettings,
apiUsageDefaultSettings,
ApiUsageWidgetSettings
} from "@home/components/widget/lib/settings/cards/api-usage-settings.component.models";
@Component({
selector: 'tb-api-usage-widget',
templateUrl: './api-usage-widget.component.html',
styleUrls: ['api-usage-widget.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class ApiUsageWidgetComponent implements OnInit, OnDestroy {
settings: ApiUsageWidgetSettings;
@Input()
ctx: WidgetContext;
@Input()
widgetTitlePanel: TemplateRef<any>;
backgroundStyle$: Observable<ComponentStyle>;
overlayStyle: ComponentStyle = {};
padding: string;
apiUsages = [];
currentState = '';
noDataDisplayMessageText: string;
private contentResize$: ResizeObserver;
private powers: {key: string, value: number}[] = [
{ key: 'Q', value: 1e15 },
{ key: 'T', value: 1e12 },
{ key: 'B', value: 1e9 },
{ key: 'M', value: 1e6 },
{ key: 'K', value: 1e3 }
];
constructor(private imagePipe: ImagePipe,
private utils: UtilsService,
private sanitizer: DomSanitizer,
private cd: ChangeDetectorRef) {
}
ngOnInit(): void {
this.ctx.$scope.apiUsageWidget = this;
this.settings = {...apiUsageDefaultSettings, ...this.ctx.settings};
this.parseApiUsages();
const ds = {
type: DatasourceType.entity,
name: '',
entityAliasId: this.settings.dsEntityAliasId,
dataKeys: this.getUniqueDataKeys(this.settings.dataKeys)
}
const apiUsageSubscriptionOptions: WidgetSubscriptionOptions = {
datasources: [ds],
useDashboardTimewindow: false,
type: widgetType.latest,
callbacks: {
onDataUpdated: (subscription) => {
const data = formattedDataFormDatasourceData(subscription.data);
this.apiUsages.forEach(key => {
const progress = data[0][key.maxLimit.key] !== 0 ? Math.min(100, ((data[0][key.current.key] / data[0][key.maxLimit.key]) * 100)) : 0;
key.progress = isFinite(progress) ? progress : 0;
key.status.value = data[0][key.status.key] ? data[0][key.status.key].toLowerCase() : 'enabled';
key.maxLimit.value = isFinite(data[0][key.maxLimit.key]) && data[0][key.maxLimit.key] !== 0 ? this.toShortNumber(data[0][key.maxLimit.key]) : '∞';
key.current.value = isFinite(data[0][key.current.key]) ? this.toShortNumber(data[0][key.current.key]) : 0;
});
this.cd.detectChanges();
}
}
};
this.ctx.subscriptionApi.createSubscription(apiUsageSubscriptionOptions, true).subscribe();
this.currentState = this.ctx.stateController.getStateId();
this.ctx.stateController.stateId().subscribe((state) => {
// @ts-ignore
this.ctx.dashboardWidget.updateCustomHeaderActions();
this.currentState = state;
this.cd.markForCheck();
});
this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer);
this.overlayStyle = overlayStyle(this.settings.background.overlay);
this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding;
}
updateState($event: MouseEvent, stateName: string) {
$event?.preventDefault();
if (stateName?.length) {
this.ctx.stateController.updateState(stateName, this.ctx.stateController.getStateParams(), this.ctx.isMobile);
}
}
parseApiUsages() {
this.settings.dataKeys.forEach((key) => {
this.apiUsages.push({
label: this.utils.customTranslation(key.label, key.label),
state: key.state,
progress: 0,
status: {key: key.status.name, value: 'enabled'},
maxLimit: {key: key.maxLimit.name, value: '∞'},
current: {key: key.current.name, value: 0},
});
})
}
getUniqueDataKeys(data: ApiUsageDataKeysSettings[]): DataKey[] {
const seenNames = new Set<string>();
return data
.flatMap(item => [item.status, item.maxLimit, item.current])
.filter(key => {
if (seenNames.has(key.name)) {
return false;
}
seenNames.add(key.name);
return true;
});
};
ngOnDestroy() {
if (this.contentResize$) {
this.contentResize$.disconnect();
}
}
private toShortNumber(number: any, decimals = 1) {
if (!Number.isFinite(number) || number < 0) {
return '0';
}
for (const power of this.powers) {
if (number >= power.value) {
const reduced = number / power.value;
const rounded = Number(reduced.toFixed(decimals));
return `${rounded}${power.key}`;
}
}
return `${Number(number.toFixed(decimals))}`;
}
public onInit() {
const borderRadius = this.ctx.$widgetElement.css('borderRadius');
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
this.cd.detectChanges();
}
}

View File

@ -0,0 +1,82 @@
<!--
Copyright © 2016-2025 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div [formGroup]="dataKeyFormGroup" class="tb-form-table-row tb-api-usage-data-key-row">
<div class="tb-source-field">
<mat-form-field
class="tb-label-field tb-inline-field" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="label" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<mat-form-field
class="tb-label-field tb-inline-field" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="state" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
<tb-data-key-input
class="tb-data-key-field"
required
requiredText="{{ 'widgets.maps.data-layer.marker.latitude-key-required'}}"
[datasourceType]="DatasourceType.entity"
[entityAliasId]="dsEntityAliasId"
[aliasController]="context.aliasController"
[widgetType]="widgetType.latest"
[dataKeyTypes]="[DataKeyType.attribute, DataKeyType.timeseries]"
[callbacks]="context.callbacks"
[generateKey]="context.generateDataKey"
(keyEdit)="editKey('status')"
formControlName="status">
</tb-data-key-input>
<tb-data-key-input
class="tb-data-key-field"
required
requiredText="{{ 'widgets.maps.data-layer.marker.latitude-key-required'}}"
[datasourceType]="DatasourceType.entity"
[entityAliasId]="dsEntityAliasId"
[aliasController]="context.aliasController"
[widgetType]="widgetType.latest"
[dataKeyTypes]="[DataKeyType.attribute, DataKeyType.timeseries]"
[callbacks]="context.callbacks"
[generateKey]="context.generateDataKey"
(keyEdit)="editKey('maxLimit')"
formControlName="maxLimit">
</tb-data-key-input>
<tb-data-key-input
class="tb-data-key-field"
required
requiredText="{{ 'widgets.maps.data-layer.marker.latitude-key-required'}}"
[datasourceType]="DatasourceType.entity"
[entityAliasId]="dsEntityAliasId"
[aliasController]="context.aliasController"
[widgetType]="widgetType.latest"
[dataKeyTypes]="[DataKeyType.attribute, DataKeyType.timeseries]"
[callbacks]="context.callbacks"
[generateKey]="context.generateDataKey"
(keyEdit)="editKey('current')"
formControlName="current">
</tb-data-key-input>
<div class="tb-form-table-row-cell-buttons">
<div class="tb-remove-button">
<button type="button"
mat-icon-button
(click)="dataKeyRemoved.emit()"
matTooltip="{{ 'widgets.api-usage.delete-key' | translate }}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,58 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import '../../../../../../../../scss/constants';
.tb-form-table-row.tb-api-usage-data-key-row {
.tb-source-field {
flex: 1 1 50%;
display: flex;
gap: 12px;
.tb-label-field {
flex: 1;
}
}
.tb-data-key-field {
flex: 1 1 25%;
min-width: 0;
}
.tb-remove-button {
width: 40px;
min-width: 40px;
}
@media #{$mat-lt-lg} {
.tb-source-field {
flex-direction: column;
flex: 1 1 30%;
}
.tb-data-key-field{
flex: 1 1 35%;
}
}
@media screen and (min-width: 450px) and (max-width: 599px) {
.tb-source-field {
flex-direction: row;
}
}
@media #{$mat-xs} {
.tb-data-key-field {
display: none;
}
}
}

View File

@ -0,0 +1,151 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
forwardRef,
Input,
OnInit,
Output,
ViewEncapsulation
} from '@angular/core';
import {
ControlValueAccessor,
NG_VALUE_ACCESSOR,
UntypedFormBuilder,
UntypedFormGroup,
Validators
} from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import {
ApiUsageDataKeysSettings,
ApiUsageSettingsContext
} from "@home/components/widget/lib/settings/cards/api-usage-settings.component.models";
@Component({
selector: 'tb-api-usage-data-key-row',
templateUrl: './api-usage-data-key-row.component.html',
styleUrls: ['./api-usage-data-key-row.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ApiUsageDataKeyRowComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None
})
export class ApiUsageDataKeyRowComponent implements ControlValueAccessor, OnInit {
DatasourceType = DatasourceType;
DataKeyType = DataKeyType;
widgetType = widgetType;
@Input()
disabled: boolean;
@Input()
dsEntityAliasId: string;
@Input()
context: ApiUsageSettingsContext;
@Output()
dataKeyRemoved = new EventEmitter();
dataKeyFormGroup: UntypedFormGroup;
modelValue: ApiUsageDataKeysSettings;
private propagateChange = (_val: any) => {};
constructor(private fb: UntypedFormBuilder,
private cd: ChangeDetectorRef,
private destroyRef: DestroyRef) {
}
ngOnInit() {
this.dataKeyFormGroup = this.fb.group({
label: [null, [Validators.required]],
state: [null, []],
status: [null, [Validators.required]],
maxLimit: [null, [Validators.required]],
current: [null, [Validators.required]]
});
this.dataKeyFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(
() => this.updateModel()
);
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(_fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {
this.dataKeyFormGroup.disable({emitEvent: false});
} else {
this.dataKeyFormGroup.enable({emitEvent: false});
this.updateValidators();
}
}
writeValue(value: ApiUsageDataKeysSettings): void {
this.modelValue = value;
this.dataKeyFormGroup.patchValue(
{
label: value?.label,
state: value?.state,
status: value?.status,
maxLimit: value?.maxLimit,
current: value?.current
}, {emitEvent: false}
);
this.updateValidators();
this.cd.markForCheck();
}
editKey(keyType: 'status' | 'maxLimit' | 'current') {
const targetDataKey: DataKey = this.dataKeyFormGroup.get(keyType).value;
this.context.editKey(targetDataKey, this.dsEntityAliasId).subscribe(
(updatedDataKey) => {
if (updatedDataKey) {
this.dataKeyFormGroup.get(keyType).patchValue(updatedDataKey);
}
}
);
}
private updateValidators() {
}
private updateModel() {
this.modelValue = {...this.modelValue, ...this.dataKeyFormGroup.value};
this.propagateChange(this.modelValue);
}
}

View File

@ -0,0 +1,106 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { IAliasController } from '@core/api/widget-api.models';
import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models';
import { DataKey, Widget, widgetType } from '@shared/models/widget.models';
import { Observable } from "rxjs";
import { BackgroundSettings, BackgroundType } from "@shared/models/widget-settings.models";
import { DataKeyType } from "@shared/models/telemetry/telemetry.models";
import { materialColors } from "@shared/models/material.models";
export interface ApiUsageSettingsContext {
aliasController: IAliasController;
callbacks: WidgetConfigCallbacks;
widget: Widget;
editKey: (key: DataKey, entityAliasId: string, WidgetType?: widgetType) => Observable<DataKey>;
generateDataKey: (key: DataKey) => DataKey;
}
export interface ApiUsageWidgetSettings {
dsEntityAliasId: string;
dataKeys: ApiUsageDataKeysSettings[];
targetDashboardState: string;
background: BackgroundSettings;
padding: string;
}
export interface ApiUsageDataKeysSettings {
label: string;
state: string;
status: DataKey;
maxLimit: DataKey;
current: DataKey;
}
const generateDataKey = (label: string, status: string, maxLimit: string, current: string) => {
return {
label,
state: '',
status: {
name: status,
label: status,
type: DataKeyType.timeseries,
funcBody: undefined,
settings: {},
color: materialColors[0].value
},
maxLimit: {
name: maxLimit,
label: maxLimit,
type: DataKeyType.timeseries,
funcBody: undefined,
settings: {},
color: materialColors[0].value
},
current: {
name: current,
label: current,
type: DataKeyType.timeseries,
funcBody: undefined,
settings: {},
color: materialColors[0].value
}
}
}
export const apiUsageDefaultSettings: ApiUsageWidgetSettings = {
dsEntityAliasId: '',
dataKeys: [
generateDataKey('{i18n:api-usage.transport-messages}', 'transportApiState', 'transportMsgLimit', 'transportMsgCount'),
generateDataKey('{i18n:api-usage.transport-data-points}', 'transportApiState', 'transportDataPointsLimit', 'transportDataPointsCount'),
generateDataKey('{i18n:api-usage.rule-engine-executions}', 'ruleEngineApiState', 'ruleEngineExecutionLimit', 'ruleEngineExecutionCount'),
generateDataKey('{i18n:api-usage.javascript-function-executions}', 'jsExecutionApiState', 'jsExecutionLimit', 'jsExecutionCount'),
generateDataKey('{i18n:api-usage.tbel-function-executions}', 'tbelExecutionApiState', 'tbelExecutionLimit', 'tbelExecutionCount'),
generateDataKey('{i18n:api-usage.data-points-storage-days}', 'dbApiState', 'storageDataPointsLimit', 'storageDataPointsCount'),
generateDataKey('{i18n:api-usage.alarms-created}', 'alarmApiState', 'createdAlarmsLimit', 'createdAlarmsCount'),
generateDataKey('{i18n:api-usage.emails}', 'emailApiState', 'emailLimit', 'emailCount'),
generateDataKey('{i18n:api-usage.sms}', 'notificationApiState', 'smsLimit', 'smsCount'),
],
targetDashboardState: 'default',
background: {
type: BackgroundType.color,
color: '#fff',
overlay: {
enabled: false,
color: 'rgba(255,255,255,0.72)',
blur: 3
}
},
padding: '0'
};

View File

@ -0,0 +1,96 @@
<!--
Copyright © 2016-2025 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<ng-container [formGroup]="apiUsageWidgetSettingsForm">
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.datasource</div>
<tb-entity-alias-select
class="tb-entity-alias-field"
tbRequired
[appearance]="'outline'"
[aliasController]="context.aliasController"
formControlName="dsEntityAliasId"
[callbacks]="context.callbacks">
</tb-entity-alias-select>
<div class="tb-form-panel no-padding no-border">
<div class="tb-form-table tb-map-data-layers">
<div class="tb-form-table-header no-padding-right">
<div class="tb-form-table-header-cell tb-key-header" translate>widgets.api-usage.label</div>
<div class="tb-form-table-header-cell tb-key-header" translate>widgets.api-usage.state-name</div>
<div class="tb-form-table-header-cell tb-key-header" translate>widgets.api-usage.status</div>
<div class="tb-form-table-header-cell tb-key-header" translate>widgets.api-usage.limit</div>
<div class="tb-form-table-header-cell tb-key-header" translate>widgets.api-usage.current-number</div>
<div class="tb-form-table-header-cell tb-actions-header"></div>
</div>
<div cdkDropList cdkDropListOrientation="vertical"
[cdkDropListDisabled]="!dragEnabled"
(cdkDropListDropped)="layerDrop($event)" *ngIf="dataKeysFormArray().controls?.length; else noDataLayers" class="tb-form-table-body">
<div cdkDrag [cdkDragDisabled]="!dragEnabled"
*ngFor="let dataKeyControl of dataKeysFormArray().controls; trackBy: trackByDataKey; let $index = index;">
<div class="tb-draggable-form-table-row">
<tb-api-usage-data-key-row class="flex-1"
[context]="context"
[dsEntityAliasId]="apiUsageWidgetSettingsForm.get('dsEntityAliasId').value"
[formControl]="dataKeyControl"
(dataKeyRemoved)="removeDataKey($index)">
</tb-api-usage-data-key-row>
<div class="tb-form-table-row-cell-buttons">
<button [class.tb-hidden]="!dragEnabled"
mat-icon-button
type="button"
cdkDragHandle
matTooltip="{{ 'action.drag' | translate }}"
matTooltipPosition="above">
<mat-icon>drag_indicator</mat-icon>
</button>
</div>
</div>
</div>
</div>
</div>
<div>
<button type="button" mat-stroked-button color="primary" (click)="addDataKey()">
{{ 'widgets.api-usage.add-key' | translate }}
</button>
</div>
</div>
<ng-template #noDataLayers>
<span class="tb-prompt flex items-center justify-center">{{ 'widgets.api-usage.no-key' | translate }}</span>
</ng-template>
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>widgets.api-usage.target-dashboard-state</mat-label>
<input matInput formControlName="targetDashboardState" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.card-appearance</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.background.background' | translate }}</div>
<tb-background-settings formControlName="background">
</tb-background-settings>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-padding' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
</ng-container>

View File

@ -0,0 +1,62 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import '../../../../../../../../scss/constants';
.tb-map-data-layers {
.tb-form-table-header-cell {
&.tb-source-header {
flex: 1 1 50%;
}
&.tb-x-pos-header {
flex: 1 1 25%;
}
&.tb-y-pos-header {
flex: 1 1 25%;
}
&.tb-key-header {
flex: 1 1 50%;
}
&.tb-actions-header {
width: 80px;
min-width: 80px;
}
@media #{$mat-lt-lg} {
&.tb-source-header {
flex: 1 1 30%;
}
&.tb-x-pos-header, &.tb-y-pos-header {
flex: 1 1 35%;
}
&.tb-key-header {
flex: 1 1 70%;
}
}
@media #{$mat-xs} {
&.tb-x-pos-header, &.tb-y-pos-header {
display: none;
}
&.tb-key-header {
display: none;
}
}
}
.tb-form-table-body {
tb-api-usage-data-key-row {
overflow: hidden;
}
}
}

View File

@ -0,0 +1,193 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, forwardRef } from '@angular/core';
import {
DataKey,
DataKeyConfigMode,
WidgetSettings,
WidgetSettingsComponent,
widgetType
} from '@shared/models/widget.models';
import {
AbstractControl,
NG_VALUE_ACCESSOR,
UntypedFormArray,
UntypedFormBuilder,
UntypedFormGroup,
ValidationErrors,
ValidatorFn
} from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import {
ApiUsageDataKeysSettings,
apiUsageDefaultSettings,
ApiUsageSettingsContext
} from "@home/components/widget/lib/settings/cards/api-usage-settings.component.models";
import { deepClone } from "@core/utils";
import { Observable } from "rxjs";
import {
DataKeyConfigDialogComponent,
DataKeyConfigDialogData
} from "@home/components/widget/lib/settings/common/key/data-key-config-dialog.component";
import { MatDialog } from "@angular/material/dialog";
import { CdkDragDrop } from "@angular/cdk/drag-drop";
@Component({
selector: 'tb-api-usage-widget-settings',
templateUrl: './api-usage-widget-settings.component.html',
styleUrls: ['./../widget-settings.scss', 'api-usage-widget-settings.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ApiUsageWidgetSettingsComponent),
multi: true
}
],
})
export class ApiUsageWidgetSettingsComponent extends WidgetSettingsComponent {
apiUsageWidgetSettingsForm: UntypedFormGroup;
context: ApiUsageSettingsContext;
constructor(protected store: Store<AppState>,
private dialog: MatDialog,
private fb: UntypedFormBuilder) {
super(store);
}
ngOnInit() {
this.context = {
aliasController: this.aliasController,
callbacks: this.callbacks,
widget: this.widget,
editKey: this.editKey.bind(this),
generateDataKey: this.generateDataKey.bind(this)
};
}
dataKeysFormArray(): UntypedFormArray {
return this.apiUsageWidgetSettingsForm.get('dataKeys') as UntypedFormArray;
}
trackByDataKey(index: number, dataKeyControl: AbstractControl): any {
return dataKeyControl;
}
get dragEnabled(): boolean {
return this.dataKeysFormArray().controls.length > 1;
}
layerDrop(event: CdkDragDrop<string[]>) {
const layer = this.dataKeysFormArray().at(event.previousIndex);
this.dataKeysFormArray().removeAt(event.previousIndex);
this.dataKeysFormArray().insert(event.currentIndex, layer);
}
removeDataKey(index: number) {
(this.apiUsageWidgetSettingsForm.get('dataKeys') as UntypedFormArray).removeAt(index);
}
addDataKey() {
const dataKey = {
label: '',
state: '',
status: null,
maxLimit: null,
current: null
};
const dataKeysArray = this.apiUsageWidgetSettingsForm.get('dataKeys') as UntypedFormArray;
const dataKeyControl = this.fb.control(dataKey, [this.mapDataKeyValidator()]);
dataKeysArray.push(dataKeyControl);
}
protected settingsForm(): UntypedFormGroup {
return this.apiUsageWidgetSettingsForm;
}
protected defaultSettings(): WidgetSettings {
return apiUsageDefaultSettings;
}
protected onSettingsSet(settings: WidgetSettings) {
this.apiUsageWidgetSettingsForm = this.fb.group({
dsEntityAliasId: [settings?.dsEntityAliasId],
dataKeys: this.prepareDataKeysFormArray(settings?.dataKeys),
targetDashboardState: [settings?.targetDashboardState],
background: [settings?.background, []],
padding: [settings.padding, []]
});
}
private prepareDataKeysFormArray(dataKeys: ApiUsageDataKeysSettings[]): UntypedFormArray {
const dataKeysControls: Array<AbstractControl> = [];
if (dataKeys) {
dataKeys.forEach((dataLayer) => {
dataKeysControls.push(this.fb.control(dataLayer, [this.mapDataKeyValidator()]));
});
}
return this.fb.array(dataKeysControls);
}
protected validatorTriggers(): string[] {
return [];
}
protected updateValidators() {
}
mapDataKeyValidator = (): ValidatorFn => {
return (control: AbstractControl): ValidationErrors | null => {
const value: ApiUsageDataKeysSettings = control.value;
if (!value?.label || !value?.current || !value?.maxLimit || !value?.status) {
return {
dataKey: true
}
}
return null;
};
};
private editKey(key: DataKey, entityAliasId: string, _widgetType = widgetType.latest): Observable<DataKey> {
return this.dialog.open<DataKeyConfigDialogComponent, DataKeyConfigDialogData, DataKey>(DataKeyConfigDialogComponent,
{
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
dataKey: deepClone(key),
dataKeyConfigMode: DataKeyConfigMode.general,
aliasController: this.aliasController,
widgetType: _widgetType,
entityAliasId,
showPostProcessing: true,
callbacks: this.callbacks,
hideDataKeyColor: true,
hideDataKeyDecimals: true,
hideDataKeyUnits: true,
widget: this.widget,
dashboard: null,
dataKeySettingsForm: null,
dataKeySettingsDirective: null
}
}).afterClosed();
}
private generateDataKey(key: DataKey): DataKey {
return this.callbacks.generateDataKey(key.name, key.type, null, false, null);
}
}

View File

@ -375,6 +375,12 @@ import {
ValueStepperWidgetSettingsComponent
} from '@home/components/widget/lib/settings/control/value-stepper-widget-settings.component';
import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings/map/map-widget-settings.component';
import {
ApiUsageWidgetSettingsComponent
} from "@home/components/widget/lib/settings/cards/api-usage-widget-settings.component";
import {
ApiUsageDataKeyRowComponent
} from "@home/components/widget/lib/settings/cards/api-usage-data-key-row.component";
@NgModule({
declarations: [
@ -508,7 +514,9 @@ import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings
LabelValueCardWidgetSettingsComponent,
UnreadNotificationWidgetSettingsComponent,
ScadaSymbolWidgetSettingsComponent,
MapWidgetSettingsComponent
MapWidgetSettingsComponent,
ApiUsageWidgetSettingsComponent,
ApiUsageDataKeyRowComponent
],
imports: [
CommonModule,
@ -647,7 +655,8 @@ import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings
LabelValueCardWidgetSettingsComponent,
UnreadNotificationWidgetSettingsComponent,
ScadaSymbolWidgetSettingsComponent,
MapWidgetSettingsComponent
MapWidgetSettingsComponent,
ApiUsageWidgetSettingsComponent
]
})
export class WidgetSettingsModule {

View File

@ -94,6 +94,7 @@ import {
SelectMapEntityPanelComponent
} from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component';
import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component';
import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-usage-widget.component";
@NgModule({
declarations: [
@ -151,7 +152,8 @@ import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/pane
ScadaSymbolWidgetComponent,
SelectMapEntityPanelComponent,
MapTimelinePanelComponent,
MapWidgetComponent
MapWidgetComponent,
ApiUsageWidgetComponent
],
imports: [
CommonModule,
@ -214,7 +216,8 @@ import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/pane
UnreadNotificationWidgetComponent,
NotificationTypeFilterPanelComponent,
ScadaSymbolWidgetComponent,
MapWidgetComponent
MapWidgetComponent,
ApiUsageWidgetComponent
],
providers: [
{provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule},

File diff suppressed because it is too large Load Diff

View File

@ -865,15 +865,18 @@
"api-features": "API features",
"api-usage": "API usage",
"alarm": "Alarm",
"alarms-created": "Alarms created",
"alarms-created": "Created alarms",
"queue-stats": "Queue Stats",
"processing-failures-and-timeouts": "Processing Failures and Timeouts",
"exceptions": "Exceptions",
"alarms-created-daily-activity": "Alarms created daily activity",
"alarms-created-hourly-activity": "Alarms created hourly activity",
"alarms-created-monthly-activity": "Alarms created monthly activity",
"alarms-created-daily-activity": "Created alarms daily activity",
"alarms-created-hourly-activity": "Created alarms hourly activity",
"alarms-created-monthly-activity": "Created alarms monthly activity",
"data-points": "Data points",
"data-points-storage-days": "Data points storage days",
"data-points-storage-days-hourly-activity": "Data points storage days hourly activity",
"data-points-storage-days-daily-activity": "Data points storage days daily activity",
"data-points-storage-days-monthly-activity": "Data points storage days monthly activity",
"device-api": "Device API",
"email": "Email",
"email-messages": "Email messages",
@ -899,14 +902,15 @@
"processing-timeouts": "${entityName} Processing Timeouts",
"rule-chain": "Rule Chain",
"rule-engine": "Rule Engine",
"rule-engine-daily-activity": "Rule Engine daily activity",
"rule-engine-executions": "Rule Engine executions",
"rule-engine-hourly-activity": "Rule Engine hourly activity",
"rule-engine-daily-activity": "Rule Engine daily activity",
"rule-engine-monthly-activity": "Rule Engine monthly activity",
"rule-engine-statistics": "Rule Engine Statistics",
"rule-node": "Rule Node",
"sms": "SMS",
"sms-messages": "SMS messages",
"sms-messages-hourly-activity": "SMS messages hourly activity",
"sms-messages-daily-activity": "SMS messages daily activity",
"sms-messages-monthly-activity": "SMS messages monthly activity",
"successful": "${entityName} Successful",
@ -916,13 +920,40 @@
"telemetry-persistence-hourly-activity": "Telemetry persistence hourly activity",
"telemetry-persistence-monthly-activity": "Telemetry persistence monthly activity",
"transport": "Transport",
"transport-msg-hourly-activity": "Transport messages hourly activity",
"transport-msg-daily-activity": "Transport messages daily activity",
"transport-msg-monthly-activity": "Transport messages monthly activity",
"transport-daily-activity": "Transport daily activity",
"transport-data-points": "Transport data points",
"transport-hourly-activity": "Transport hourly activity",
"transport-messages": "Transport messages",
"transport-monthly-activity": "Transport monthly activity",
"transport-data-points-hourly-activity": "Transport data points hourly activity",
"transport-data-points-daily-activity": "Transport data points daily activity",
"transport-data-points-monthly-activity": "Transport data points monthly activity",
"view-details": "View details",
"view-statistics": "View statistics"
"view-statistics": "View statistics",
"transport-messages": "Transport messages",
"transport-messages-hourly-activity": "Transport messages hourly activity",
"transport-data-point-hourly-activity": "Transport data point hourly activity",
"javascript-function-executions": "JavaScript function executions",
"javascript-function-executions-hourly-activity": "JavaScript function executions hourly activity",
"javascript-function-executions-daily-activity": "JavaScript function executions daily activity",
"javascript-function-executions-monthly-activity": "JavaScript function executions monthly activity",
"tbel-function-executions": "TBEL function executions",
"tbel-function-executions-hourly-activity": "TBEL function executions hourly activity",
"tbel-function-executions-daily-activity": "TBEL function executions daily activity",
"tbel-function-executions-monthly-activity": "TBEL function executions monthly activity",
"created-reports": "Created reports",
"created-reports-hourly-activity": "Created reports hourly activity",
"created-reports-daily-activity": "Created reports daily activity",
"created-reports-monthly-activity": "Created reports monthly activity",
"emails": "Emails",
"emails-hourly-activity": "Emails hourly activity",
"emails-daily-activity": "Emails daily activity",
"emails-monthly-activity": "Emails monthly activity",
"status": {
"enabled": "Enabled",
"disabled": "Disabled",
"warning": "Warning"
}
},
"api-limit": {
"cassandra-write-queries-core": "Rest API Cassandra write queries",
@ -9483,6 +9514,18 @@
"how-to-create-customer-and-assign-dashboard": "How to create Customer and assign Dashboard"
}
}
},
"api-usage": {
"api-usage": "API usage",
"label": "Label",
"state-name": "State name",
"status": "Status",
"limit": "Max limit",
"current-number": "Current number",
"add-key": "Add key",
"no-key": "No key",
"delete-key": "Delete key",
"target-dashboard-state": "Target dashboard state"
}
},
"color": {