@c8y/ngx-components
Version:
Angular modules for Cumulocity IoT applications
1,053 lines (1,005 loc) • 107 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, inject, DestroyRef, input, computed, ChangeDetectionStrategy, Component, viewChild, signal, ElementRef, output, effect } from '@angular/core';
import { takeUntilDestroyed, toSignal, toObservable } from '@angular/core/rxjs-interop';
import * as i2 from '@c8y/ngx-components';
import { IconDirective, C8yTranslatePipe, AlertService, DropAreaComponent, FormGroupComponent, ListGroupModule, ModalModule, FormsModule as FormsModule$1, DashboardChildComponent, DynamicComponentAlertAggregator, DismissAlertStrategy, DynamicComponentAlert, ManagedObjectRealtimeService } from '@c8y/ngx-components';
import { gettext } from '@c8y/ngx-components/gettext';
import { WidgetConfigService, WidgetConfigSectionComponent, AssetPropertyMappingsService, quoteAndEscape, renamePropertyKeyInCode, escapeForRegExp } from '@c8y/ngx-components/context-dashboard';
import { Subject, debounceTime, switchMap, startWith, shareReplay, firstValueFrom, tap, filter, retry, take, map } from 'rxjs';
import { InventoryService, InventoryBinaryService } from '@c8y/client';
import { ComputedPropertiesService } from '@c8y/ngx-components/asset-properties';
import { FieldbusService } from '@c8y/ngx-components/computed-asset-properties';
import { WidgetConfigMigrationService, GLOBAL_CONTEXT_DISPLAY_MODE, PRESET_NAME, GlobalContextConnectorComponent, LocalControlsComponent } from '@c8y/ngx-components/global-context';
import { merge, camelCase } from 'lodash-es';
import { SVG_EXTRACT_TAG_NAME, MAPPING_EXTRACT_TAG_NAME, SCADA_SVG_AGENT_DEFINITION, SCADA_OUTPUT_TAG_NAME } from '@c8y/ngx-components/ai/agents/scada';
import * as i1 from '@angular/forms';
import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { BsDropdownModule } from 'ngx-bootstrap/dropdown';
import { A11yModule } from '@angular/cdk/a11y';
import { AIService } from '@c8y/ngx-components/ai';
import { AgentChatComponent } from '@c8y/ngx-components/ai/agent-chat';
import { DomSanitizer } from '@angular/platform-browser';
import { TooltipDirective } from 'ngx-bootstrap/tooltip';
import { BsModalService } from 'ngx-bootstrap/modal';
import { HtmlFrameComponent, WidgetCodeEditorComponent, HtmlWidgetConfigService } from '@c8y/ngx-components/widgets/implementations/html-widget';
import { AsyncPipe } from '@angular/common';
const SCADA_WIDGET_FEATURE_FLAG_NAME = 'ui.scada-widget.v2';
const defaultDisplayOption = 'full-width';
const defaultAlignment = 'center';
const INITIAL_SVG_CODE = `<?xml version="1.0" encoding="utf-8"?>
<svg width="600px" height="1350px" viewBox="0 0 600 1350" xmlns="http://www.w3.org/2000/svg">
<style>
/* Typography */
.header {
font-family: var(--c8y-font-family-base);
font-size: 28px;
font-weight: bold;
fill: var(--text-color, var(--c8y-component-color-default, var(--c8y-root-component-color-default)));
}
.branded {
fill: var(--brand-primary,var(--c8y-brand-primary));
}
.step-number {
font-family: var(--c8y-font-family-base);
font-size: 18px;
font-weight: bold;
fill: var(--c8y-component-background-default, var(--c8y-root-component-background-default));
}
.step-title {
font-family: var(--c8y-font-family-base);
font-size: 16px;
font-weight: bold;
fill: var(--text-color, var(--c8y-component-color-default, var(--c8y-root-component-color-default)));
}
.description {
font-family: var(--c8y-font-family-base);
font-size: 14px;
}
.placeholder {
font-family: var(--c8y-font-family-base);
font-size: 14px;
fill: var(--brand-primary,var(--c8y-brand-primary));
font-weight: bold;
}
.code {
font-family: var(--c8y-font-family-mono-spaced);
font-size: 12px;
fill: var(--c8y-code-color);
}
.label {
font-family: var(--c8y-font-family-base);
font-size: 13px;
fill: var(--text-color, var(--c8y-component-color-default, var(--c8y-root-component-color-default)));
}
/* Components */
.section-bg {
fill: var(--c8y-pre-background);
stroke: var(--c8y-pre-border-color);
stroke-width: 1px;
rx: 4px;
}
.step-circle {
fill: var(--brand-primary,var(--c8y-brand-primary));
}
.code-bg {
fill: #0000000d;
stroke: transparent;
stroke-width: 1px;
rx: 13px;
}
.button{
cursor: pointer;
}
.button-bg {
fill: var(--brand-primary,var(--c8y-brand-primary));
rx: var(--c8y-btn-border-radius-base);
stroke: var(--brand-primary,var(--c8y-brand-primary));
stroke-width: 1px;
}
.button-text {
font-family: inherit;
font-size: 14px;
fill: var(--c8y-component-background-default, var(--c8y-root-component-background-default));
text-anchor: middle;
}
.button:hover .button-bg {
fill: var(--c8y-component-background-default, var(--c8y-root-component-background-default));
}
.button:hover .button-text {
fill: var(--brand-primary,var(--c8y-brand-primary));
}
.divider {
stroke: var(--c8y-component-border-color, var(--c8y-root-component-border-color));
stroke-width: 2px;
}
/* Alarm status indicators */
.alarm-none {
fill: var(--c8y-palette-status-success);
}
.alarm-warning {
fill: var(--c8y-palette-status-info);
}
.alarm-minor {
fill: var(--c8y-palette-status-warning-high);
}
.alarm-major {
fill: var(--c8y-palette-status-warning);
}
.alarm-critical {
fill: var(--c8y-palette-status-danger);
}
/* Conditional text colors */
.temp-ok {
fill: var(--c8y-palette-status-success);
}
.temp-warning {
fill:var(--c8y-palette-status-danger);
}
</style>
<!-- Background -->
<rect width="600" height="1400" fill="var(--c8y-component-background-default, var(--c8y-root-component-background-default))" />
<!-- Header -->
<text x="40" y="50" class="header">SCADA widget example</text>
<text x="40" y="90" class="description">Follow these steps to customize your visualization</text>
<!-- Divider -->
<line x1="40" y1="110" x2="560" y2="110" class="divider" />
<!-- STEP 1: SVG configuration -->
<circle cx="55" cy="150" r="15" class="step-circle" />
<text x="55" y="157" class="step-number" text-anchor="middle">1</text>
<text x="85" y="155" class="step-title">SVG configuration</text>
<text x="85" y="180" class="description">Use "SVG configuration" section to upload a file, paste code, or generate with AI.</text>
<!-- STEP 2: Add Placeholders -->
<circle cx="55" cy="230" r="15" class="step-circle" />
<text x="55" y="237" class="step-number" text-anchor="middle">2</text>
<text x="85" y="235" class="step-title">Add placeholders</text>
<text x="85" y="260" class="description">Click on a text or tspan element in the preview to convert it into a placeholder.</text>
<rect x="85" y="280" width="475" height="80" class="section-bg" />
<text x="100" y="305" class="label">Example - click text below to make it dynamic:</text>
<text x="100" y="330" class="placeholder"><tspan>Device name</tspan> (<tspan>Device ID</tspan>)</text>
<!-- STEP 3: Configure Data Binding -->
<circle cx="55" cy="395" r="15" class="step-circle" />
<text x="55" y="402" class="step-number" text-anchor="middle">3</text>
<text x="85" y="400" class="step-title">Configure data binding</text>
<text x="85" y="425" class="description">Assign asset properties to placeholders in the "Placeholder mappings" section</text>
<text x="85" y="445" class="description">to see their values in the preview.</text>
<!-- Example with actual placeholders -->
<rect x="40" y="470" width="520" height="120" class="section-bg" />
<text x="60" y="495" class="label">Examples:</text>
<text x="60" y="525" class="label">Device name:</text>
<text x="180" y="525" class="placeholder">\${this.c8yScadaValues?.deviceName || '-'}</text>
<text x="60" y="550" class="label">Temperature:</text>
<text x="180" y="550" class="placeholder">\${this.c8yScadaValues?.temperature || '-'} °C</text>
<text x="60" y="575" class="label">Status:</text>
<text x="180" y="575" class="placeholder">\${this.c8yScadaValues?.status || '-'}</text>
<!-- STEP 4: Advanced Customization -->
<circle cx="55" cy="630" r="15" class="step-circle" />
<text x="55" y="637" class="step-number" text-anchor="middle">4</text>
<text x="85" y="635" class="step-title">Advanced: Edit component code</text>
<text x="85" y="660" class="description">Open "Advanced editor" section to customize the web component directly.</text>
<!-- Advanced Examples -->
<rect x="40" y="690" width="520" height="630" class="section-bg" />
<text x="60" y="715" class="label">Advanced examples (see implementation in the editor):</text>
<!-- Example 1a: Interactive Button - Device Details -->
<text x="60" y="745" class="description">• Button navigating to device details:</text>
<g class="button" @click=\${() => this.c8yScadaFunctions?.goToDeviceDetails(this.c8yScadaValues?.deviceId)}>
<rect x="70" y="755" width="200" height="35" class="button-bg" />
<text x="170" y="777" class="button-text">Go to device details</text>
</g>
<!-- Example 1b: Interactive Button - Group Details -->
<text x="60" y="820" class="description">• Button navigating to group details:</text>
<g class="button" @click=\${() => this.c8yScadaFunctions?.goToGroupDetails(this.c8yScadaValues?.groupId)}>
<rect x="70" y="830" width="200" height="35" class="button-bg" />
<text x="170" y="852" class="button-text">Go to group details</text>
</g>
<!-- Example 2: Conditional Styling -->
<text x="60" y="895" class="description">• Conditional styling based on temperature range:</text>
<text x="70" y="920"
class="\${(this.c8yScadaValues?.temperature >= 20 && this.c8yScadaValues?.temperature <= 30) ? 'temp-ok' : 'temp-warning'}"
font-size="14px"
font-weight="bold">
Green if temperature is between 20 and 30 °C, otherwise red.
</text>
<!-- Example 3: Dynamic Width Progress Bar -->
<text x="60" y="975" class="description">• Dynamic calculations (progress bar):</text>
<rect x="70" y="985" width="400" height="25" class="code-bg" />
<rect x="70" y="985"
width="\${Math.min((this.c8yScadaValues?.progress || 0) * 4, 400)}"
height="25"
fill="var(--brand-primary,var(--c8y-brand-primary))"
rx="13" />
<text x="480" y="1000" class="label">\${this.c8yScadaValues?.progress || 0}%</text>
<!-- Example 4: Alarm Status Circle -->
<text x="60" y="1040" class="description">• Alarm status indicator using getActiveAlarmsStatusClass():</text>
<circle cx="80" cy="1065" r="12"
class="\${this.c8yScadaFunctions?.getActiveAlarmsStatusClass ? 'alarm-' + this.c8yScadaFunctions.getActiveAlarmsStatusClass(this.c8yScadaValues?.alarmsStatus) : 'alarm-none'}" />
<text x="100" y="1070" class="label">
Alarm status:
\${this.c8yScadaValues?.alarmsStatus?.critical > 0 ? 'CRITICAL' : this.c8yScadaValues?.alarmsStatus?.major > 0 ? 'MAJOR' : this.c8yScadaValues?.alarmsStatus?.minor > 0 ? 'MINOR' : this.c8yScadaValues?.alarmsStatus?.warning > 0 ? 'WARNING' : 'OK'}
</text>
<!-- Legend for alarm colors -->
<text x="70" y="1110" class="label">Alarm color legend:</text>
<circle cx="80" cy="1130" r="8" class="alarm-none" />
<text x="95" y="1135" class="description">OK</text>
<circle cx="150" cy="1130" r="8" class="alarm-warning" />
<text x="165" y="1135" class="description">Warning</text>
<circle cx="240" cy="1130" r="8" class="alarm-minor" />
<text x="255" y="1135" class="description">Minor</text>
<circle cx="320" cy="1130" r="8" class="alarm-major" />
<text x="335" y="1135" class="description">Major</text>
<circle cx="400" cy="1130" r="8" class="alarm-critical" />
<text x="415" y="1135" class="description">Critical</text>
<!-- Additional examples info -->
<rect x="70" y="1170" width="460" height="130" fill="var(--c8y-palette-status-info-light)" stroke="var(--c8y-palette-status-info)" stroke-width="1" rx="4" />
<text x="85" y="1195" class="label" font-weight="bold">💡 Tips for advanced usage:</text>
<text x="85" y="1220" class="description">• Use this.c8yScadaValues to access placeholder values</text>
<text x="85" y="1240" class="description">• Use this.c8yScadaFunctions for navigation and utilities</text>
<text x="85" y="1260" class="description">• Combine template literals \\\${ } with conditional logic</text>
<text x="85" y="1280" class="description">• Apply CSS classes dynamically for styling</text>
</svg>
`;
class ScadaWidgetService {
getScadaFunctions() {
return {
goToGroupDetails: (groupId) => {
window.location.hash = `/group/${groupId}`;
},
goToDeviceDetails: (deviceId) => {
window.location.hash = `/device/${deviceId}`;
},
getActiveAlarmsStatusClass: (activeAlarmsStatus) => {
const severities = [
'critical',
'major',
'minor',
'warning'
];
for (const severity of severities) {
if (activeAlarmsStatus?.[severity] > 0) {
return severity;
}
}
return 'none';
}
};
}
getSvgComputedStyling(settings) {
const displayOption = settings?.displayOption ?? 'full-width';
const hAlign = settings?.horizontalAlignment ?? 'center';
const vAlign = settings?.verticalAlignment ?? 'center';
if (!settings || displayOption === 'full-width') {
return {
svgWidth: '100%',
svgHeight: '',
svgMaxWidth: '',
svgMaxHeight: '',
preserveAspectRatio: null,
containerDisplay: '',
containerJustifyContent: '',
containerAlignItems: '',
containerOverflow: '',
containerHeight: '100%',
containerBoxSizing: 'border-box'
};
}
const hFlex = {
left: 'flex-start',
center: 'center',
right: 'flex-end'
};
const vFlex = {
top: 'flex-start',
center: 'center',
bottom: 'flex-end'
};
const hPar = { left: 'Min', center: 'Mid', right: 'Max' };
const vPar = { top: 'Min', center: 'Mid', bottom: 'Max' };
const parBase = `x${hPar[hAlign]}Y${vPar[vAlign]}`;
// contain
return {
svgWidth: '',
svgHeight: '',
svgMaxWidth: '100%',
svgMaxHeight: '100%',
preserveAspectRatio: `${parBase} meet`,
containerDisplay: 'flex',
containerJustifyContent: hFlex[hAlign],
containerAlignItems: vFlex[vAlign],
containerOverflow: '',
containerHeight: '100%',
containerBoxSizing: 'border-box'
};
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaWidgetService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaWidgetService }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaWidgetService, decorators: [{
type: Injectable
}] });
/**
* Escapes Lit syntax to safe data attributes for XML parsing.
*
* Converts:
* - @click=${...} => data-lit-event-click="${...}"
* - ?attr=${...} => data-lit-attribute-attr="${...}"
* - .prop=${...} => data-lit-property-prop="${...}"
* - `"` inside expressions => `"` (to produce valid XML/HTML attribute values)
*
* @param svg The SVG string containing Lit syntax
* @returns The SVG string with escaped Lit syntax
*/
function escapeLitSyntax(svg) {
return (svg
// @click=${...} => data-lit-event-click="${...}"
.replace(/@([a-zA-Z0-9_-]+)=\$\{([^}]*)\}/g, (_, name, expr) => {
const escaped = expr.replace(/"/g, '"');
return `data-lit-event-${name}="\${${escaped}}"`;
})
// ?attr=${...} => data-lit-attribute-attr="${...}"
.replace(/\?([a-zA-Z0-9_-]+)=\$\{([^}]*)\}/g, (_, name, expr) => {
const escaped = expr.replace(/"/g, '"');
return `data-lit-attribute-${name}="\${${escaped}}"`;
})
// .prop=${...} => data-lit-property-prop="${...}"
.replace(/\.([a-zA-Z0-9_-]+)=\$\{([^}]*)\}/g, (_, name, expr) => {
const escaped = expr.replace(/"/g, '"');
return `data-lit-property-${name}="\${${escaped}}"`;
}));
}
/**
* Unescapes data attributes back to Lit syntax.
*
* Converts:
* - data-lit-event-click="${...}" => @click=${...}
* - data-lit-attribute-attr="${...}" => ?attr=${...}
* - data-lit-property-prop="${...}" => .prop=${...}
* - `"` inside expression values => `"` (decoded automatically via `DOMParser`)
*
* @param svg The SVG string with escaped data attributes
* @returns The SVG string with restored Lit syntax
*/
function unescapeLitSyntax(svg) {
return (svg
// data-lit-event-click="${...}" => @click=${...}
.replace(/data-lit-event-([a-zA-Z0-9_-]+)="([^"]*)"/g, (_, name, value) => {
const valueDocument = new DOMParser().parseFromString(value, 'text/html');
const { textContent } = valueDocument.documentElement;
return `@${name}=${textContent}`;
})
// data-lit-attribute-attr="${...}" => ?attr=${...}
.replace(/data-lit-attribute-([a-zA-Z0-9_-]+)="([^"]*)"/g, (_, name, value) => {
const valueDocument = new DOMParser().parseFromString(value, 'text/html');
const { textContent } = valueDocument.documentElement;
return `?${name}=${textContent}`;
})
// data-lit-property-prop="${...}" => .prop=${...}
.replace(/data-lit-property-([a-zA-Z0-9_-]+)="([^"]*)"/g, (_, name, value) => {
const valueDocument = new DOMParser().parseFromString(value, 'text/html');
const { textContent } = valueDocument.documentElement;
return `.${name}=${textContent}`;
}));
}
/**
* Removes Lit expressions from SVG and replaces them with placeholders.
*
* Converts ${...} to __LIT_EXPR_n__ to allow safe XML parsing.
* The extracted expressions are stored in the provided array for later restoration.
*
* @param svg The SVG string containing Lit expressions
* @param litExpressions Array to store extracted expressions
* @returns The SVG string with expressions replaced by placeholders
*/
function removeLitExpressions(svg, litExpressions) {
// ${...} => __LIT_EXPR_n__
let exprIndex = 0;
return svg.replace(/\$\{([^}]*)\}/g, (_, expr) => {
litExpressions.push(expr);
return `__LIT_EXPR_${exprIndex++}__`;
});
}
/**
* Restores Lit expressions in SVG from placeholders.
*
* Converts __LIT_EXPR_n__ back to ${...} using expressions from the provided array.
*
* @param svg The SVG string with expression placeholders
* @param litExpressions Array containing the extracted expressions
* @returns The SVG string with restored Lit expressions
*/
function restoreLitExpressions(svg, litExpressions) {
// __LIT_EXPR_n__ => ${...}
return svg.replace(/__LIT_EXPR_(\d+)__/g, (_, index) => {
const expr = litExpressions[Number(index)];
return `\${${expr}}`;
});
}
/**
* Generates Lit web component code for rendering SCADA SVG elements.
*
* Creates a custom element class that extends LitElement with support for:
* - Dynamic context binding (c8yContext)
* - SCADA value placeholders (c8yScadaValues)
* - SCADA helper functions (c8yScadaFunctions)
* - Interactive placeholder highlighting and selection
*
* The generated component includes event listeners for hover and click interactions
* on elements with the 'potential-placeholder' class, enabling users to identify
* and select placeholders within the SCADA visualization in the widget's configuration.
*
* @param svgCode The SVG markup to embed in the component's render method
* @returns A string containing the complete Lit web component code
*/
function getWebComponentCode(svgCode) {
return `
import { LitElement, html, css } from 'lit';
export default class ScadaElement extends LitElement {
static styles = css\`
:host > div {
padding: var(--c8y-root-component-padding-default);
}
.enable-potential-placeholders .potential-placeholder {
cursor: pointer;
transition: fill 0.2s, stroke 0.2s;
pointer-events: auto;
}
.enable-potential-placeholders .potential-placeholder.hovered {
fill: var(--brand-primary, var(--c8y-brand-primary));
stroke: var(--brand-primary, var(--c8y-brand-primary));
}
\`;
static properties = {
// Context managed object assigned to this widget, might be null.
c8yContext: { type: Object },
// Object with values assigned to placeholders in the widget, might be null.
c8yScadaValues: { type: Object },
// Object with helper functions, might be null.
c8yScadaFunctions: { type: Object },
// The instant translation function.
c8yTranslate: { type: Function },
// Pre-computed SVG display and alignment styling.
c8ySvgComputedStyling: { type: Object },
// Whether to highlight potential placeholders and emit click events.
enablePotentialPlaceholders: { type: Boolean },
};
constructor() {
super();
}
get _wrapperStyle() {
const s = this.c8ySvgComputedStyling;
if (!s || !s.containerDisplay) {
return '';
}
let style = 'display: ' + s.containerDisplay + '; height: ' + s.containerHeight + '; justify-content: ' + s.containerJustifyContent + '; align-items: ' + s.containerAlignItems;
if (s.containerOverflow) {
style += '; overflow: ' + s.containerOverflow;
}
if (s.containerBoxSizing) {
style += '; box-sizing: ' + s.containerBoxSizing;
}
return style;
}
_applySvgStyling() {
const svg = this.renderRoot.querySelector('svg');
if (!svg) return;
const s = this.c8ySvgComputedStyling;
if (!s) {
svg.style.width = '100%';
svg.style.height = '';
svg.style.maxWidth = '';
svg.style.maxHeight = '';
svg.removeAttribute('preserveAspectRatio');
return;
}
svg.style.width = s.svgWidth;
svg.style.height = s.svgHeight;
svg.style.maxWidth = s.svgMaxWidth;
svg.style.maxHeight = s.svgMaxHeight;
if (s.preserveAspectRatio === null) {
svg.removeAttribute('preserveAspectRatio');
} else {
svg.setAttribute('preserveAspectRatio', s.preserveAspectRatio);
}
}
firstUpdated() {
// Add hover and click listeners to potential placeholders
const svg = this.renderRoot.querySelector('svg');
if (svg) {
svg.querySelectorAll('.potential-placeholder').forEach(node => {
const dispatchPlaceholderClickEvent = () => {
const event = new CustomEvent('potential-placeholder-click', {
detail: { node },
bubbles: true,
composed: true
});
this.dispatchEvent(event);
};
node.setAttribute('tabindex', '0');
node.setAttribute('role', 'button');
node.addEventListener('mouseenter', () => {
node.classList.add('hovered');
});
node.addEventListener('mouseleave', () => {
node.classList.remove('hovered');
});
node.addEventListener('click', () => {
dispatchPlaceholderClickEvent();
});
node.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
event.preventDefault();
dispatchPlaceholderClickEvent();
}
});
});
}
this._applySvgStyling();
}
updated(changedProperties) {
if (changedProperties.has('c8ySvgComputedStyling')) {
this._applySvgStyling();
}
}
render() {
return html\`
<div class="\${this.enablePotentialPlaceholders ? 'enable-potential-placeholders' : ''}"
style="\${this._wrapperStyle}">
${svgCode}
</div>
\`;
}
}`;
}
class ScadaWidgetConfigService {
constructor() {
this.widgetConfigService = inject(WidgetConfigService);
this.widgetConfigMigration = inject(WidgetConfigMigrationService);
this.inventory = inject(InventoryService);
this.inventoryBinary = inject(InventoryBinaryService);
this.computedPropertiesService = inject(ComputedPropertiesService);
this.fieldbusService = inject(FieldbusService);
this.destroyRef = inject(DestroyRef);
this.configChanged$ = new Subject();
this.config$ = this.widgetConfigService.currentConfig$.pipe(debounceTime(100), switchMap(config => this.initializeConfig(config)), switchMap(initConfig => this.configChanged$.pipe(startWith(initConfig))), shareReplay({ bufferSize: 1, refCount: true }), takeUntilDestroyed(this.destroyRef));
this.destroyRef.onDestroy(() => this.configChanged$.complete());
}
async initializeConfig(config) {
let migratedConfig = await this.migrateConfigIfNeeded(config);
if (!migratedConfig.config?.htmlWidgetConfig) {
migratedConfig = await this.updateConfigWithSvgCode(migratedConfig, INITIAL_SVG_CODE);
}
return migratedConfig;
}
save(config) {
// - only persist SCADA-specific state (avoid writing stale global time context values)
// - force=true applies the update synchronously so that subsequent dialog saves see the latest state
this.widgetConfigService.updateConfig({ config: config.config }, true);
this.configChanged$.next(config);
}
async updateConfigWithSvgFile(config, svgFile) {
if (!svgFile || !svgFile.text) {
throw new Error('Invalid SVG file provided');
}
const originalSvgCode = await svgFile.text();
return this.updateConfigWithSvgCode(config, originalSvgCode);
}
async updateConfigWithSvgCode(config, originalSvgCode) {
// Remove hardcoded dimensions to make the SVG responsive
const svgCodeWithoutDimensions = await this.removeHardcodedSvgDimensions(originalSvgCode);
const svgCodeWithUuids = await this.findAndAddMarkupForPotentialTextPlaceholders(svgCodeWithoutDimensions);
const svgCode = this.replacePlaceholdersWithTemplateSyntax(svgCodeWithUuids);
config.config = config.config || {};
config.config.htmlWidgetConfig = {
config: {
devMode: true,
code: getWebComponentCode(svgCode),
css: '',
legacy: false,
options: {
cssEncapsulation: false,
advancedSecurity: false
}
},
device: null,
settings: {}
};
this.updateMappings(config);
return config;
}
extractTagContent(text, tagName) {
const openTag = `<${tagName}>`;
const closeTag = `</${tagName}>`;
const openIdx = text.indexOf(openTag);
const closeIdx = text.indexOf(closeTag);
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
return text.substring(openIdx + openTag.length, closeIdx);
}
return undefined;
}
async applySvgCode(code) {
const config = await firstValueFrom(this.config$);
const updatedConfig = await this.updateConfigWithSvgCode(config, code);
this.save(updatedConfig);
}
async applyMappings(mappings) {
try {
const parsedMappings = JSON.parse(mappings);
const config = await firstValueFrom(this.config$);
config.config.mappings = parsedMappings;
this.save(config);
}
catch (ex) {
// eslint-disable-next-line no-console
console.error('Failed to apply placeholder mapping:', ex);
}
}
async applyScadaOutput(content) {
const svgContent = this.extractTagContent(content, SVG_EXTRACT_TAG_NAME);
const mappingContent = this.extractTagContent(content, MAPPING_EXTRACT_TAG_NAME);
if (svgContent) {
await this.applySvgCode(svgContent.trim());
}
if (mappingContent) {
await this.applyMappings(mappingContent.trim());
}
}
/**
* Safely parses an SVG string containing Lit expressions, allows DOM editing, and returns the edited SVG string.
* Handles escaping/unescaping Lit syntax and expressions.
*
* @param svg The SVG string to process.
* @param editFn A function that receives the parsed SVG document for editing.
* @returns The edited SVG string, or throws an exception if parsing or editing failed.
*/
async editSafelySvgWithLitExpressions(svg, editFn) {
const litExpressions = [];
const parseToDoc = (svg) => {
const parser = new DOMParser();
const doc = parser.parseFromString(removeLitExpressions(escapeLitSyntax(svg), litExpressions), 'image/svg+xml');
if (doc.querySelector('parsererror')) {
throw new Error('Could not parse SVG: it may contain unsupported Lit syntax.');
}
return doc;
};
const serializeToString = (doc) => {
const serializer = new XMLSerializer();
const serializedSvg = serializer.serializeToString(doc.documentElement);
return restoreLitExpressions(unescapeLitSyntax(serializedSvg), litExpressions);
};
const doc = parseToDoc(svg);
await editFn(doc);
return serializeToString(doc);
}
updateMappings(config) {
let keys;
if (config.config.htmlWidgetConfig.config.legacy) {
keys = this.extractLegacyScadaKeys(config.config.htmlWidgetConfig.config.code);
}
else {
keys = this.extractScadaKeys(config.config.htmlWidgetConfig.config.code);
}
const currentMappings = config.config.mappings || {};
const newMappings = {};
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(currentMappings, key)) {
newMappings[key] = currentMappings[key];
}
else {
newMappings[key] = { assetProperty: null };
}
}
config.config.mappings = newMappings;
}
/**
* Removes hardcoded width and height attributes from the root SVG element.
*
* @param svg The SVG string to process.
* @returns The modified SVG string with width and height attributes removed from the root SVG element.
*/
async removeHardcodedSvgDimensions(svg) {
return this.editSafelySvgWithLitExpressions(svg, async (doc) => {
const svgElement = doc.documentElement;
if (svgElement.tagName.toLowerCase() === 'svg') {
svgElement.removeAttribute('width');
svgElement.removeAttribute('height');
}
});
}
/**
* Processes an SVG string to identify and mark potential text placeholders within `<text>` and `<tspan>` elements.
*
* This method performs the following steps:
* 1. Escapes Lit syntax event bindings (e.g., `@click=${...}`) to avoid XML parsing issues.
* 2. Parses the SVG string into a DOM structure.
* 3. For each `<text>` and `<tspan>` element:
* - Adds a unique `id` attribute if not already present.
* - Adds the CSS class `potential-placeholder` if the element's text content does not already contain a placeholder expression (`this.c8yScadaValues?.`).
* 4. Serializes the DOM back to an SVG string and restores the original Lit syntax.
*
* If parsing fails (e.g., due to unhandled Lit syntax), the original SVG string is returned unchanged.
*
* @param svg The SVG string to process.
* @returns The modified SVG string with potential placeholders marked, or the original SVG if parsing fails.
*/
async findAndAddMarkupForPotentialTextPlaceholders(svg) {
return this.editSafelySvgWithLitExpressions(svg, async (doc) => {
// Handle both <text> and <tspan> elements
const nodes = doc.querySelectorAll('text, tspan');
nodes.forEach(node => {
// exclude nodes which has child nodes
if (node.children.length > 0) {
return;
}
// exclude nodes which already contain expressions
const nodeTextContent = node.textContent || '';
if (nodeTextContent.includes('__LIT_EXPR_')) {
return;
}
// add id attribute with random UUID if not present
if (!node.hasAttribute('id')) {
node.setAttribute('id', crypto.randomUUID());
}
// add class 'potential-placeholder'
node.classList.add('potential-placeholder');
});
});
}
/**
* Replaces `{{ key }}` placeholders with new template syntax `${this.c8yScadaValues?.key}`.
*
* @param svgCode The SVG code string with placeholders.
* @returns The SVG code string with new template syntax.
*/
replacePlaceholdersWithTemplateSyntax(svgCode) {
return svgCode.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => `\${this.c8yScadaValues?.${key}}`);
}
/**
* Extracts placeholder keys used in the template syntax (`${this.c8yScadaValues?.key}`)
* from the provided SVG code string.
*
* @param svgCode The SVG code string containing template syntax placeholders.
* @returns Array of unique placeholder keys.
*/
extractScadaKeys(svgCode) {
const curlyBracesRegex = /\$\{([^}]*)\}/g;
const scadaValueRegex = /this\.c8yScadaValues\??\.([a-zA-Z0-9_]+)/g;
const keys = new Set();
let curlyBracesMatch;
while ((curlyBracesMatch = curlyBracesRegex.exec(svgCode)) !== null) {
const curlyBracesExpression = curlyBracesMatch[1];
let scadaValueMatch;
while ((scadaValueMatch = scadaValueRegex.exec(curlyBracesExpression)) !== null) {
keys.add(scadaValueMatch[1]);
}
}
return Array.from(keys);
}
/**
* Extracts placeholder keys used in the legacy template syntax (`{{ key }}`)
* from the provided SVG code string.
*
* @param svgCode The SVG code string containing legacy template syntax placeholders.
* @returns Array of unique placeholder keys.
*/
extractLegacyScadaKeys(svgCode) {
const legacyPlaceholderRegex = /\{\{\s*(\w+)\s*\}\}/g;
const keys = new Set();
let match;
while ((match = legacyPlaceholderRegex.exec(svgCode)) !== null) {
keys.add(match[1]);
}
return Array.from(keys);
}
async migrateConfigIfNeeded(config) {
const hasNewConfig = !!config?.config?.mappings && !!config?.config?.htmlWidgetConfig;
const hasLegacyConfig = !!config?.binaryId && !!config?.mapping && !!config?.deviceIds;
const migrationNeeded = hasLegacyConfig && !hasNewConfig;
config = this.migrateGlobalContextConfig(config);
if (!migrationNeeded) {
return config;
}
return await this.migrateFromLegacyBinary(config);
}
migrateGlobalContextConfig(config) {
// Always migrate global context config and set displayMode to 'dashboard' if no other value was explicitly used:
const hadExplicitDisplayMode = typeof config?.displayMode === 'string' &&
Object.values(GLOBAL_CONTEXT_DISPLAY_MODE).includes(config.displayMode);
const migratedGlobalContextConfig = this.widgetConfigMigration.migrateWidgetConfig(config);
config = merge(config, migratedGlobalContextConfig);
if (!hadExplicitDisplayMode) {
config.displayMode = GLOBAL_CONTEXT_DISPLAY_MODE.DASHBOARD;
}
return config;
}
async migrateFromLegacyBinary(config) {
const binaryId = config.binaryId;
const binary = await this.inventoryBinary.download(binaryId);
const svgCode = await binary.text();
const mappings = {};
for (const key of Object.keys(config.mapping)) {
mappings[key] = {
assetProperty: await this.migrateProperty(config.mapping[key], config.deviceIds[key])
};
}
return {
...config,
config: {
htmlWidgetConfig: {
config: {
code: svgCode,
css: '',
legacy: true,
devMode: false,
options: {
cssEncapsulation: false,
// must be `false`, otherwise the legacy widgets won't work correctly
advancedSecurity: false
}
},
device: null,
settings: {}
},
mappings,
svgDisplaySettings: {
displayOption: 'contain',
horizontalAlignment: defaultAlignment,
verticalAlignment: defaultAlignment
}
}
};
}
async migrateProperty(legacyProperty, assetId) {
const isFieldbus = (prop) => prop['fieldbus'] === true;
const isComputed = (prop) => prop['computed'] === true;
const isRegular = (prop) => !isFieldbus(prop) && !isComputed(prop);
const asset = (await this.inventory.detail(assetId)).data;
if (isFieldbus(legacyProperty)) {
return this.fieldbusService.migrateFieldbusProperty(legacyProperty, asset);
}
if (isComputed(legacyProperty)) {
const computedPropertyName = legacyProperty.id.replace(/^c8ySchema!!/, '');
const computedProperty = await this.computedPropertiesService.getByName(computedPropertyName);
return {
...computedProperty.prop,
instanceId: legacyProperty.config._id,
active: true,
temporary: true,
contextAssetId: assetId,
config: legacyProperty.config,
asset: {
id: asset.id,
name: asset.name
}
};
}
if (isRegular(legacyProperty)) {
const buildJsonSchema = (keyPath, label, type) => {
if (keyPath.length === 1) {
return {
[keyPath[0]]: {
label,
type,
key: keyPath[0],
properties: {}
}
};
}
// Recursively nest properties
const [first, ...rest] = keyPath;
return {
[first]: {
properties: buildJsonSchema(rest, label, type)
}
};
};
const assetProperty = {
isEditable: true,
c8y_JsonSchema: {
properties: buildJsonSchema(legacyProperty.keyPath, legacyProperty.label, legacyProperty.type)
},
name: legacyProperty.keyPath.join('.'),
active: true,
label: legacyProperty.label,
type: legacyProperty.type,
asset: {
id: asset.id,
name: asset.name
}
};
return assetProperty;
}
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaWidgetConfigService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaWidgetConfigService }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaWidgetConfigService, decorators: [{
type: Injectable
}], ctorParameters: () => [] });
class ScadaAiChatToolDetailsComponent {
constructor() {
this.tool = input.required(...(ngDevMode ? [{ debugName: "tool" }] : []));
this.content = computed(() => this.tool().input?.content || '', ...(ngDevMode ? [{ debugName: "content" }] : []));
this.isExecuting = computed(() => this.tool().type !== 'tool-result', ...(ngDevMode ? [{ debugName: "isExecuting" }] : []));
this.svgContent = computed(() => this.scadaWidgetConfigService.extractTagContent(this.content(), SVG_EXTRACT_TAG_NAME), ...(ngDevMode ? [{ debugName: "svgContent" }] : []));
this.mappingContent = computed(() => this.scadaWidgetConfigService.extractTagContent(this.content(), MAPPING_EXTRACT_TAG_NAME), ...(ngDevMode ? [{ debugName: "mappingContent" }] : []));
this.mappingRows = computed(() => {
const mapping = this.mappingContent();
if (!mapping)
return null;
try {
const parsed = JSON.parse(mapping);
return Object.entries(parsed).map(([placeholder, value]) => {
const assetProperty = value?.assetProperty;
const asset = assetProperty?.asset?.name ?? '';
let property = assetProperty?.label ?? '';
if (assetProperty?.computed && assetProperty.config?.dp?.[0]?.label) {
property = assetProperty.config.dp[0].label;
}
return { placeholder, asset, property };
});
}
catch {
return null;
}
}, ...(ngDevMode ? [{ debugName: "mappingRows" }] : []));
this.previewSvg = computed(() => this.sanitizer.bypassSecurityTrustHtml(this.simplifyPreviewSvg(this.svgContent() || '')), ...(ngDevMode ? [{ debugName: "previewSvg" }] : []));
this.sanitizer = inject(DomSanitizer);
this.scadaWidgetConfigService = inject(ScadaWidgetConfigService);
}
apply() {
const svg = this.svgContent();
const mapping = this.mappingContent();
if (svg) {
void this.scadaWidgetConfigService.applySvgCode(svg.trim());
}
if (mapping) {
void this.scadaWidgetConfigService.applyMappings(mapping.trim());
}
}
simplifyPreviewSvg(svg) {
return svg
.replace(/\$\{this\.c8yScadaValues\?\.(\w+)\?\.value[^}]*\}/g, '{$1}')
.replace(/\$\{this\.c8yScadaValues\?\.(\w+)\?\.(\w+)[^}]*\}/g, '{$1.$2}')
.replace(/\$\{this\.c8yScadaValues\?\.(\w+)[^}]*\}/g, '{$1}')
.replace(/\$\{[^}]+\}/g, '');
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaAiChatToolDetailsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: ScadaAiChatToolDetailsComponent, isStandalone: true, selector: "c8y-scada-ai-chat-tool-details", inputs: { tool: { classPropertyName: "tool", publicName: "tool", isSignal: true, isRequired: true, transformFunction: null } }, host: { styleAttribute: "display: block" }, ngImport: i0, template: "@if (svgContent()) {\n <div\n class=\"m-b-8\"\n style=\"max-height: 320px; overflow: auto\"\n [innerHTML]=\"previewSvg()\"\n ></div>\n}\n@if (mappingRows(); as rows) {\n <div\n class=\"m-b-8\"\n style=\"max-height: 160px; overflow: auto\"\n >\n <table class=\"table table-condensed m-b-0\">\n <thead>\n <tr>\n <th>{{ 'Placeholder' | translate }}</th>\n <th>{{ 'Asset' | translate }}</th>\n <th>{{ 'Property' | translate }}</th>\n </tr>\n </thead>\n <tbody>\n @for (row of rows; track row.placeholder) {\n <tr>\n <td>\n <code>{{ row.placeholder }}</code>\n </td>\n <td>{{ row.asset }}</td>\n <td>{{ row.property }}</td>\n </tr>\n }\n </tbody>\n </table>\n </div>\n} @else if (mappingContent()) {\n <pre\n class=\"m-b-8\"\n style=\"max-height: 160px\"\n >{{ mappingContent() }}</pre\n >\n}\n@if (!isExecuting()) {\n <div class=\"d-flex j-c-end\">\n <button\n class=\"btn btn-default btn-sm\"\n [attr.aria-label]=\"'Apply' | translate\"\n [tooltip]=\"'Apply' | translate\"\n container=\"body\"\n (click)=\"apply()\"\n >\n <i c8yIcon=\"check\"></i>\n </button>\n </div>\n}\n", dependencies: [{ kind: "directive", type: TooltipDirective, selector: "[tooltip], [tooltipHtml]", inputs: ["adaptivePosition", "tooltip", "placement", "triggers", "container", "containerClass", "boundariesElement", "isOpen", "isDisabled", "delay", "tooltipHtml", "tooltipPlacement", "tooltipIsOpen", "tooltipEnable", "tooltipAppendToBody", "tooltipAnimation", "tooltipClass", "tooltipContext", "tooltipPopupDelay", "tooltipFadeDuration", "tooltipTrigger"], outputs: ["tooltipChange", "onShown", "onHidden", "tooltipStateChanged"], exportAs: ["bs-tooltip"] }, { kind: "directive", type: IconDirective, selector: "[c8yIcon]", inputs: ["c8yIcon"] }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: ScadaAiChatToolDetailsComponent, decorators: [{
type: Component,
args: [{ selector: 'c8y-scada-ai-chat-tool-details', host: { style: 'display: block' }, imports: [TooltipDirective, C8yTranslatePipe, IconDirective], changeDetection: ChangeDetectionStrategy.OnPush, template: "@if (svgContent()) {\n <div\n class=\"m-b-8\"\n style=\"max-height: 320px; overflow: auto\"\n [innerHTML]=\"previewSvg()\"\n ></div>\n}\n@if (mappingRows(); as rows) {\n <div\n class=\"m-b-8\"\n style=\"max-height: 160px; overflow: auto\"\n >\n <table class=\"table table-condensed m-b-0\">\n <thead>\n <tr>\n <th>{{ 'Placeholder' | translate }}</th>\n <th>{{ 'Asset' | translate }}</th>\n <th>{{ 'Property' | translate }}</th>\n </tr>\n </thead>\n <tbody>\n @for (row of rows; track row.placeholder) {\n <tr>\n <td>\n <code>{{ row.placeholder }}</code>\n </td>\n <td>{{ row.asset }}</td>\n <td>{{ row.property }}</td>\n </tr>\n }\n </tbody>\n </table>\n </div>\n} @else if (mappingContent()) {\n <pre\n class=\"m-b-8\"\n style=\"max-height: 160px\"\n >{{ mappingContent() }}</pre\n >\n}\n@if (!isExecuting()) {\n <div class=\"d-flex j-c-end\">\n <button\n class=\"btn btn-default btn-sm\"\n [attr.aria-label]=\"'Apply' | translate\"\n [tooltip]=\"'Apply' | translate\"\n container=\"body\"\n (click)=\"apply()\"\n >\n <i c8yIcon=\"check\"></i>\n </button>\n </div>\n}\n" }]
}], propDecorators: { tool: [{ type: i0.Input, args: [{ isSignal: true, alias: "tool", required: true }] }] } });
class ScadaWidgetSvgSelectorComponent {
constructor() {
this.widgetConfigService = inject(WidgetConfigService);
this.scadaConfigService = inject(ScadaWidgetConfigService);
this.sectionComponent = inject(WidgetConfigSectionComponent);
this.inventoryService = inject(InventoryService);
this.alertService = inject(AlertService);
this.aiService = inject(AIService);
this.formBuilder = inject(FormBuilder);
this.destroyRef = inject(DestroyRef);
this.formGroup = this.formBuilder.group({
source: this.formBuilder.nonNullable.control('file'),
svgCode: this.formBuilder.nonNullable.control('', []),
svgDisplaySettings: this.formBuilder.group({
displayOption: this.formBuilder.nonNullable.control(defaultDisplayOption),
horizontalAlignment: this.formBuilder.nonNullable.control(defaultAlignment),
verticalAlignment: this.formBuilder.nonNullable.control(defaultAlignment)
})
});
this.dropArea = viewChild('dropArea', ...(ngDevMode ? [{ debugName: "dropArea" }] : []));
this.placeholderText = `<svg viewBox="0 0 200 200">…</svg>`;
this.displayOptions = [
{
label: gettext('Full width`image fitting option`'),
value: 'full-width',
description: gettext(`The image is resized to fit the widget's width while preserving the aspect ratio. Overflowing area is scrollable.`)
},
{
label: gettext('Contain`verb, image fitting option`'),
value: 'contain',
description: gettext('The image is entirely displayed within the widget while preserving the aspect ratio.')
}
];
this.horizontalAlignmentOptions = [
{ label: gettext('left`horizontal alignme