diff --git a/.gitignore b/.gitignore index 48f49264..87e4785f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ hs_err_pid* # SBT related stuff .bsp + +# Ignore the Angular Cache +frontend/.angular/cache diff --git a/Dockerfile b/Dockerfile index f4e85abb..166eee99 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax = docker/dockerfile:1.0-experimental FROM openjdk:11-buster as builder -ARG NODE_VERSION=10 +ARG NODE_VERSION=16 RUN echo "deb https://repo.scala-sbt.org/scalasbt/debian /" | tee -a /etc/apt/sources.list.d/sbt.list \ && curl -sL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x2EE0EA64E40A89B84B2DF73499E82A75642AC823" | apt-key add \ @@ -14,7 +14,8 @@ RUN apt-get install -y lsb-release \ && echo "deb-src https://deb.nodesource.com/node_$NODE_VERSION.x $DISTRO main" >> /etc/apt/sources.list.d/nodesource.list \ && curl -sSL https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add - \ && apt-get update \ - && apt-get install -y nodejs + && apt-get install -y nodejs \ + && apt-get install -y g++ make COPY . /smui WORKDIR /smui diff --git a/app/controllers/ApiController.scala b/app/controllers/ApiController.scala index 52603259..3a46e3cf 100644 --- a/app/controllers/ApiController.scala +++ b/app/controllers/ApiController.scala @@ -21,13 +21,14 @@ import models.config.SmuiVersion import models.config.TargetEnvironment._ import models.input.{InputTagId, InputValidator, ListItem, SearchInputId, SearchInputWithRules} import models.querqy.QuerqyRulesTxtGenerator +import models.reports.RulesUsageReport import models.rules.{DeleteRule, FilterRule, RedirectRule, SynonymRule, UpDownRule} import models.spellings.{CanonicalSpellingId, CanonicalSpellingValidator, CanonicalSpellingWithAlternatives} import org.pac4j.core.profile.{ProfileManager, UserProfile} import org.pac4j.play.PlayWebContext import org.pac4j.play.scala.{Security, SecurityComponents} import play.api.libs.Files -import services.{RulesTxtDeploymentService, RulesTxtImportService} +import services.{RulesTxtDeploymentService, RulesTxtImportService, RulesUsageService} // TODO Make ApiController pure REST- / JSON-Controller to ensure all implicit Framework responses (e.g. 400, 500) conformity @@ -37,7 +38,8 @@ class ApiController @Inject()(val controllerComponents: SecurityComponents, querqyRulesTxtGenerator: QuerqyRulesTxtGenerator, rulesTxtDeploymentService: RulesTxtDeploymentService, rulesTxtImportService: RulesTxtImportService, - targetEnvironmentConfigService: TargetEnvironmentConfigService) + targetEnvironmentConfigService: TargetEnvironmentConfigService, + rulesUsageService: RulesUsageService) (implicit executionContext: ExecutionContext) extends Security[UserProfile] with play.api.i18n.I18nSupport with Logging { @@ -667,6 +669,16 @@ class ApiController @Inject()(val controllerComponents: SecurityComponents, } } + def getRulesUsageReport(solrIndexId: String): Action[AnyContent] = Action { + rulesUsageService.getRulesUsageStatistics.map { ruleUsageStatistics => + val allSearchInputs = searchManagementRepository.listAllSearchInputsInclDirectedSynonyms(SolrIndexId(solrIndexId)) + val report = RulesUsageReport.create(allSearchInputs, ruleUsageStatistics) + Ok(Json.toJson(report)) + }.getOrElse( + NoContent + ) + } + private def lookupUserInfo(request: Request[AnyContent]) = { val maybeUserId = getProfiles(request).headOption.map(_.getId) logger.debug(s"Current user: $maybeUserId") diff --git a/app/models/FeatureToggleModel.scala b/app/models/FeatureToggleModel.scala index 48c14ef9..8545ff2a 100644 --- a/app/models/FeatureToggleModel.scala +++ b/app/models/FeatureToggleModel.scala @@ -7,6 +7,7 @@ import play.twirl.api.utils.StringEscapeUtils import scala.util.Try import models.rules.UpDownRule +import services.RulesUsageService // TODO refactor FeatureToggleModel (and FeatureToggleService) to config package (for being in sync with Spec structure) package object FeatureToggleModel extends Logging { @@ -65,6 +66,7 @@ package object FeatureToggleModel extends Logging { private val FEATURE_CUSTOM_UP_DOWN_MAPPINGS = "toggle.ui-concept.custom.up-down-dropdown-mappings" private val SMUI_DEPLOYMENT_GIT_REPO_URL = "smui.deployment.git.repo-url" private val SMUI_DEPLOYMENT_GIT_FN_COMMON_RULES_TXT = "smui2solr.deployment.git.filename.common-rules-txt" + private val FEATURE_TOGGLE_REPORT_RULE_USAGE_STATISTICS = "toggle.report.rule-usage-statistics" /** * helper for custom UP/DOWN mappings @@ -166,7 +168,8 @@ package object FeatureToggleModel extends Logging { JsFeatureToggle(FEATURE_TOGGLE_DEPLOYMENT_LABEL, new JsStringFeatureToggleValue( appConfig.getOptional[String](FEATURE_TOGGLE_DEPLOYMENT_LABEL).getOrElse("LIVE"))), JsFeatureToggle(FEATURE_TOGGLE_DEPLOYMENT_PRELIVE_LABEL, new JsStringFeatureToggleValue( - appConfig.getOptional[String](FEATURE_TOGGLE_DEPLOYMENT_PRELIVE_LABEL).getOrElse("PRELIVE"))) + appConfig.getOptional[String](FEATURE_TOGGLE_DEPLOYMENT_PRELIVE_LABEL).getOrElse("PRELIVE"))), + jsBoolFeatureToggle(FEATURE_TOGGLE_REPORT_RULE_USAGE_STATISTICS, hasRulesUsageStatisticsLocationConfigured) ) } @@ -230,6 +233,10 @@ package object FeatureToggleModel extends Logging { appConfig.getOptional[String](FEATURE_TOGGLE_DEPLOYMENT_PRELIVE_LABEL).getOrElse("PRELIVE") } + def hasRulesUsageStatisticsLocationConfigured: Boolean = { + appConfig.getOptional[String](RulesUsageService.ConfigKeyRuleUsageStatistics).nonEmpty + } + } } diff --git a/app/models/reports/RulesUsageReport.scala b/app/models/reports/RulesUsageReport.scala new file mode 100644 index 00000000..4e1906bc --- /dev/null +++ b/app/models/reports/RulesUsageReport.scala @@ -0,0 +1,56 @@ +package models.reports + +import models.input.{SearchInput, SearchInputId, SearchInputWithRules} +import play.api.Logging +import play.api.libs.json.{Json, OFormat} +import services.RulesUsage + +case class RulesUsageReportEntry( + // the search input ID from the report + searchInputId: String, + // the stored search input term if it could be found in the DB + searchInputTerm: Option[String], + // the user keywords/query that the search input was triggered on + keywords: String, + // the frequency that rule was triggered for the particular keywords + frequency: Int +) + +case class RulesUsageReport(items: Seq[RulesUsageReportEntry]) + +object RulesUsageReport extends Logging { + + implicit val jsonFormatRulesUsageReportEntry: OFormat[RulesUsageReportEntry] = Json.format[RulesUsageReportEntry] + implicit val jsonFormatRulesUsageReport: OFormat[RulesUsageReport] = Json.format[RulesUsageReport] + + def create(searchInputs: Seq[SearchInputWithRules], rulesUsageStatistics: Seq[RulesUsage]): RulesUsageReport = { + // perform a "full outer join" of the rules usage with the existing search inputs + val searchInputsById = searchInputs.map(searchInput => searchInput.id.id -> searchInput).toMap + val searchInputIdsFromAnalytics = rulesUsageStatistics.map(_.inputId.id).toSet + val searchInputIdsNotFound = searchInputIdsFromAnalytics -- searchInputsById.keySet + val searchInputIdsNotUsed = searchInputsById.keySet -- searchInputIdsFromAnalytics + logger.info(s"Creating report from ${searchInputIdsFromAnalytics.size} used search inputs" + + s" and ${searchInputsById.size} search inputs currently configured" + + s" with ${searchInputIdsNotFound.size} search inputs not found" + + s" and ${searchInputIdsNotUsed.size} search inputs not used") + + val reportEntriesUsedSearchInputs = rulesUsageStatistics.map { rulesUsage: RulesUsage => + RulesUsageReportEntry( + rulesUsage.inputId.id, + searchInputsById.get(rulesUsage.inputId.id).map(_.term), + rulesUsage.keywords, + rulesUsage.frequency + ) + } + val reportEntriesUnusedSearchInputs = searchInputIdsNotUsed.map { searchInputId => + RulesUsageReportEntry( + searchInputId, + searchInputsById.get(searchInputId).map(_.term), + "", + 0 + ) + } + RulesUsageReport(reportEntriesUsedSearchInputs ++ reportEntriesUnusedSearchInputs) + } + +} diff --git a/app/services/ReaderProvider.scala b/app/services/ReaderProvider.scala new file mode 100644 index 00000000..84d8aa5f --- /dev/null +++ b/app/services/ReaderProvider.scala @@ -0,0 +1,50 @@ +package services + +import com.google.cloud.storage.{BlobId, StorageOptions} + +import java.io.{InputStreamReader, Reader} +import java.net.URI +import java.nio.channels.Channels +import java.nio.charset.StandardCharsets +import java.nio.file.{Files, Paths} + +trait ReaderProvider { + def openReader(url: String): Reader +} + +class FileSystemReaderProvider extends ReaderProvider { + override def openReader(url: String): Reader = { + val uri = if (url.startsWith("file://")) url else "file://" + url + val path = Paths.get(new URI(uri)) + Files.newBufferedReader(path) + } +} + +class GcsReaderProvider extends ReaderProvider { + + override def openReader(url: String): Reader = { + val storage = StorageOptions.getDefaultInstance.getService + val blob = storage.get(BlobId.fromGsUtilUri(url)) + val readChannel = blob.reader() + new InputStreamReader(Channels.newInputStream(readChannel), StandardCharsets.UTF_8) + } +} + +class ReaderProviderDispatcher extends ReaderProvider { + + private val fileSystemReaderProvider = new FileSystemReaderProvider() + private lazy val gcsReaderProvider: GcsReaderProvider = { + new GcsReaderProvider() + } + + override def openReader(url: String): Reader = { + val readerProvider = url.toLowerCase.trim match { + case url if url.startsWith("gs://") => gcsReaderProvider + case url if url.startsWith("file://") => fileSystemReaderProvider + case url if Files.exists(Paths.get(url)) => fileSystemReaderProvider + case _ => throw new IllegalArgumentException(s"Unsupported URL scheme or file not found: ${url}") + } + readerProvider.openReader(url) + } + +} diff --git a/app/services/RulesUsageService.scala b/app/services/RulesUsageService.scala new file mode 100644 index 00000000..be5ca69b --- /dev/null +++ b/app/services/RulesUsageService.scala @@ -0,0 +1,49 @@ +package services + +import models.input.SearchInputId +import org.apache.commons.csv.CSVFormat +import play.api.{Configuration, Logging} + +import javax.inject.Inject +import scala.collection.JavaConverters.iterableAsScalaIterableConverter + +case class RulesUsage(inputId: SearchInputId, + keywords: String, + frequency: Int) + +class RulesUsageService @Inject()(configuration: Configuration, + readerProvider: ReaderProviderDispatcher) extends Logging { + + private val CsvFormat = CSVFormat.DEFAULT.builder().setHeader().setSkipHeaderRecord(true).build() + + def getRulesUsageStatistics: Option[Seq[RulesUsage]] = { + configuration.getOptional[String](RulesUsageService.ConfigKeyRuleUsageStatistics) + .map { location => + logger.info(s"Loading rule usage statistics from ${location}") + try { + val reader = readerProvider.openReader(location) + try { + CsvFormat.parse(reader).asScala.map { record => + RulesUsage( + SearchInputId(record.get("SMUI_GUID")), + record.get("USER_QUERY"), + record.get("FREQUENCY").toInt) + }.toSeq + } finally { + reader.close() + } + } catch { + case e: Exception => + logger.error("Could not load rule usage statistics", e) + Seq.empty + } + } + } + +} + +object RulesUsageService { + + val ConfigKeyRuleUsageStatistics = "smui.rule-usage-statistics.location" + +} diff --git a/build.sbt b/build.sbt index a2459cc9..3e8a771f 100644 --- a/build.sbt +++ b/build.sbt @@ -1,7 +1,7 @@ import com.typesafe.sbt.GitBranchPrompt name := "search-management-ui" -version := "4.0.11" +version := "4.1.0" maintainer := "Contact productful.io " scalaVersion := "2.12.17" @@ -67,10 +67,12 @@ libraryDependencies ++= { "com.typesafe.play" %% "play-json" % "2.9.3", "com.pauldijou" %% "jwt-play" % "4.1.0", "com.fasterxml.jackson.module" %% "jackson-module-scala" % JacksonVersion, + "org.apache.commons" % "commons-csv" % "1.10.0", "org.apache.shiro" % "shiro-core" % "1.12.0", "org.pac4j" % "pac4j-http" % Pac4jVersion excludeAll (JacksonCoreExclusion, BcProv15Exclusion, SpringJclBridgeExclusion), "org.pac4j" % "pac4j-saml" % Pac4jVersion excludeAll (JacksonCoreExclusion, BcProv15Exclusion, SpringJclBridgeExclusion), "org.pac4j" %% "play-pac4j" % "11.1.0-PLAY2.8", + "com.google.cloud" % "google-cloud-storage" % "2.33.0", "org.scalatestplus.play" %% "scalatestplus-play" % "3.1.0" % Test, "org.mockito" % "mockito-all" % "1.10.19" % Test, "com.pauldijou" %% "jwt-play" % "4.1.0", diff --git a/conf/application.conf b/conf/application.conf index d5ecd582..f8dffc59 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -168,6 +168,10 @@ database.dispatcher { } } +smui.rule-usage-statistics { + # Set a file:// or gs:// URL to a CSV file to load rule usage statistics for the rule usage report + location = ${?SMUI_RULE_USAGE_STATISTICS_LOCATION} +} # Enable security module/filter play.modules.enabled += "modules.SecurityModule" diff --git a/conf/routes b/conf/routes index 19fc2ded..3ac9fa9b 100644 --- a/conf/routes +++ b/conf/routes @@ -44,6 +44,7 @@ GET /api/v1/report/activity-report/:solrIndexId controllers.ApiC GET /api/v1/version/latest-info controllers.ApiController.getLatestVersionInfo() GET /api/v2/log/deployment-info controllers.ApiController.getLatestDeploymentResult(solrIndexId: String) GET /api/v1/config/target-environment controllers.ApiController.getTargetEnvironment() +GET /api/v1/report/rules-usage-report/:solrIndexId controllers.ApiController.getRulesUsageReport(solrIndexId: String) # Map static resources from the /public folder to the /assets URL path GET /*file controllers.FrontendController.assetOrDefault(file) diff --git a/frontend/angular.json b/frontend/angular.json index a07c7bf8..9a94d568 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -28,9 +28,13 @@ ], "styles": [ "node_modules/bootstrap/dist/css/bootstrap.min.css", + "node_modules/datatables.net-dt/css/dataTables.dataTables.css", "src/styles.css" ], - "scripts": [] + "scripts": [ + "node_modules/jquery/dist/jquery.js", + "node_modules/datatables.net/js/dataTables.js" + ] }, "configurations": { "production": { @@ -50,8 +54,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "500kb", - "maximumError": "1mb" + "maximumWarning": "1mb", + "maximumError": "2mb" }, { "type": "anyComponentStyle", diff --git a/frontend/package.json b/frontend/package.json index b0e9bd78..a288df16 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,35 +13,41 @@ }, "private": true, "dependencies": { - "@angular/animations": "~11.0.1", - "@angular/common": "~11.0.1", - "@angular/compiler": "~11.0.1", - "@angular/core": "~11.0.1", - "@angular/forms": "~11.0.1", - "@angular/localize": "~11.0.1", - "@angular/platform-browser": "~11.0.1", - "@angular/platform-browser-dynamic": "~11.0.1", - "@angular/router": "~11.0.1", + "@angular/animations": "~13.4.0", + "@angular/common": "~13.4.0", + "@angular/compiler": "~13.4.0", + "@angular/core": "~13.4.0", + "@angular/forms": "~13.4.0", + "@angular/localize": "~13.4.0", + "@angular/platform-browser": "~13.4.0", + "@angular/platform-browser-dynamic": "~13.4.0", + "@angular/router": "~13.4.0", "@fortawesome/fontawesome-free": "^5.15.1", "@ng-bootstrap/ng-bootstrap": "^8.0.0", - "angular2-multiselect-dropdown": "^4.6.6", + "angular-datatables": "^13.1.0", + "angular2-multiselect-dropdown": "^5.0.4", "angular2-toaster": "^11.0.1", "bootstrap": "^4.5.0", + "datatables.net": "~2.0.3", + "datatables.net-dt": "~2.0.3", + "jquery": "~3.7.1", "rxjs": "~6.6.0", - "tslib": "^2.0.0", + "tslib": "^2.6.2", "zone.js": "~0.10.2" }, "devDependencies": { - "@angular-devkit/build-angular": "~0.1100.2", - "@angular-eslint/builder": "1.1.0", - "@angular-eslint/eslint-plugin": "1.1.0", - "@angular-eslint/eslint-plugin-template": "1.1.0", - "@angular-eslint/schematics": "1.1.0", - "@angular-eslint/template-parser": "1.1.0", - "@angular/cli": "~11.0.2", - "@angular/compiler-cli": "~11.0.1", + "@angular-devkit/build-angular": "13.3.11", + "@angular-eslint/builder": "13.4.0", + "@angular-eslint/eslint-plugin": "13.4.0", + "@angular-eslint/eslint-plugin-template": "13.4.0", + "@angular-eslint/schematics": "13.4.0", + "@angular-eslint/template-parser": "13.4.0", + "@angular/cli": "13.3.11", + "@angular/compiler-cli": "13.3.11", + "@types/datatables.net": "1.10.21", "@types/jasmine": "~3.6.0", "@types/jasminewd2": "~2.0.8", + "@types/jquery": "~3.5.29", "@types/node": "^12.11.1", "@typescript-eslint/eslint-plugin": "4.3.0", "@typescript-eslint/parser": "4.3.0", @@ -59,6 +65,6 @@ "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.5.0", "ts-node": "~8.3.0", - "typescript": "~4.0.2" + "typescript": "~4.6.4" } } diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 7c7b44d9..ced79fd5 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -8,6 +8,7 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { ToasterModule } from 'angular2-toaster'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { AngularMultiSelectModule } from 'angular2-multiselect-dropdown'; +import { DataTablesModule } from "angular-datatables"; import { httpInterceptorProviders } from './interceptors'; // services @@ -71,7 +72,8 @@ import { ToasterModule, BrowserAnimationsModule, AngularMultiSelectModule, - NgbModule + NgbModule, + DataTablesModule ], declarations: [ AppComponent, diff --git a/frontend/src/app/components/modal/modal-confirm.component.ts b/frontend/src/app/components/modal/modal-confirm.component.ts index f23c96c7..52c4849f 100644 --- a/frontend/src/app/components/modal/modal-confirm.component.ts +++ b/frontend/src/app/components/modal/modal-confirm.component.ts @@ -12,7 +12,7 @@ import { NgbModalOptions } from '@ng-bootstrap/ng-bootstrap/modal/modal-config'; class Deferred { promise: Promise; - resolve: (value?: T | PromiseLike) => void; + resolve: (value: T | PromiseLike) => void; reject: (reason?: any) => void; constructor() { diff --git a/frontend/src/app/components/modal/modal-copy.component.ts b/frontend/src/app/components/modal/modal-copy.component.ts index 46314f69..25348f03 100644 --- a/frontend/src/app/components/modal/modal-copy.component.ts +++ b/frontend/src/app/components/modal/modal-copy.component.ts @@ -13,7 +13,7 @@ import { NgbModalOptions } from '@ng-bootstrap/ng-bootstrap/modal/modal-config'; class Deferred { promise: Promise; - resolve: (value?: T | PromiseLike) => void; + resolve: (value: T | PromiseLike) => void; reject: (reason?: any) => void; constructor() { diff --git a/frontend/src/app/components/report/report-settingsbar/report-settingsbar.component.html b/frontend/src/app/components/report/report-settingsbar/report-settingsbar.component.html index 7733a981..7f1c2a3e 100644 --- a/frontend/src/app/components/report/report-settingsbar/report-settingsbar.component.html +++ b/frontend/src/app/components/report/report-settingsbar/report-settingsbar.component.html @@ -9,10 +9,10 @@ [(ngModel)]="configReport" > diff --git a/frontend/src/app/components/report/report-settingsbar/report-settingsbar.component.ts b/frontend/src/app/components/report/report-settingsbar/report-settingsbar.component.ts index d6451c30..b9ebebee 100644 --- a/frontend/src/app/components/report/report-settingsbar/report-settingsbar.component.ts +++ b/frontend/src/app/components/report/report-settingsbar/report-settingsbar.component.ts @@ -18,9 +18,10 @@ import { DeploymentDetailedInfo } from '../../../models'; -interface ReportOption { - [key: string]: ValueType; -} +// keys for selectint the different reports, aligned with URL partial of /report route in /smui/conf/routes +export const KEY_OLDEST_RULES_REPORT = 'rules-report'; +export const KEY_ACTIVITY_REPORT = 'activity-report'; +export const KEY_RULES_USAGE_REPORT = 'rules-usage-report'; @Component({ selector: 'app-smui-report-settingsbar', @@ -34,11 +35,11 @@ export class ReportSettingsBarComponent implements OnInit, OnChanges { @Output() changeReport: EventEmitter = new EventEmitter(); @Output() generateReport: EventEmitter = new EventEmitter(); - // TODO make more elegant in just one dict - reportSelectOptionModelKeys = ['rules-report', 'activity-report']; - // keys aligned with URL partial of /report route in /smui/conf/routes - reportSelectOptionModel: ReportOption = {}; - configReport: string = this.reportSelectOptionModelKeys[0]; + private readonly OLDEST_RULES_REPORT: [string, string] = [KEY_OLDEST_RULES_REPORT, 'Oldest rules (by last_updated date)'] + private readonly ACTIVITY_REPORT: [string, string] = [KEY_ACTIVITY_REPORT, 'Latest rule management activities'] + private readonly RULES_USAGE_REPORT: [string, string] = [KEY_RULES_USAGE_REPORT, 'Rules usage'] + + configReport: string = KEY_OLDEST_RULES_REPORT; configDateFrom?: string; configDateTo?: string; @@ -47,9 +48,14 @@ export class ReportSettingsBarComponent implements OnInit, OnChanges { public featureToggleService: FeatureToggleService, private toasterService: ToasterService, public deploymentDetailedInfoService: DeploymentDetailedInfoService - ) { - this.reportSelectOptionModel['rules-report'] = 'Oldest rules (by last_updated date)'; - this.reportSelectOptionModel['activity-report'] = 'Latest rule management activities'; + ) {} + + availableReports() { + return [ + this.OLDEST_RULES_REPORT, + this.ACTIVITY_REPORT, + ...(this.featureToggleService.getSyncToggleRuleUsageStatistics() ? [this.RULES_USAGE_REPORT] : []) + ] } ngOnInit() { @@ -119,7 +125,7 @@ export class ReportSettingsBarComponent implements OnInit, OnChanges { this.configDateFrom = this.dateToFrontendString( new Date(Date.parse(instanceDeplInfo.formattedDateTime)) ) - + } else { this.showErrorMsg('Error in clickSetFromDate :: deployInstance = "' + deployInstance + '" not found!') } diff --git a/frontend/src/app/components/report/report.component.css b/frontend/src/app/components/report/report.component.css new file mode 100644 index 00000000..382a64c5 --- /dev/null +++ b/frontend/src/app/components/report/report.component.css @@ -0,0 +1,9 @@ + +form.rule-usage-report-filters input { + margin-right: 0.5rem; +} + +form.rule-usage-report-filters label { + margin-right: 1rem; +} + diff --git a/frontend/src/app/components/report/report.component.html b/frontend/src/app/components/report/report.component.html index eecbce37..620a0d92 100644 --- a/frontend/src/app/components/report/report.component.html +++ b/frontend/src/app/components/report/report.component.html @@ -8,7 +8,7 @@
No report generated yet. Use panel above to select and generate a report. @@ -141,3 +141,77 @@
+ + +
+
+
+
+ +
+ + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
Search Management InputKeywordsFrequency
+ + {{ reportItem.searchInputTerm }} + + + + not found + + + {{ reportItem.searchInputId }} + + + {{ reportItem.keywords }} + + {{ reportItem.frequency }} +
+
+
+
+
diff --git a/frontend/src/app/components/report/report.component.ts b/frontend/src/app/components/report/report.component.ts index 71bf2091..ab92ba3c 100644 --- a/frontend/src/app/components/report/report.component.ts +++ b/frontend/src/app/components/report/report.component.ts @@ -3,10 +3,12 @@ import { ToasterService } from 'angular2-toaster'; import { ReportSettingsBarComponent } from './report-settingsbar/report-settingsbar.component'; import { ReportService, SolrService } from '../../services'; -import { ActivityReport, RulesReport } from '../../models'; +import {ActivityReport, RulesReport, RulesUsageReport} from '../../models'; +import {DataTableDirective} from "angular-datatables"; @Component({ selector: 'app-smui-report', + styleUrls: ['./report.component.css'], templateUrl: './report.component.html' }) export class ReportComponent implements OnInit { @@ -15,9 +17,22 @@ export class ReportComponent implements OnInit { @ViewChild('smuiReportSettingsBar') settingsBarComponent: ReportSettingsBarComponent; + // for now: datatable used for rule usage report + @ViewChild(DataTableDirective) + datatableElement: DataTableDirective; + generateBtnDisabled = false; activityReport?: ActivityReport; rulesReport?: RulesReport; + rulesUsageReport?: RulesUsageReport; + + // view/filter flags for the rule usage report + ruleUsageReportShowUsed: boolean = true; + ruleUsageReportShowUnused: boolean = true; + ruleUsageReportShowFound: boolean = true; + ruleUsageReportShowNotFound: boolean = true; + + dtOptions: DataTables.Settings = {}; constructor( private toasterService: ToasterService, @@ -81,6 +96,8 @@ export class ReportComponent implements OnInit { configDateFrom, configDateTo ); + } else if (configReport === 'rules-usage-report') { + this.getRulesUsageReport(this.currentSolrIndexId); } } } @@ -107,8 +124,75 @@ export class ReportComponent implements OnInit { .catch(error => this.showErrorMsg(error)); } + private getRulesUsageReport(solrIndexId: string) { + this.reportService + .getRulesUsageReport(solrIndexId) + .then(retReport => { + console.log(':: getRulesUsageReport :: retReport received'); + this.rulesUsageReport = retReport; + this.dtOptions = { + columnDefs: [ + { orderData: [0, 1, 2], targets: 0 }, + { orderData: [1, 0, 2], targets: 1 }, + { orderData: [2, 0, 1], targets: 2 } + ], + pageLength: 25 + } + $.fn['dataTable'].ext.search.push(this.filterRuleUsageReportByFlags); + console.log(`Length of search ext after push: ${$.fn['dataTable'].ext.search.length}`) + this.generateBtnDisabled = false; + }) + .catch(error => this.showErrorMsg(error)); + } + + filterRuleUsageReportByFlags = (settings: string, data: any[], index: number) => { + // filtering based on data tables String values + const isUsed = parseInt(data[2]) > 0; + const isFound: boolean = !data[0].trim().startsWith("not found"); + return ( + ((isUsed && this.ruleUsageReportShowUsed) || (!isUsed && this.ruleUsageReportShowUnused)) && + ((isFound && this.ruleUsageReportShowFound) || (!isFound && this.ruleUsageReportShowNotFound)) + ); + } + + ruleUsageReportFilter(state: 'used' | 'unused' | 'found' | 'notfound') { + if (state === "found") { + this.ruleUsageReportShowFound = !this.ruleUsageReportShowFound; + if (!this.ruleUsageReportShowFound && !this.ruleUsageReportShowNotFound) { + this.ruleUsageReportShowNotFound = true; + } + } else if (state === "notfound") { + this.ruleUsageReportShowNotFound = !this.ruleUsageReportShowNotFound; + if (!this.ruleUsageReportShowFound && !this.ruleUsageReportShowNotFound) { + this.ruleUsageReportShowFound = true; + } + } else if (state == "used") { + this.ruleUsageReportShowUsed = !this.ruleUsageReportShowUsed; + if (!this.ruleUsageReportShowUsed && !this.ruleUsageReportShowUnused) { + this.ruleUsageReportShowUnused = true; + } + } else if (state == "unused") { + this.ruleUsageReportShowUnused = !this.ruleUsageReportShowUnused; + if (!this.ruleUsageReportShowUsed && !this.ruleUsageReportShowUnused) { + this.ruleUsageReportShowUsed = true; + } + } + this.redrawRules(); + } + + redrawRules(): void { + this.datatableElement.dtInstance.then((dtInstance: DataTables.Api) => { + dtInstance.draw(); + }); + } + private resetReports() { this.activityReport = undefined; this.rulesReport = undefined; + this.rulesUsageReport = undefined; + if ($.fn['dataTable']) { + $.fn['dataTable'].ext.search.pop(); + console.log(`Length of search ext after pop: ${$.fn['dataTable'].ext.search.length}`) + } } } diff --git a/frontend/src/app/models/report.model.ts b/frontend/src/app/models/report.model.ts index 4bac8166..38bd28d1 100644 --- a/frontend/src/app/models/report.model.ts +++ b/frontend/src/app/models/report.model.ts @@ -26,3 +26,14 @@ export class ActivityReportEntry { export class ActivityReport { items: Array; } + +export class RulesUsageReportEntry { + searchInputId: string; + searchInputTerm?: string; + keywords: string; + frequency: number; +} + +export class RulesUsageReport { + items: Array; +} diff --git a/frontend/src/app/services/feature-toggle.service.ts b/frontend/src/app/services/feature-toggle.service.ts index fa4e4c84..b92d8c30 100644 --- a/frontend/src/app/services/feature-toggle.service.ts +++ b/frontend/src/app/services/feature-toggle.service.ts @@ -11,6 +11,7 @@ const FEATURE_ACTIVATE_EVENTHISTORY = 'toggle.activate-eventhistory'; const FEATURE_CUSTOM_UP_DOWN_MAPPINGS = 'toggle.ui-concept.custom.up-down-dropdown-mappings'; const FEATURE_TOGGLE_DEPLOYMENT_LABEL = "toggle.rule-deployment-label"; const FEATURE_TOGGLE_DEPLOYMENT_PRELIVE_LABEL = "toggle.deploy-prelive-fn-label"; +const FEATURE_TOGGLE_REPORT_RULE_USAGE_STATISTICS = "toggle.report.rule-usage-statistics" @Injectable({ @@ -98,4 +99,8 @@ export class FeatureToggleService { return this.getSync(s); } + getSyncToggleRuleUsageStatistics(): any { + return this.getSync(FEATURE_TOGGLE_REPORT_RULE_USAGE_STATISTICS); + } + } diff --git a/frontend/src/app/services/report.service.ts b/frontend/src/app/services/report.service.ts index 619e552f..dd524fca 100644 --- a/frontend/src/app/services/report.service.ts +++ b/frontend/src/app/services/report.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { RulesReport, ActivityReport } from '../models'; +import {RulesReport, ActivityReport, RulesUsageReport} from '../models'; @Injectable({ providedIn: 'root' @@ -29,4 +29,10 @@ export class ReportService { .get(this.baseUrl + '/report/activity-report/' + solrIndexId, options) .toPromise(); } + + getRulesUsageReport(solrIndexId: string): Promise { + return this.http + .get(this.baseUrl + '/report/rules-usage-report/' + solrIndexId) + .toPromise(); + } } diff --git a/frontend/src/rules-usage-report-datatable.css b/frontend/src/rules-usage-report-datatable.css new file mode 100644 index 00000000..9deae23b --- /dev/null +++ b/frontend/src/rules-usage-report-datatable.css @@ -0,0 +1,9 @@ +/* Styling the inner datatable elements for the rules usage report was not possible from within the component. */ + +.dt-length label { + margin-left: 10px; +} + +.dt-search label { + margin-right: 10px; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e0ff8ad0..8e0aeabc 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2,3 +2,4 @@ @import '~angular2-toaster/toaster.css'; @import '~angular2-multiselect-dropdown/themes/default.theme.css'; @import '~@fortawesome/fontawesome-free/css/all.css'; +@import './rules-usage-report-datatable.css';