diff --git a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java index 6da5ce337c..8b3647efe2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java @@ -96,7 +96,7 @@ public class DashboardController extends BaseController { public static final String DASHBOARD_DEFINITION = "The Dashboard object is a heavyweight object that contains information about the dashboard (e.g. title, image, assigned customers) and also configuration JSON (e.g. layouts, widgets, entity aliases)."; public static final String HIDDEN_FOR_MOBILE = "Exclude dashboards that are hidden for mobile"; - @Value("${dashboard.max_datapoints_limit}") + @Value("${ui.dashboard.max_datapoints_limit}") private long maxDatapointsLimit; @ApiOperation(value = "Get server time (getServerTime)", diff --git a/application/src/main/java/org/thingsboard/server/controller/UiSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/UiSettingsController.java new file mode 100644 index 0000000000..e28e378119 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/UiSettingsController.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2021 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. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +public class UiSettingsController extends BaseController { + + @Value("${ui.help.base-url}") + private String helpBaseUrl; + + @ApiOperation(value = "Get UI help base url (getHelpBaseUrl)", + notes = "Get UI help base url used to fetch help assets. " + + "The actual value of the base url is configurable in the system configuration file.") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/uiSettings/helpBaseUrl", method = RequestMethod.GET) + @ResponseBody + public String getHelpBaseUrl() throws ThingsboardException { + return helpBaseUrl; + } + +} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 2b108c2eb9..57a6a638af 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -129,10 +129,16 @@ usage: check: cycle: "${USAGE_STATS_CHECK_CYCLE:60000}" -# Dashboard parameters -dashboard: - # Maximum allowed datapoints fetched by widgets - max_datapoints_limit: "${DASHBOARD_MAX_DATAPOINTS_LIMIT:50000}" +# UI parameters +ui: + # Dashboard parameters + dashboard: + # Maximum allowed datapoints fetched by widgets + max_datapoints_limit: "${DASHBOARD_MAX_DATAPOINTS_LIMIT:50000}" + # Help parameters + help: + # Base url for UI help assets + base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-3.3.2}" database: ts_max_intervals: "${DATABASE_TS_MAX_INTERVALS:700}" # Max number of DB queries generated by single API call to fetch telemetry records diff --git a/docker/haproxy/config/haproxy.cfg b/docker/haproxy/config/haproxy.cfg index 16b8af0595..50dcf36434 100644 --- a/docker/haproxy/config/haproxy.cfg +++ b/docker/haproxy/config/haproxy.cfg @@ -70,13 +70,12 @@ frontend http-in acl transport_http_acl path_beg /api/v1/ acl letsencrypt_http_acl path_beg /.well-known/acme-challenge/ acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/ - acl tb_rulenode_assets path_reg ^/assets/help/.*/rulenode/.*$ redirect scheme https if !letsencrypt_http_acl !transport_http_acl { env(FORCE_HTTPS_REDIRECT) -m str true } use_backend letsencrypt_http if letsencrypt_http_acl use_backend tb-http-backend if transport_http_acl - use_backend tb-api-backend if tb_api_acl or tb_rulenode_assets + use_backend tb-api-backend if tb_api_acl default_backend tb-web-backend @@ -89,10 +88,9 @@ frontend https_in acl transport_http_acl path_beg /api/v1/ acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/ - acl tb_rulenode_assets path_reg ^/assets/help/.*/rulenode/.*$ use_backend tb-http-backend if transport_http_acl - use_backend tb-api-backend if tb_api_acl or tb_rulenode_assets + use_backend tb-api-backend if tb_api_acl default_backend tb-web-backend diff --git a/ui-ngx/proxy.conf.js b/ui-ngx/proxy.conf.js index 1211280966..2e3817445b 100644 --- a/ui-ngx/proxy.conf.js +++ b/ui-ngx/proxy.conf.js @@ -26,10 +26,6 @@ const PROXY_CONFIG = { "target": ruleNodeUiforwardUrl, "secure": false, }, - "/assets/help/*/rulenode/**": { - "target": ruleNodeUiforwardUrl, - "secure": false, - }, "/static/widgets": { "target": forwardUrl, "secure": false, diff --git a/ui-ngx/src/app/core/http/ui-settings.service.ts b/ui-ngx/src/app/core/http/ui-settings.service.ts new file mode 100644 index 0000000000..e86ecb53e7 --- /dev/null +++ b/ui-ngx/src/app/core/http/ui-settings.service.ts @@ -0,0 +1,43 @@ +/// +/// Copyright © 2016-2021 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 { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { defaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; +import { Observable } from 'rxjs'; +import { publishReplay, refCount } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class UiSettingsService { + + private helpBaseUrlObservable: Observable; + + constructor( + private http: HttpClient + ) { } + + public getHelpBaseUrl(): Observable { + if (!this.helpBaseUrlObservable) { + this.helpBaseUrlObservable = this.http.get('/api/uiSettings/helpBaseUrl', {responseType: 'text', ...defaultHttpOptions(true)}).pipe( + publishReplay(1), + refCount() + ); + } + return this.helpBaseUrlObservable; + } +} diff --git a/ui-ngx/src/app/core/services/help.service.ts b/ui-ngx/src/app/core/services/help.service.ts index 2ed8863b4d..df8b14d71c 100644 --- a/ui-ngx/src/app/core/services/help.service.ts +++ b/ui-ngx/src/app/core/services/help.service.ts @@ -18,23 +18,29 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { TranslateService } from '@ngx-translate/core'; import { Observable, of } from 'rxjs'; -import { catchError, mergeMap, tap } from 'rxjs/operators'; -import { helpBaseUrl } from '@shared/models/constants'; +import { catchError, map, mergeMap, tap } from 'rxjs/operators'; +import { helpBaseUrl as siteBaseUrl } from '@shared/models/constants'; +import { UiSettingsService } from '@core/http/ui-settings.service'; -const NOT_FOUND_CONTENT = '## Not found'; +const localHelpBaseUrl = '/assets'; + +const NOT_FOUND_CONTENT: HelpData = { + content: '## Not found', + helpBaseUrl: localHelpBaseUrl +}; @Injectable({ providedIn: 'root' }) export class HelpService { - private helpBaseUrl = helpBaseUrl; - + private siteBaseUrl = siteBaseUrl; private helpCache: {[lang: string]: {[key: string]: string}} = {}; constructor( private translate: TranslateService, - private http: HttpClient + private http: HttpClient, + private uiSettingsService: UiSettingsService ) {} getHelpContent(key: string): Observable { @@ -70,13 +76,38 @@ export class HelpService { } } - private loadHelpContent(lang: string, key: string): Observable { - return this.http.get(`/assets/help/${lang}/${key}.md`, {responseType: 'text'} ); + private loadHelpContent(lang: string, key: string): Observable { + return this.uiSettingsService.getHelpBaseUrl().pipe( + mergeMap((helpBaseUrl) => { + return this.loadHelpContentFromBaseUrl(helpBaseUrl, lang, key).pipe( + catchError((e) => { + if (localHelpBaseUrl !== helpBaseUrl) { + return this.loadHelpContentFromBaseUrl(localHelpBaseUrl, lang, key); + } else { + throw e; + } + }) + ); + }) + ); + } + + private loadHelpContentFromBaseUrl(helpBaseUrl: string, lang: string, key: string): Observable { + return this.http.get(`${helpBaseUrl}/help/${lang}/${key}.md`, {responseType: 'text'} ).pipe( + map((content) => { + return { + content, + helpBaseUrl + }; + }) + ); } - private processVariables(content: string): string { - const baseUrlReg = /\${baseUrl}/g; - return content.replace(baseUrlReg, this.helpBaseUrl); + private processVariables(helpData: HelpData): string { + const baseUrlReg = /\${siteBaseUrl}/g; + helpData.content = helpData.content.replace(baseUrlReg, this.siteBaseUrl); + const helpBaseUrlReg = /\${helpBaseUrl}/g; + return helpData.content.replace(helpBaseUrlReg, helpData.helpBaseUrl); } private processIncludes(content: string): Observable { @@ -96,3 +127,8 @@ export class HelpService { } } + +interface HelpData { + content: string; + helpBaseUrl: string; +} diff --git a/ui-ngx/src/app/shared/components/help-popup.component.ts b/ui-ngx/src/app/shared/components/help-popup.component.ts index 50566cc9d4..26497460fa 100644 --- a/ui-ngx/src/app/shared/components/help-popup.component.ts +++ b/ui-ngx/src/app/shared/components/help-popup.component.ts @@ -31,7 +31,7 @@ import { isDefinedAndNotNull } from '@core/utils'; @Component({ // tslint:disable-next-line:component-selector - selector: '[tb-help-popup]', + selector: '[tb-help-popup], [tb-help-popup-content]', templateUrl: './help-popup.component.html', styleUrls: ['./help-popup.component.scss'], encapsulation: ViewEncapsulation.None @@ -44,6 +44,9 @@ export class HelpPopupComponent implements OnChanges, OnDestroy { // tslint:disable-next-line:no-input-rename @Input('tb-help-popup') helpId: string; + // tslint:disable-next-line:no-input-rename + @Input('tb-help-popup-content') helpContent: string; + // tslint:disable-next-line:no-input-rename @Input('trigger-text') triggerText: string; @@ -82,7 +85,7 @@ export class HelpPopupComponent implements OnChanges, OnDestroy { const trigger = this.textMode ? this.toggleHelpTextButton.nativeElement : this.toggleHelpButton.nativeElement; this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef, this.helpId, - '', + this.helpContent, (visible) => { this.popoverVisible = visible; }, (ready => { diff --git a/ui-ngx/src/app/shared/components/marked-options.service.ts b/ui-ngx/src/app/shared/components/marked-options.service.ts index 0aab6b0aa2..97faf53118 100644 --- a/ui-ngx/src/app/shared/components/marked-options.service.ts +++ b/ui-ngx/src/app/shared/components/marked-options.service.ts @@ -23,6 +23,7 @@ import { Tokenizer } from 'marked'; import * as marked from 'marked'; const copyCodeBlock = '{:copy-code}'; +const codeStyleRegex = '^{:code-style="(.*)"}\n'; const autoBlock = '{:auto}'; const targetBlankBlock = '{:target="_blank"}'; @@ -70,13 +71,13 @@ export class MarkedOptionsService extends MarkedOptions { }; marked.use({tokenizer}); this.renderer.code = (code: string, language: string | undefined, isEscaped: boolean) => { - if (code.endsWith(copyCodeBlock)) { - code = code.substring(0, code.length - copyCodeBlock.length); - const content = postProcessCodeContent(this.renderer2.code(code, language, isEscaped), code); + const codeContext = processCode(code); + if (codeContext.copyCode) { + const content = postProcessCodeContent(this.renderer2.code(codeContext.code, language, isEscaped), codeContext); this.id++; - return this.wrapCopyCode(this.id, content, code); + return this.wrapCopyCode(this.id, content, codeContext); } else { - return this.wrapDiv(postProcessCodeContent(this.renderer2.code(code, language, isEscaped), code)); + return this.wrapDiv(postProcessCodeContent(this.renderer2.code(codeContext.code, language, isEscaped), codeContext)); } }; this.renderer.table = (header: string, body: string) => { @@ -95,10 +96,11 @@ export class MarkedOptionsService extends MarkedOptions { header: boolean; align: 'center' | 'left' | 'right' | null; }) => { - if (content.endsWith(copyCodeBlock)) { - content = content.substring(0, content.length - copyCodeBlock.length); + const codeContext = processCode(content); + codeContext.multiline = false; + if (codeContext.copyCode) { this.id++; - content = this.wrapCopyCode(this.id, content, content); + content = this.wrapCopyCode(this.id, codeContext.code, codeContext); } return this.renderer2.tablecell(content, flags); }; @@ -119,10 +121,14 @@ export class MarkedOptionsService extends MarkedOptions { return `
${content}
`; } - private wrapCopyCode(id: number, content: string, code: string): string { + private wrapCopyCode(id: number, content: string, context: CodeContext): string { + let copyCodeButtonClass = 'clipboard-btn'; + if (context.multiline) { + copyCodeButtonClass += ' multiline'; + } return `
${content}` + - `` + - `