diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index e1f0cd198a8..2f3e5fee7d4 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -6,7 +6,7 @@ name: CI
on:
# Triggers the workflow on push or pull request events but only for the master branch
pull_request:
- branches: [ master, next ]
+ branches: [ master, next, release.24.10 ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -302,7 +302,7 @@ jobs:
cd ui-tests
npm install
xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" \
- npm run cy:run:dashboard --headless --no-sandbox --disable-gpu --disable-dev-shm-usage
+ npm run cy:run:dashboard
- name: Upload UI tests artifacts
if: ${{ failure() }}
@@ -381,7 +381,7 @@ jobs:
cd ui-tests
npm install
xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" \
- npm run cy:run:onboarding --headless --no-sandbox --disable-gpu --disable-dev-shm-usage
+ npm run cy:run:onboarding
- name: Upload UI tests artifacts
if: ${{ failure() }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6c881958168..f6aedf66af5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,42 @@
+## Version 24.10.2
+Fixes:
+- [core] Correct aggregated collection cleanup on event omitting
+- [core] Fixed bug where changing passwords results in the loss of the "Global Admin" role
+- [core] Fixed bug where exporting incoming data logs could result in "Incorrect parameter \"data\" error
+- [core] Removed use of commands which needs admin rights from report manager.
+- [crash] Fixed bug in crash ingestion for scenarios where the "app version" is not a string.
+- [script] Fixing bug with "delete_old_members" script that led to malformed requests
+
+Enterprise fixes:
+- [nps] Fixed bug that showed the wrong nps preview title
+
+## Version 24.10.1
+Fixes:
+- [core] Replaced "Users" with "Sessions" label on technology home widgets
+- [push] Improved ability to observe push related errors
+- [push] Replaced push plugin with an earlier version of the plugin
+
+Enterprise fixes:
+- [cohorts] Fixed issues with nightly cleanup
+- [data-manager] Fixed UI bug where rules were not visible when editing "Merge by regex" transformations
+- [drill] Fixed wrong pie chart label tooltip in dashboard widget
+- [flows] Fixed bug in case of null data in schema
+- [license] Fixed bug with MAU type of licenses that would prevent the server from starting
+- [nps] Fixed bug in the editor where the "internal name" field was not mandatory
+- [nps] Fixed bug where it was possible to submit empty nps surveys
+- [ratings] Fixed bug with user consent
+- [ratings] Fixed UI bug where "Internal name" was not a mandatory field
+
+Security:
+- Bumped cookie-parser from 1.4.6 to 1.4.7
+- Bumped express-rate-limit from 7.4.0 to 7.4.1
+- Bumped moment-timezone from 0.5.45 to 0.5.46
+- Bumped sass from 1.79.3 to 1.79.4
+- Fixing minor vulnerability that would allow for unauthorized file upload
+
+Enterprise Features:
+- [block] Added a way to filter crashes by their error (stacktrace)
+
## Version 24.10
Fixes:
- [core] Interpreting carrier value of "--" as an unknown value
@@ -34,6 +73,7 @@ Enterprise Features:
## Version 24.05.15
Enterprise fixes:
+- [ab-testing] Fixed JSON.parse issue preventing creation of AB tests
- [nps] Fixed UI issues in the widget editor related to the "user consent" section
- [ratings] Fixed rendering issue for escaped values
diff --git a/api/api.js b/api/api.js
index 5f259134ff8..593030fe2ee 100644
--- a/api/api.js
+++ b/api/api.js
@@ -313,6 +313,7 @@ plugins.connectToAllDatabases().then(function() {
jobs.job('api:clearAutoTasks').replace().schedule('every 1 day');
jobs.job('api:task').replace().schedule('every 5 minutes');
jobs.job('api:userMerge').replace().schedule('every 10 minutes');
+ jobs.job("api:ttlCleanup").replace().schedule("every 1 minute");
//jobs.job('api:appExpire').replace().schedule('every 1 day');
}, 10000);
diff --git a/api/jobs/task.js b/api/jobs/task.js
index a106a4bcbf2..06b6893a7e7 100644
--- a/api/jobs/task.js
+++ b/api/jobs/task.js
@@ -56,6 +56,10 @@ class MonitorJob extends job.Job {
return true;
}
+ if (task.dirty) {
+ return true;
+ }
+
if ((now + duration - lastStart) / 1000 >= interval) {
return true;
}
@@ -74,7 +78,8 @@ class MonitorJob extends job.Job {
taskmanager.rerunTask({
db: common.db,
id: task._id,
- autoUpdate: true
+ autoUpdate: true,
+ dirty: task.dirty
}, function(e) {
if (e) {
log.e(e, e.stack);
diff --git a/api/jobs/ttlCleanup.js b/api/jobs/ttlCleanup.js
new file mode 100644
index 00000000000..1c168d38b21
--- /dev/null
+++ b/api/jobs/ttlCleanup.js
@@ -0,0 +1,48 @@
+const plugins = require("../../plugins/pluginManager.js");
+const common = require('../utils/common');
+const job = require("../parts/jobs/job.js");
+const log = require("../utils/log.js")("job:ttlCleanup");
+
+/**
+ * Class for job of cleaning expired records inside ttl collections
+ */
+class TTLCleanup extends job.Job {
+ /**
+ * Run the job
+ */
+ async run() {
+ log.d("Started running TTL clean up job");
+ for (let i = 0; i < plugins.ttlCollections.length; i++) {
+ const {
+ db = "countly",
+ collection,
+ property,
+ expireAfterSeconds = 0
+ } = plugins.ttlCollections[i];
+ let dbInstance;
+ switch (db) {
+ case "countly": dbInstance = common.db; break;
+ case "countly_drill": dbInstance = common.drillDb; break;
+ case "countly_out": dbInstance = common.outDb; break;
+ }
+ if (!dbInstance) {
+ log.e("Invalid db selection:", db);
+ continue;
+ }
+
+ log.d("Started cleaning up", collection);
+ const result = await dbInstance.collection(collection).deleteMany({
+ [property]: {
+ $lte: new Date(Date.now() - expireAfterSeconds * 1000)
+ }
+ });
+ log.d("Finished cleaning up", result.deletedCount, "records from", collection);
+
+ // Sleep 1 second to prevent sending too many deleteMany queries
+ await new Promise(res => setTimeout(res, 1000));
+ }
+ log.d("Finished running TTL clean up job");
+ }
+}
+
+module.exports = TTLCleanup;
\ No newline at end of file
diff --git a/api/parts/data/fetch.js b/api/parts/data/fetch.js
index c94f20bb422..210c277571a 100644
--- a/api/parts/data/fetch.js
+++ b/api/parts/data/fetch.js
@@ -1323,7 +1323,7 @@ fetch.fetchEvents = function(params) {
*/
fetch.fetchTimeObj = function(collection, params, isCustomEvent, options) {
fetchTimeObj(collection, params, isCustomEvent, options, function(output) {
- if (params?.qstring?.event) {
+ if (params.qstring?.event) {
output.eventName = params.qstring.event;
}
common.returnOutput(params, output);
diff --git a/api/parts/jobs/README.md b/api/parts/jobs/README.md
new file mode 100644
index 00000000000..e8329172151
--- /dev/null
+++ b/api/parts/jobs/README.md
@@ -0,0 +1,650 @@
+# Jobs Feature Documentation
+
+## Table of Contents
+1. [Overview](#overview)
+2. [System Architecture](#system-architecture)
+3. [Flowcharts](#flowcharts)
+ - [Job Lifecycle](#job-lifecycle-flowchart)
+ - [Dependency Chart](#dependency-chart)
+ - [IPC Communication](#ipc-communication-flow)
+ - [Retry Logic](#retry-logic-flowchart)
+ - [Job Creation and Scheduling](#job-creation-and-scheduling-flowchart)
+4. [Key Components](#key-components)
+5. [Job Lifecycle](#job-lifecycle)
+6. [IPC Communication](#ipc-communication)
+7. [Retry Mechanism](#retry-mechanism)
+8. [Usage Guide](#usage-guide)
+9. [Implementation Guide](#implementation-guide)
+10. [File Breakdown](#file-breakdown)
+11. [Configuration](#configuration)
+12. [Technical Implementation of Scheduling](#technical-implementation-of-scheduling)
+13. [Best Practices](#best-practices)
+
+## Overview
+
+The Jobs feature is a robust system designed to handle asynchronous, potentially long-running tasks in a distributed environment. It supports job scheduling, execution, inter-process communication, and automatic retries. The system is built to be scalable and fault-tolerant, capable of running jobs across multiple processes and servers.
+
+Key features include:
+- Flexible job scheduling (immediate, delayed, or recurring)
+- Inter-process communication for resource-intensive jobs
+- Automatic retry mechanism with customizable policies
+- Resource management for efficient job execution
+- Scalable architecture supporting distributed environments
+
+## System Architecture
+
+The Jobs system consists of several interconnected components:
+
+- **Manager**: Oversees job execution and resource management
+- **Handle**: Provides API for job creation and scheduling
+- **Job**: Base class for all job types
+- **Resource**: Manages resources for IPC jobs
+- **RetryPolicy**: Defines retry behavior for failed jobs
+- **IPC**: Handles inter-process communication
+
+These components work together to create, schedule, execute, and monitor jobs across the system.
+
+## Flowcharts
+
+### Job Lifecycle Flowchart
+
+```mermaid
+graph TD
+ A[Start] --> B{Is it Master Process?}
+ B -->|Yes| C[Initialize Manager]
+ B -->|No| D[Initialize Handle]
+ C --> E[Scan for job types]
+ E --> F[Monitor jobs collection]
+ D --> G[Wait for job requests]
+ F --> H{New job to run?}
+ H -->|Yes| I[Create job instance]
+ H -->|No| F
+ I --> J{Is it an IPC job?}
+ J -->|Yes| K[Create ResourceFaçade]
+ J -->|No| L[Run locally]
+ K --> M[Fork executor.js]
+ M --> N[Initialize Resource]
+ N --> O[Run job in Resource]
+ L --> P[Run job in current process]
+ O --> Q[Job completes]
+ P --> Q
+ Q --> R[Update job status]
+ R --> S{Retry needed?}
+ S -->|Yes| T[Delay and retry]
+ S -->|No| F
+ T --> I
+ G --> U{Job request received?}
+ U -->|Yes| V[Create job]
+ U -->|No| G
+ V --> W[Schedule or run job]
+ W --> X[Send to Manager if needed]
+ X --> G
+```
+
+### Dependency Chart
+
+```mermaid
+graph TD
+ A[index.js] --> B[manager.js]
+ A --> C[handle.js]
+ B --> D[job.js]
+ B --> E[resource.js]
+ B --> F[ipc.js]
+ B --> G[retry.js]
+ C --> D
+ C --> F
+ D --> G
+ E --> F
+ E --> D
+ H[executor.js] --> D
+ H --> E
+ H --> F
+```
+
+### IPC Communication Flow
+
+```mermaid
+sequenceDiagram
+ participant MP as Main Process
+ participant CP as Child Process
+ participant R as Resource
+
+ MP->>CP: Fork child process
+ Note over MP,CP: Child process starts
+
+ MP->>CP: CMD.RUN (Job data)
+ CP->>R: Initialize Resource
+ R-->>CP: Resource ready
+ CP-->>MP: EVT.UPDATE (Resource initialized)
+
+ loop Job Execution
+ CP->>R: Execute job step
+ R-->>CP: Step result
+ CP-->>MP: EVT.UPDATE (Progress)
+ end
+
+ alt Job Completed Successfully
+ CP-->>MP: CMD.DONE (Result)
+ else Job Failed
+ CP-->>MP: CMD.DONE (Error)
+ end
+
+ MP->>CP: CMD.ABORT (optional)
+ CP->>R: Abort job
+ R-->>CP: Job aborted
+ CP-->>MP: CMD.DONE (Aborted)
+
+ CP->>R: Close resource
+ R-->>CP: Resource closed
+ CP-->>MP: EVT.UPDATE (Resource closed)
+
+ MP->>CP: Terminate child process
+ Note over MP,CP: Child process ends
+```
+
+### Retry Logic Flowchart
+
+```mermaid
+graph TD
+ A[Job Fails] --> B{Retry Policy Check}
+ B -->|Retry Allowed| C[Delay]
+ B -->|No More Retries| D[Mark Job as Failed]
+ C --> E[Increment Retry Count]
+ E --> F[Reschedule Job]
+ F --> G[Job Runs Again]
+ G --> H{Job Succeeds?}
+ H -->|Yes| I[Mark Job as Completed]
+ H -->|No| B
+```
+
+### Job Creation and Scheduling Flowchart
+
+```mermaid
+graph TD
+ A[Start] --> B[Create Job Instance]
+ B --> C{Schedule Type}
+ C -->|Now| D[Run Immediately]
+ C -->|Once| E[Schedule for Specific Time]
+ C -->|In| F[Schedule After Delay]
+ C -->|Custom| G[Set Custom Schedule]
+ D --> H[Save Job to Database]
+ E --> H
+ F --> H
+ G --> H
+ H --> I[Job Ready for Execution]
+```
+
+## Key Components
+
+### Manager (manager.js)
+The Manager is responsible for overseeing job execution and resource management. It monitors the jobs collection, creates job instances, and delegates execution to the appropriate process.
+
+### Handle (handle.js)
+The Handle provides an API for job creation and scheduling. It's the entry point for creating new jobs and defining their execution parameters.
+
+### Job (job.js)
+The Job class is the base class for all job types. It defines the structure and basic behavior of a job, including methods for running, aborting, and updating status.
+
+### Resource (resource.js)
+The Resource class manages resources for IPC jobs. It handles the lifecycle of resources needed for job execution in separate processes.
+
+### RetryPolicy (retry.js)
+The RetryPolicy defines the behavior for retrying failed jobs. It determines if and when a job should be retried based on its failure characteristics.
+
+### IPC (ipc.js)
+The IPC module facilitates communication between the main process and child processes running IPC jobs. It provides a robust messaging system for exchanging data, commands, and status updates.
+
+### Executor (executor.js)
+The Executor is responsible for setting up and running IPC jobs in separate processes. It initializes the necessary resources and communication channels, executes the job, and reports results back to the main process.
+
+## Job Lifecycle
+
+1. **Creation**: Jobs are created using the Handle API.
+2. **Scheduling**: Jobs can be scheduled to run immediately, at a specific time, or on a recurring basis.
+3. **Execution**: The Manager picks up scheduled jobs and executes them, either locally or in a separate process for IPC jobs.
+4. **Monitoring**: Job progress is monitored and status is updated in the database.
+5. **Completion/Failure**: Upon completion or failure, the job status is updated, and the retry policy is consulted if necessary.
+6. **Retry**: If required and allowed by the retry policy, failed jobs are rescheduled for another attempt.
+7. **Cleanup**: After job completion (successful or failed), any associated resources are cleaned up, and the job's final status is recorded.
+
+## IPC Communication
+
+Inter-Process Communication (IPC) is used for jobs that need to run in separate processes. The system uses a custom IPC module (ipc.js) to facilitate communication between the main process and job processes.
+
+Key aspects of IPC:
+- **Channel Creation**: Each IPC job has a unique channel for communication.
+- **Message Passing**: The main process and job processes exchange messages for status updates, commands, and results.
+- **Resource Management**: IPC is used to manage the lifecycle of resources in separate processes.
+
+The IPC system uses a message-based protocol with the following key message types:
+- `CMD.RUN`: Instructs a child process to start running a job
+- `CMD.ABORT`: Signals a job to abort its execution
+- `CMD.DONE`: Indicates that a job has completed (successfully or with an error)
+- `EVT.UPDATE`: Provides progress updates from the job to the main process
+
+Example of sending an IPC message:
+
+```javascript
+channel.send(CMD.RUN, jobData);
+```
+
+Example of receiving an IPC message:
+
+```javascript
+channel.on(CMD.DONE, (result) => {
+ // Handle job completion
+});
+```
+
+## Retry Mechanism
+
+The retry mechanism is implemented through the RetryPolicy class. It defines:
+
+- **Number of Retries**: How many times a job should be retried.
+- **Delay**: The delay between retries, which can be fixed or increasing.
+- **Error Handling**: Which types of errors should trigger a retry.
+
+Different job types can have custom retry policies tailored to their specific needs.
+
+Example of a custom retry policy:
+
+```javascript
+class CustomRetryPolicy extends RetryPolicy {
+ constructor(maxRetries, initialDelay) {
+ super();
+ this.maxRetries = maxRetries;
+ this.initialDelay = initialDelay;
+ }
+
+ shouldRetry(attempt, error) {
+ return attempt < this.maxRetries && this.isRetriableError(error);
+ }
+
+ getDelay(attempt) {
+ return this.initialDelay * Math.pow(2, attempt); // Exponential backoff
+ }
+
+ isRetriableError(error) {
+ // Define which errors should trigger a retry
+ return error.code === 'NETWORK_ERROR' || error.code === 'RESOURCE_UNAVAILABLE';
+ }
+}
+```
+
+## Usage Guide
+
+This section provides examples and explanations for using the Jobs system, including all supported inputs and scheduling types.
+
+### Creating a Job
+
+To create a job, use the `job()` method from the Handle API:
+
+```javascript
+const jobHandle = require('./path/to/handle');
+
+const myJob = jobHandle.job('myJobType', {
+ // job data
+ param1: 'value1',
+ param2: 'value2'
+});
+```
+
+Parameters:
+- `jobType` (string): The type of job to create. This should match a registered job type in the system.
+- `jobData` (object): An object containing any data needed for the job execution.
+
+### Scheduling a Job
+
+After creating a job, you can schedule it using one of the following methods:
+
+#### Run Immediately
+
+```javascript
+myJob.now();
+```
+
+This schedules the job to run as soon as possible.
+
+#### Run Once at a Specific Time
+
+```javascript
+myJob.once(new Date('2023-12-31T23:59:59'));
+```
+
+This schedules the job to run once at the specified date and time.
+
+#### Run After a Delay
+
+```javascript
+myJob.in(3600); // Run after 1 hour
+```
+
+This schedules the job to run after the specified number of seconds.
+
+#### Custom Schedule
+
+The `schedule` method is highly flexible and accepts various types of inputs:
+It uses ```later.js``` to support this
+
+1. Cron Syntax
+ ```javascript
+ myJob.schedule('0 0 * * *'); // Run at midnight every day
+ ```
+
+2. Natural Language
+ ```javascript
+ myJob.schedule('every 5 minutes');
+ myJob.schedule('at 10:15 am every weekday');
+ ```
+
+3. Object Literal
+ ```javascript
+ myJob.schedule({
+ h: [10, 14, 18], // Run at 10am, 2pm, and 6pm
+ dw: [1, 3, 5] // On Monday, Wednesday, and Friday
+ });
+ ```
+
+4. Array of Schedules
+ ```javascript
+ myJob.schedule([
+ '0 0 * * *', // At midnight
+ 'every weekday at 9am' // Every weekday at 9am
+ ]);
+ ```
+
+5. Custom Schedule Functions
+ ```javascript
+ myJob.schedule(function() {
+ return Date.now() + 60000; // Run 1 minute from now
+ });
+ ```
+
+6. Predefined Schedule Constants (if supported by your implementation)
+ ```javascript
+ myJob.schedule(SCHEDULE.EVERY_HOUR);
+ ```
+
+
+This sets a custom schedule for the job using a cron-like syntax. The example above schedules the job to run every 5 minutes.
+
+### Job Types
+
+Different job types can be implemented by extending the base Job class. Here's an example of a custom job type:
+
+```javascript
+const { Job } = require('./path/to/job');
+
+class MyCustomJob extends Job {
+ async run(db, done, progress) {
+ // Job logic goes here
+ // Use 'db' for database operations
+ // Call 'progress()' to report progress
+ // Call 'done()' when the job is complete
+ }
+}
+
+module.exports = MyCustomJob;
+```
+
+### Handling Job Results
+
+Job results and errors are typically handled in the job implementation. However, for immediate feedback, you can chain promises:
+
+```javascript
+myJob.now().then((result) => {
+ console.log('Job completed successfully:', result);
+}).catch((error) => {
+ console.error('Job failed:', error);
+});
+```
+
+Remember that for long-running jobs, it's better to implement result handling within the job itself, possibly by updating a database or triggering a callback.
+
+## Implementation Guide
+
+To implement a new job type:
+
+1. Create a new job class that extends the base Job class.
+2. Implement the `run` method to define the job's main logic.
+3. Define a retry policy if needed.
+4. Register the job type in the system (usually done automatically by the scanner).
+5. Use the Handle API to create and schedule instances of your job.
+
+Example:
+
+```javascript
+const { Job } = require('./job.js');
+const { DefaultRetryPolicy } = require('./retry.js');
+
+class MyCustomJob extends Job {
+ constructor(name, data) {
+ super(name, data);
+ }
+
+ async run(db, done, progress) {
+ // Implement job logic here
+ // Use 'db' for database operations
+ // Call 'done()' when finished
+ // Use 'progress()' to report progress
+ }
+
+ retryPolicy() {
+ return new DefaultRetryPolicy(3); // Retry up to 3 times
+ }
+}
+
+module.exports = MyCustomJob;
+```
+
+## File Breakdown
+
+### index.js
+Entry point that determines whether to load the manager or handle based on the process type.
+
+```javascript
+const countlyConfig = require('./../../config', 'dont-enclose');
+
+if (require('cluster').isMaster && process.argv[1].endsWith('api/api.js') && !(countlyConfig && countlyConfig.preventJobs)) {
+ module.exports = require('./manager.js');
+} else {
+ module.exports = require('./handle.js');
+}
+```
+
+### manager.js
+Implements the Manager class, which oversees job execution and resource management.
+
+Key methods:
+- `constructor()`: Initializes the manager and starts monitoring jobs.
+- `check()`: Periodically checks for new jobs to run.
+- `process(jobs)`: Processes a batch of jobs.
+- `run(job)`: Executes a single job.
+
+### handle.js
+Implements the Handle class, which provides the API for job creation and scheduling.
+
+Key methods:
+- `job(name, data)`: Creates a new job instance.
+- `schedule(schedule, strict, nextTime)`: Schedules a job.
+- `once(date, strict)`: Schedules a job to run once at a specific time.
+- `now()`: Schedules a job to run immediately.
+- `in(seconds)`: Schedules a job to run after a delay.
+
+### job.js
+Defines the base Job class and its subclasses (IPCJob, TransientJob).
+
+Key methods:
+- `run(db, done, progress)`: Main method to be implemented by subclasses.
+- `_save(set)`: Saves job state to the database.
+- `_finish(err)`: Marks a job as finished.
+- `retryPolicy()`: Returns the retry policy for the job.
+
+### resource.js
+Implements the Resource and ResourceFaçade classes for managing job resources.
+
+Key methods:
+- `open()`: Opens a resource.
+- `close()`: Closes a resource.
+- `run(job)`: Runs a job using the resource.
+
+### retry.js
+Defines retry policies for jobs.
+
+Classes:
+- `DefaultRetryPolicy`: Standard retry policy.
+- `IPCRetryPolicy`: Retry policy specific to IPC jobs.
+- `NoRetryPolicy`: Policy for jobs that should never be retried.
+
+### ipc.js
+Handles inter-process communication.
+
+Key classes:
+- `IdChannel`: Represents a communication channel between processes.
+
+Key methods:
+- `send(cmd, data)`: Sends a message through the channel.
+- `on(cmd, handler)`: Registers a handler for incoming messages.
+
+### executor.js
+Entry point for child processes that run IPC jobs.
+
+Key functionality:
+- Sets up the environment for running a job in a separate process.
+- Initializes the necessary resources and communication channels.
+- Executes the job and communicates results back to the main process.
+
+## Configuration
+
+The Jobs system can be configured through the main application configuration file. Key configuration options include:
+
+- `jobs.concurrency`: Maximum number of jobs that can run concurrently
+- `jobs.retryDelay`: Default delay between retry attempts
+- `jobs.maxRetries`: Default maximum number of retry attempts
+- `jobs.timeout`: Default timeout for job execution
+
+Example configuration:
+
+```javascript
+{
+ jobs: {
+ concurrency: 5,
+ retryDelay: 60000, // 1 minute
+ maxRetries: 3,
+ timeout: 300000 // 5 minutes
+ }
+}
+```
+
+## Technical Implementation of Scheduling
+
+The Jobs system implements scheduling using a combination of database persistence and Node.js timers. Here's a breakdown of the process:
+
+### 1. Schedule Persistence (manager.js)
+
+When a job is scheduled, it's saved to the MongoDB database with a `next` field indicating the next run time. This is handled in the `schedule` method of the Job class:
+
+```javascript
+schedule(schedule, strict, nextTime) {
+ this._json.schedule = schedule;
+ this._json.status = STATUS.SCHEDULED;
+
+ if (strict !== undefined) {
+ this._json.strict = strict;
+ }
+
+ if (nextTime) {
+ this._json.next = nextTime;
+ }
+ else {
+ schedule = typeof schedule === 'string' ? later.parse.text(schedule) : schedule;
+ var next = later.schedule(schedule).next(1);
+ if (!next) {
+ return null;
+ }
+
+ this._json.next = next.getTime();
+ }
+
+ return this._save();
+}
+```
+
+### 2. Job Checking (manager.js)
+
+The Manager class periodically checks for jobs that are due to run. This is done in the `check` method:
+
+```javascript
+check() {
+ var find = {
+ status: STATUS.SCHEDULED,
+ next: {$lt: Date.now()},
+ name: {$in: this.types}
+ };
+
+ this.collection.find(find).sort({next: 1}).limit(MAXIMUM_IN_LINE_JOBS_PER_NAME).toArray((err, jobs) => {
+ if (err) {
+ // Error handling
+ }
+ else if (jobs && jobs.length) {
+ this.process(jobs);
+ }
+ else {
+ this.checkAfterDelay();
+ }
+ });
+}
+```
+
+### 3. Scheduling Next Check (manager.js)
+
+After processing jobs, the system schedules the next check using Node.js's `setTimeout`:
+
+```javascript
+checkAfterDelay(delay) {
+ setTimeout(() => {
+ this.check();
+ }, delay || DELAY_BETWEEN_CHECKS);
+}
+```
+
+### 4. Job Execution (manager.js)
+
+When it's time to run a job, the `run` method is called:
+
+```javascript
+run(job) {
+ if (job instanceof JOB.IPCJob) {
+ return this.runIPC(job);
+ }
+ else {
+ return this.runLocally(job);
+ }
+}
+```
+
+### 5. Rescheduling Recurring Jobs (job.js)
+
+After a job completes, if it's a recurring job, it's rescheduled:
+
+```javascript
+_finish(err) {
+ // ... other completion logic ...
+
+ if (this._json.schedule) {
+ this.schedule(this._json.schedule, this._json.strict);
+ }
+}
+```
+
+This implementation allows for efficient scheduling of jobs:
+- Jobs are persisted in the database, allowing for system restarts without losing scheduled jobs.
+- The periodic checking mechanism allows for handling a large number of jobs without keeping them all in memory.
+- Using Node.js timers for the checking mechanism provides a simple and reliable way to trigger job processing.
+- The use of `later.js` for parsing schedules allows for flexible and powerful schedule definitions.
+
+## Best Practices
+
+1. **Job Atomicity**: Design jobs to be atomic and idempotent where possible. This ensures that jobs can be safely retried without unintended side effects.
+
+7. **Job Data**: Keep job data lightweight and serializable. Avoid storing large objects or non-serializable data in the job's data field.
diff --git a/api/parts/jobs/job_flow.md b/api/parts/jobs/job_flow.md
new file mode 100644
index 00000000000..e77b9d18ce4
--- /dev/null
+++ b/api/parts/jobs/job_flow.md
@@ -0,0 +1,101 @@
+```mermaid
+
+graph TD
+ A[Start] --> B{Is it Master Process? index.js}
+ B -->|Yes| C[Load manager.js]
+ B -->|No| D[Load handle.js]
+
+ C --> E[Initialize Manager Manager.constructor manager.js]
+ E --> F[Scan for job types scan scanner.js]
+ F --> G[Monitor jobs collection check manager.js]
+
+ D --> H[Initialize Handle Handle.constructor handle.js]
+ H --> I[Wait for job requests]
+
+ G --> J{New job to run? process manager.js}
+ J -->|Yes| K[Create job instance create manager.js]
+ J -->|No| G
+
+ K --> AI{Job requires division? divide job.js}
+ AI -->|Yes| AJ[Create sub-jobs _divide job.js]
+ AI -->|No| L
+ AJ --> AK[Process sub-jobs manager.js]
+ AK --> L
+
+ L{Is it an IPC job? instanceof IPCJob job.js}
+ L -->|Yes| M[Create ResourceFaçade ResourceFaçade.constructor resource.js]
+ L -->|No| N[Run locally runLocally manager.js]
+
+ M --> O[Fork executor.js cp.fork resource.js]
+ O --> P[Initialize Resource Resource.constructor resource.js]
+ P --> Q[Run job in Resource run resource.js]
+
+ N --> R[Run job in current process _run job.js]
+
+ Q --> AL{Resource check needed? resource.js}
+ AL -->|Yes| AM[Check resource activity checkActive resource.js]
+ AL -->|No| S
+ AM -->|Active| Q
+ AM -->|Inactive| AN[Close resource close resource.js]
+ AN --> S
+
+ R --> AO{Job aborted? _abort job.js}
+ AO -->|Yes| AP[Handle abortion _finish job.js]
+ AO -->|No| S
+ AP --> T
+
+ S[Job completes _finish job.js]
+
+ S --> T[Update job status _save job.js]
+ T --> U{Retry needed? retryPolicy.run retry.js}
+ U -->|Yes| V[Delay and retry delay retry.js]
+ U -->|No| G
+ V --> K
+
+ I --> W{Job request received? handle.js}
+ W -->|Yes| X[Create job job handle.js]
+ W -->|No| I
+
+ X --> Y{Schedule type? handle.js}
+ Y -->|Now| Z[Run immediately now handle.js]
+ Y -->|Once| AA[Schedule for later once handle.js]
+ Y -->|In| AB[Schedule after delay in handle.js]
+ Y -->|Custom Schedule| AC[Set custom schedule schedule handle.js]
+
+ Z --> AD[Save job _save job.js]
+ AA --> AD
+ AB --> AD
+ AC --> AD
+
+ AD --> AE{Is it transient? handle.js}
+ AE -->|Yes| AF[Run transient job runTransient handle.js]
+ AE -->|No| AG[Send to Manager if needed ipc.send ipc.js]
+
+ AF --> AH[Process in IPC channel IdChannel.on ipc.js]
+ AG --> I
+ AH --> I
+
+ Q --> AQ{IPC communication needed? resource.js}
+ AQ -->|Yes| AR[Send IPC message channel.send ipc.js]
+ AQ -->|No| S
+ AR --> AS[Receive IPC message channel.on ipc.js]
+ AS --> Q
+
+ R --> AT{Progress update? job.js}
+ AT -->|Yes| AU[Send progress _save job.js]
+ AT -->|No| R
+ AU --> R
+
+ G --> AV{Job cancelled or paused? manager.js}
+ AV -->|Yes| AW[Update job status _save job.js]
+ AV -->|No| G
+ AW --> G
+
+ P --> AX[Open resource open resource.js]
+ AX --> AY{Resource opened successfully? resource.js}
+ AY -->|Yes| Q
+ AY -->|No| AZ[Handle resource error resource.js]
+ AZ --> S
+
+
+```
\ No newline at end of file
diff --git a/api/parts/jobs/runner.js b/api/parts/jobs/runner.js
index 93edee66693..6d305d3c69b 100644
--- a/api/parts/jobs/runner.js
+++ b/api/parts/jobs/runner.js
@@ -23,7 +23,7 @@ let leader, // leader doc
*/
function setup() {
if (!collection) {
- common.db.createCollection(COLLECTION, (err, col) => {
+ common.db.createCollection(COLLECTION, (err) => {
if (err) {
log.d('collection exists');
collection = common.db.collection(COLLECTION);
@@ -40,7 +40,7 @@ function setup() {
});
}
else {
- collection = col;
+ collection = common.db.collection(COLLECTION);
setImmediate(periodic);
}
});
@@ -406,4 +406,4 @@ module.exports = {
// console.log('resolving');
// return new Promise(res => setTimeout(res, 10000 * Math.random()));
// }
-// });
\ No newline at end of file
+// });
diff --git a/api/utils/requestProcessor.js b/api/utils/requestProcessor.js
index d4786939a2e..17099466dad 100755
--- a/api/utils/requestProcessor.js
+++ b/api/utils/requestProcessor.js
@@ -7,7 +7,7 @@ const Promise = require('bluebird');
const url = require('url');
const common = require('./common.js');
const countlyCommon = require('../lib/countly.common.js');
-const { validateAppAdmin, validateUser, validateRead, validateUserForRead, validateUserForWrite, validateGlobalAdmin, dbUserHasAccessToCollection, validateUpdate, validateDelete, validateCreate } = require('./rights.js');
+const { validateAppAdmin, validateUser, validateRead, validateUserForRead, validateUserForWrite, validateGlobalAdmin, dbUserHasAccessToCollection, validateUpdate, validateDelete, validateCreate, getBaseAppFilter } = require('./rights.js');
const authorize = require('./authorizer.js');
const taskmanager = require('./taskmanager.js');
const plugins = require('../../plugins/pluginManager.js');
@@ -828,17 +828,6 @@ const processRequest = (params) => {
});
});
break;
- case 'stop':
- validateUserForWrite(params, () => {
- taskmanager.stopTask({
- db: common.db,
- id: params.qstring.task_id,
- op_id: params.qstring.op_id
- }, (err, res) => {
- common.returnMessage(params, 200, res);
- });
- });
- break;
case 'delete':
validateUserForWrite(params, () => {
taskmanager.deleteResult({
@@ -1011,44 +1000,46 @@ const processRequest = (params) => {
catch (SyntaxError) {
update_array.overview = []; console.log('Parse ' + params.qstring.event_overview + ' JSON failed', params.req.url, params.req.body);
}
- if (update_array.overview && Array.isArray(update_array.overview) && update_array.overview.length > 12) {
- common.returnMessage(params, 400, "You can't add more than 12 items in overview");
- return;
- }
- //sanitize overview
- var allowedEventKeys = event.list;
- var allowedProperties = ['dur', 'sum', 'count'];
- var propertyNames = {
- 'dur': 'Dur',
- 'sum': 'Sum',
- 'count': 'Count'
- };
- for (let i = 0; i < update_array.overview.length; i++) {
- update_array.overview[i].order = i;
- update_array.overview[i].eventKey = update_array.overview[i].eventKey || "";
- update_array.overview[i].eventProperty = update_array.overview[i].eventProperty || "";
- if (allowedEventKeys.indexOf(update_array.overview[i].eventKey) === -1 || allowedProperties.indexOf(update_array.overview[i].eventProperty) === -1) {
- update_array.overview.splice(i, 1);
- i = i - 1;
- }
- else {
- update_array.overview[i].is_event_group = (typeof update_array.overview[i].is_event_group === 'boolean' && update_array.overview[i].is_event_group) || false;
- update_array.overview[i].eventName = update_array.overview[i].eventName || update_array.overview[i].eventKey;
- update_array.overview[i].propertyName = propertyNames[update_array.overview[i].eventProperty];
- }
- }
- //check for duplicates
- var overview_map = Object.create(null);
- for (let p = 0; p < update_array.overview.length; p++) {
- if (!overview_map[update_array.overview[p].eventKey]) {
- overview_map[update_array.overview[p].eventKey] = {};
+ if (update_array.overview && Array.isArray(update_array.overview)) {
+ if (update_array.overview.length > 12) {
+ common.returnMessage(params, 400, "You can't add more than 12 items in overview");
+ return;
}
- if (!overview_map[update_array.overview[p].eventKey][update_array.overview[p].eventProperty]) {
- overview_map[update_array.overview[p].eventKey][update_array.overview[p].eventProperty] = 1;
+ //sanitize overview
+ var allowedEventKeys = event.list;
+ var allowedProperties = ['dur', 'sum', 'count'];
+ var propertyNames = {
+ 'dur': 'Dur',
+ 'sum': 'Sum',
+ 'count': 'Count'
+ };
+ for (let i = 0; i < update_array.overview.length; i++) {
+ update_array.overview[i].order = i;
+ update_array.overview[i].eventKey = update_array.overview[i].eventKey || "";
+ update_array.overview[i].eventProperty = update_array.overview[i].eventProperty || "";
+ if (allowedEventKeys.indexOf(update_array.overview[i].eventKey) === -1 || allowedProperties.indexOf(update_array.overview[i].eventProperty) === -1) {
+ update_array.overview.splice(i, 1);
+ i = i - 1;
+ }
+ else {
+ update_array.overview[i].is_event_group = (typeof update_array.overview[i].is_event_group === 'boolean' && update_array.overview[i].is_event_group) || false;
+ update_array.overview[i].eventName = update_array.overview[i].eventName || update_array.overview[i].eventKey;
+ update_array.overview[i].propertyName = propertyNames[update_array.overview[i].eventProperty];
+ }
}
- else {
- update_array.overview.splice(p, 1);
- p = p - 1;
+ //check for duplicates
+ var overview_map = Object.create(null);
+ for (let p = 0; p < update_array.overview.length; p++) {
+ if (!overview_map[update_array.overview[p].eventKey]) {
+ overview_map[update_array.overview[p].eventKey] = {};
+ }
+ if (!overview_map[update_array.overview[p].eventKey][update_array.overview[p].eventProperty]) {
+ overview_map[update_array.overview[p].eventKey][update_array.overview[p].eventProperty] = 1;
+ }
+ else {
+ update_array.overview.splice(p, 1);
+ p = p - 1;
+ }
}
}
}
@@ -1181,7 +1172,7 @@ const processRequest = (params) => {
return new Promise(function(resolve) {
var collectionNameWoPrefix = common.crypto.createHash('sha1').update(obj.key + params.qstring.app_id).digest('hex');
//removes all document for current segment
- common.db.collection("events" + collectionNameWoPrefix).remove({"s": {$in: obj.list}}, {multi: true}, function(err3) {
+ common.db.collection("events_data").remove({"_id": {"$regex": ("^" + params.qstring.app_id + "_" + collectionNameWoPrefix + "_.*")}, "s": {$in: obj.list}}, {multi: true}, function(err3) {
if (err3) {
console.log(err3);
}
@@ -1196,7 +1187,7 @@ const processRequest = (params) => {
unsetUs["meta_v2." + obj.list[p]] = "";
}
//clears out meta data for segments
- common.db.collection("events" + collectionNameWoPrefix).update({$or: my_query}, {$unset: unsetUs}, {multi: true}, function(err4) {
+ common.db.collection("events_data").update({"_id": {"$regex": ("^" + params.qstring.app_id + "_" + collectionNameWoPrefix + "_.*")}, $or: my_query}, {$unset: unsetUs}, {multi: true}, function(err4) {
if (err4) {
console.log(err4);
}
@@ -1240,7 +1231,6 @@ const processRequest = (params) => {
else {
resolve();
}
-
});
}
else {
@@ -2129,7 +2119,7 @@ const processRequest = (params) => {
}
dbUserHasAccessToCollection(params, params.qstring.collection, (hasAccess) => {
- if (hasAccess) {
+ if (hasAccess || (params.qstring.db === "countly_drill" && params.qstring.collection === "drill_events") || (params.qstring.db === "countly" && params.qstring.collection === "events_data")) {
var dbs = { countly: common.db, countly_drill: common.drillDb, countly_out: common.outDb, countly_fs: countlyFs.gridfs.getHandler() };
var db = "";
if (params.qstring.db && dbs[params.qstring.db]) {
@@ -2138,6 +2128,23 @@ const processRequest = (params) => {
else {
db = common.db;
}
+ if (!params.member.global_admin && params.qstring.collection === "drill_events" || params.qstring.collection === "events_data") {
+ var base_filter = getBaseAppFilter(params.member, params.qstring.db, params.qstring.collection);
+ if (base_filter && Object.keys(base_filter).length > 0) {
+ params.qstring.query = params.qstring.query || {};
+ for (var key in base_filter) {
+ if (params.qstring.query[key]) {
+ params.qstring.query.$and = params.qstring.query.$and || [];
+ params.qstring.query.$and.push({[key]: base_filter[key]});
+ params.qstring.query.$and.push({[key]: params.qstring.query[key]});
+ delete params.qstring.query[key];
+ }
+ else {
+ params.qstring.query[key] = base_filter[key];
+ }
+ }
+ }
+ }
countlyApi.data.exports.fromDatabase({
db: db,
params: params,
@@ -3657,7 +3664,7 @@ const restartRequest = (params, initiator, done, try_times, fail) => {
*/
function processUser(params, initiator, done, try_times) {
return new Promise((resolve) => {
- if (!params.app_user.uid) {
+ if (params && params.app_user && !params.app_user.uid) {
//first time we see this user, we need to id him with uid
countlyApi.mgmt.appUsers.getUid(params.app_id, function(err, uid) {
plugins.dispatch("/i/app_users/create", {
@@ -3716,7 +3723,7 @@ function processUser(params, initiator, done, try_times) {
});
}
//check if device id was changed
- else if (params.qstring.old_device_id && params.qstring.old_device_id !== params.qstring.device_id) {
+ else if (params && params.qstring && params.qstring.old_device_id && params.qstring.old_device_id !== params.qstring.device_id) {
const old_id = common.crypto.createHash('sha1')
.update(params.qstring.app_key + params.qstring.old_device_id + "")
.digest('hex');
diff --git a/api/utils/rights.js b/api/utils/rights.js
index 6cb3024ff6e..5d431752865 100644
--- a/api/utils/rights.js
+++ b/api/utils/rights.js
@@ -1083,7 +1083,32 @@ function validateWrite(params, feature, accessType, callback, callbackParam) {
});
});
}
-
+/**
+ * Creates filter object to filter by member allowed collections
+ * @param {object} member - members object from params
+ * @param {string} dbName - database name as string
+ * @param {string} collectionName - collection Name
+ * @returns {object} filter object
+ */
+exports.getBaseAppFilter = function(member, dbName, collectionName) {
+ var base_filter = {};
+ var apps = exports.getUserApps(member);
+ if (dbName === "countly_drill" && collectionName === "drill_events") {
+ if (Array.isArray(apps) && apps.length > 0) {
+ base_filter.a = {"$in": apps};
+ }
+ }
+ else if (dbName === "countly" && collectionName === "events_data") {
+ var in_array = [];
+ if (Array.isArray(apps) && apps.length > 0) {
+ for (var i = 0; i < apps.length; i++) {
+ in_array.push(new RegExp("^" + apps[i] + "_.*"));
+ }
+ base_filter = {"_id": {"$in": in_array}};
+ }
+ }
+ return base_filter;
+};
/**
* Validate user for create access by api_key for provided app_id (both required parameters for the request).
* @param {params} params - {@link params} object
diff --git a/api/utils/taskmanager.js b/api/utils/taskmanager.js
index d5d703499c8..0233bd96081 100644
--- a/api/utils/taskmanager.js
+++ b/api/utils/taskmanager.js
@@ -58,7 +58,7 @@ const log = require('./log.js')('core:taskmanager');
* }, outputData:function(err, data){
* common.returnOutput(params, data);
* }
-* }));
+* }));
*/
taskmanager.longtask = function(options) {
options.db = options.db || common.db;
@@ -66,80 +66,6 @@ taskmanager.longtask = function(options) {
var start = new Date().getTime();
var timeout;
- var saveOpId = async function(comment_id, retryCount) {
- common.db.admin().command({ currentOp: 1 }, async function(error, result) {
- if (error) {
- log.d(error);
- return;
- }
- else {
- if (result && result.inprog) {
- for (var i = 0; i < result.inprog.length; i++) {
- let op = result.inprog[i];
- if (!('$truncated' in op.command) && (i !== result.inprog.length - 1)) {
- continue;
- }
- if (!('$truncated' in op.command) && (i === result.inprog.length - 1)) {
- if (retryCount < 3) {
- setTimeout(() => saveOpId(comment_id, (++retryCount)), 500);
- return;
- }
- else {
- log.d(`operation not found for task:${options.id} comment: ${comment_id}`);
- break;
- }
- }
-
- let comment_position = op.command.$truncated.indexOf('$comment');
- if (comment_position === -1) {
- continue;
- }
-
- let substr = op.command.$truncated.substring(comment_position, op.command.$truncated.length) || "";
- var comment_val = "";
- substr = substr.match(/"(.*?)"/);
- if (substr && Array.isArray(substr)) {
- comment_val = substr[1];
- }
-
- if (comment_val === comment_id) {
- var task_id = options.id;
- var op_id = op.opid;
- await common.db.collection("long_tasks").findOneAndUpdate({ _id: common.db.ObjectID(task_id) }, { $set: { op_id: op_id } });
- log.d(`Operation found task: ${task_id} op:${op_id} comment: ${comment_id}`);
- break;
- }
- else if ((comment_val !== comment_id) && (i === (result.inprog.length - 1))) {
- if (retryCount < 3) {
- setTimeout(() => saveOpId(comment_id, (++retryCount)), 500);
- break;
- }
- else {
- log.d(`operation not found for task:${options.id} comment: ${comment_id}`);
- break;
- }
- }
- }
- }
- }
- });
- };
-
- if (options.comment_id) {
- var retryCount = 0;
- try {
- saveOpId(options.comment_id, retryCount);
- }
- catch (err) {
- if (retryCount < 3) {
- setTimeout(() =>saveOpId(options.comment_id, ++retryCount), 500);
- }
- else {
- console.log(err);
- }
- }
- }
-
/** switching to long task */
function switchToLongTask() {
timeout = null;
@@ -298,9 +224,6 @@ taskmanager.createTask = function(options, callback) {
update.subtask_key = options.subtask_key || "";
update.taskgroup = options.taskgroup || false;
update.linked_to = options.linked_to;
- if (options.comment_id) {
- update.comment_id = options.comment_id;
- }
if (options.subtask && options.subtask !== "") {
update.subtask = options.subtask;
var updateSub = {$set: {}};
@@ -323,6 +246,89 @@ taskmanager.createTask = function(options, callback) {
}
};
+var checkIfAllRulesMatch = function(rules, data) {
+ var match = true;
+ for (var key in rules) {
+ if (data[key]) {
+ if (rules[key] === data[key]) {
+ continue;
+ }
+ else {
+ if (typeof rules[key] === "object") {
+ if (!checkIfAllRulesMatch(rules[key], data[key])) {
+ return false;
+ }
+ }
+ else {
+ if (data[key].$in) {
+ if (data[key].$in.indexOf(rules[key]) === -1) {
+ return false;
+ }
+ }
+ else if (data[key].$nin) {
+ if (data[key].$nin.indexOf(rules[key]) !== -1) {
+ return false;
+ }
+ }
+ else {
+ return false;
+ }
+ }
+ }
+ }
+ else {
+ return false;
+ }
+ }
+ return match;
+};
+
+taskmanager.markReportsDirtyBasedOnRule = function(options, callback) {
+ common.db.collection("long_tasks").find({
+ autoRefresh: true,
+ }).toArray(function(err, tasks) {
+ var ids_to_mark_dirty = [];
+ if (err) {
+ log.e("Error while fetching tasks", err);
+ if (callback && typeof callback === "function") {
+ callback();
+ }
+ return;
+
+ }
+ tasks = tasks || [];
+ for (var z = 0; z < tasks.length; z++) {
+ try {
+ var req = JSON.parse(tasks[z].request);
+ if (checkIfAllRulesMatch(options.rules, req.json.queryObject)) {
+ ids_to_mark_dirty.push(tasks[z]._id);
+ }
+ }
+ catch (e) {
+ log.e(' got error while process task request parse', e);
+ }
+
+ }
+ if (ids_to_mark_dirty.length > 0) {
+ common.db.collection("long_tasks").updateMany({_id: {$in: ids_to_mark_dirty}}, {$set: {dirty: new Date().getTime()}}, function(err3) {
+ if (err3) {
+ log.e("Error while updating reports", err3);
+ }
+ if (callback && typeof callback === "function") {
+ callback();
+ }
+ });
+ }
+ else {
+ if (callback && typeof callback === "function") {
+ callback();
+ }
+ }
+
+
+ });
+
+};
/**
* Save result from the task
* @param {object} options - options for the task
@@ -388,6 +394,9 @@ taskmanager.saveResult = function(options, data, callback) {
options.db.collection("long_tasks").update({_id: options.subtask}, updateObj, {'upsert': false}, function() {});
}
options.db.collection("long_tasks").findOne({_id: options.id}, function(error, task) {
+ if (task && task.dirty && task.dirty < task.start) {
+ update.dirty = false;
+ }
if (options.gridfs || (task && task.gridfs)) {
//let's store it in gridfs
update.data = {};
@@ -886,6 +895,8 @@ taskmanager.errorResults = function(options, callback) {
* @param {object} options - options for the task
* @param {object} options.db - database connection
* @param {string} options.id - id of the task result
+* @param {boolean} options.autoUpdate - if auto update is needed or not
+* @param {boolean} options.dirty - if dirty is true then it means some part of report is wrong. It should be regenerated fully.
* @param {funciton} callback - callback for the result
*/
taskmanager.rerunTask = function(options, callback) {
@@ -990,7 +1001,7 @@ taskmanager.rerunTask = function(options, callback) {
reqData.json.period = JSON.stringify(reqData.json.period);
}
options.subtask = res.subtask;
- reqData.json.autoUpdate = options.autoUpdate || false;
+ reqData.json.autoUpdate = ((!options.dirty) && (options.autoUpdate || false)); //If dirty set autoUpdate to false
if (!reqData.json.api_key && res.creator) {
options.db.collection("members").findOne({_id: common.db.ObjectID(res.creator)}, function(err1, member) {
if (member && member.api_key) {
@@ -1029,50 +1040,6 @@ taskmanager.rerunTask = function(options, callback) {
});
};
-taskmanager.stopTask = function(options, callback) {
- options.db = options.db || common.db;
-
- /**
- * Stop task
- * @param {object} op_id - operation id for mongo process
- * @param {object} options1.db - database connection
- * @param {string} options1.id - id of the task result
- * @param {object} reqData - request data
- * @param {funciton} callback1 - callback for the result
- */
- function stopTask(op_id) {
- common.db.admin().command({ killOp: 1, op: Number.parseInt(op_id) }, function(error, result) {
- if (result.ok === 1) {
- callback(null, "Success");
- }
- else {
- callback(null, "Operation could not be stopped");
- }
- });
- }
-
- options.db.collection("long_tasks").findOne({ _id: options.id }, function(err, res) {
- if (res) {
- if (res.creator) {
- options.db.collection("members").findOne({ _id: common.db.ObjectID(res.creator) }, function(err1, member) {
- if (member) {
- stopTask(res.op_id);
- }
- else {
- callback(null, "No permission to stop this task");
- }
- });
- }
- else {
- stopTask(res.op_id);
- }
- }
- else {
- callback(null, "Task does not exist");
- }
- });
-};
-
/**
* Create a callback for getting result, including checking gridfs
* @param {function} callback - callback for the result
@@ -1113,4 +1080,4 @@ function getResult(callback, options) {
}
};
}
-module.exports = taskmanager;
\ No newline at end of file
+module.exports = taskmanager;
diff --git a/bin/scripts/data-reports/compare_drill_aggregated.js b/bin/scripts/data-reports/compare_drill_aggregated.js
index 6a16af89c9d..c93529488e9 100644
--- a/bin/scripts/data-reports/compare_drill_aggregated.js
+++ b/bin/scripts/data-reports/compare_drill_aggregated.js
@@ -7,9 +7,12 @@
* node compare_drill_aggregated.js
*/
var period = "7days"; //Chose any of formats: "Xdays" ("7days","100days") or ["1-1-2024", "1-10-2024"],
-var app_list = []; //List with apps
+var app_list = []; //List with apps ""
//Example var eventMap = {"6075f94b7e5e0d392902520c":["Logout","Login"],"6075f94b7e5e0d392902520d":["Logout","Login","Buy"]};
var eventMap = {}; //If left empty will run for all alls/events.
+
+var union_with_old_collection = true; //False if all sessions are stored in drill_events collection
+
var verbose = false; //true to show more output
@@ -151,11 +154,40 @@ Promise.all([pluginManager.dbConnection("countly"), pluginManager.dbConnection("
}
}
if (haveAnything) {
- console.log(" " + JSON.stringify(report));
+ let aggCount = totals.c || 0;
+ let drillCount = drillData.totals.c || 0;
+ let percentageDiff = 0;
+ if (drillCount !== 0) {
+ percentageDiff = ((drillCount - aggCount) / drillCount) * 100;
+ }
+ else {
+ if (aggCount !== 0) {
+ // If drillCount is 0, and aggCount is not 0, show a large difference
+ percentageDiff = (aggCount > 0 ? 100 : -100); // 100% or -100% depending on the sign of aggCount
+ }
+ else {
+ percentageDiff = 0; // Both counts are 0, no difference
+ }
+ }
+
+ console.log("----------------------------------------------");
+ console.log("- Application name:", app.name);
+ console.log("- Event name:", event);
+ console.log("- Counts in Aggregated data:", aggCount);
+ console.log("- Counts in Drill data:", drillCount);
+ console.log("- Percentage difference between Drill data and Aggregated data:", percentageDiff.toFixed(2) + "%");
+ console.log("----------------------------------------------");
endReport[app._id]["bad"]++;
endReport[app._id]["events"] = endReport[app._id]["events"] || {};
- endReport[app._id]["events"][event] = {"e": event, report: report};
+ endReport[app._id]["events"][event] = {
+ "e": event,
+ "aggregated_count": aggCount,
+ "drill_count": drillCount,
+ "percentage_difference": percentageDiff.toFixed(2),
+ "report": report
+ };
}
+
resolve2();
});
}
@@ -164,6 +196,25 @@ Promise.all([pluginManager.dbConnection("countly"), pluginManager.dbConnection("
}).then(function() {
console.log("Finished processing app: ", app.name);
resolve();
+
+ //Complete CSV after processing the apps
+ console.log("\nSummary Report (CSV-like):");
+ console.log("App,Event,Aggregated,Drill,% Difference");
+ // var csvRows = ["App,Event,Aggregated,Drill,% Difference"];
+ for (var appId in endReport) {
+ var appData = endReport[appId];
+ var appName = appData.name;
+ if (appData.events) {
+ for (var event in appData.events) {
+ var eventData = appData.events[event];
+ var row = `${appName},${event},${eventData.aggregated_count},${eventData.drill_count},${eventData.percentage_difference}`;
+ console.log(row);
+ //csvRows.push(row);
+ }
+ }
+ }
+
+
}).catch(function(eee) {
console.log("Error processing app: ", app.name);
console.log(eee);
@@ -207,14 +258,17 @@ Promise.all([pluginManager.dbConnection("countly"), pluginManager.dbConnection("
}
endDate = endDate.valueOf() - endDate.utcOffset() * 60000;
- let collection = "drill_events" + crypto.createHash('sha1').update(options.event + options.app_id).digest('hex');
- var query = {"ts": {"$gte": startDate, "$lt": endDate}};
- var pipeline = [
- {"$match": query},
- ];
+ var query = {"ts": {"$gte": startDate, "$lt": endDate}, "a": options.app_id, "e": options.event};
+ var pipeline = [];
+ pipeline.push({"$match": query});
+ if (union_with_old_collection) {
+ let collection = "drill_events" + crypto.createHash('sha1').update(options.event + options.app_id).digest('hex');
+ var query2 = {"ts": {"$gte": startDate, "$lt": endDate}};
+ pipeline.push({"$unionWith": { "coll": collection, "pipeline": [{"$match": query2}] }});
+ }
pipeline.push({"$group": {"_id": "$d", "c": {"$sum": "$c"}, "s": {"$sum": "$s"}, "dur": {"$sum": "$dur"}}});
- options.drillDb.collection(collection).aggregate(pipeline, {"allowDiskUse": true}).toArray(function(err, data) {
+ options.drillDb.collection("drill_events").aggregate(pipeline, {"allowDiskUse": true}).toArray(function(err, data) {
if (err) {
console.log(err);
}
diff --git a/bin/scripts/expire-data/delete_custom_events_regex.js b/bin/scripts/expire-data/delete_custom_events_regex.js
index b5e75b1fe2d..73d2f940a96 100755
--- a/bin/scripts/expire-data/delete_custom_events_regex.js
+++ b/bin/scripts/expire-data/delete_custom_events_regex.js
@@ -6,7 +6,6 @@
*/
-const { ObjectId } = require('mongodb');
const pluginManager = require('../../../plugins/pluginManager.js');
const common = require('../../../api/utils/common.js');
const drillCommon = require('../../../plugins/drill/api/common.js');
@@ -25,7 +24,7 @@ Promise.all([pluginManager.dbConnection("countly"), pluginManager.dbConnection("
//GET APP
try {
- const app = await countlyDb.collection("apps").findOne({_id: ObjectId(APP_ID)}, {_id: 1, name: 1});
+ const app = await countlyDb.collection("apps").findOne({_id: countlyDb.ObjectID(APP_ID)}, {_id: 1, name: 1});
console.log("App:", app.name);
//GET EVENTS
var events = [];
@@ -51,6 +50,27 @@ Promise.all([pluginManager.dbConnection("countly"), pluginManager.dbConnection("
}
]).toArray();
events = events.length ? events[0].list : [];
+ const metaEvents = await drillDb.collection("drill_meta").aggregate([
+ {
+ $match: {
+ 'app_id': app._id + "",
+ "type": "e",
+ "e": { $regex: regex, $options: CASE_INSENSITIVE ? "i" : "", $nin: events }
+ }
+ },
+ {
+ $group: {
+ _id: "$e"
+ }
+ },
+ {
+ $project: {
+ _id: 0,
+ e: "$_id"
+ }
+ }
+ ]).toArray();
+ events = events.concat(metaEvents.map(e => e.e));
}
catch (err) {
close("Invalid regex");
@@ -86,6 +106,7 @@ Promise.all([pluginManager.dbConnection("countly"), pluginManager.dbConnection("
close(err);
}
+
async function deleteDrillEvents(appId, events) {
for (let i = 0; i < events.length; i++) {
var collectionName = drillCommon.getCollectionName(events[i], appId);
diff --git a/bin/scripts/member-managament/delete_old_members.js b/bin/scripts/member-managament/delete_old_members.js
index 6dee1d7624d..a52639e79f9 100644
--- a/bin/scripts/member-managament/delete_old_members.js
+++ b/bin/scripts/member-managament/delete_old_members.js
@@ -44,7 +44,7 @@ Promise.all([pluginManager.dbConnection("countly")]).spread(function(countlyDb)
Url: SERVER_URL + "/i/users/delete",
body: {
api_key: API_KEY,
- args: JSON.stringify({user_ids: [(data._id + "")]})
+ args: {user_ids: [data._id + ""]}
}
}, function(data) {
if (data.err) {
@@ -99,8 +99,7 @@ function sendRequest(params, callback) {
const options = {
uri: url.href,
method: params.requestType,
- json: true,
- body: body,
+ json: body,
strictSSL: false
};
diff --git a/frontend/express/app.js b/frontend/express/app.js
index 06f9b736362..6e846665354 100644
--- a/frontend/express/app.js
+++ b/frontend/express/app.js
@@ -603,6 +603,10 @@ Promise.all([plugins.dbConnection(countlyConfig), plugins.dbConnection("countly_
app.use(function(req, res, next) {
var contentType = req.headers['content-type'];
if (req.method.toLowerCase() === 'post' && contentType && contentType.indexOf('multipart/form-data') >= 0) {
+ if (!req.session?.uid || Date.now() > req.session?.expires) {
+ res.status(401).send('Unauthorized');
+ return;
+ }
var form = new formidable.IncomingForm();
form.uploadDir = __dirname + '/uploads';
form.parse(req, function(err, fields, files) {
@@ -974,6 +978,7 @@ Promise.all([plugins.dbConnection(countlyConfig), plugins.dbConnection("countly_
timezones: timezones,
countlyTypeName: COUNTLY_NAMED_TYPE,
countlyTypeTrack: COUNTLY_TRACK_TYPE,
+ countlyTypeCE: COUNTLY_TYPE_CE,
countly_tracking,
countly_domain,
frontend_app: versionInfo.frontend_app || 'e70ec21cbe19e799472dfaee0adb9223516d238f',
diff --git a/frontend/express/public/core/app-management/javascripts/countly.views.js b/frontend/express/public/core/app-management/javascripts/countly.views.js
index af100602e21..884926318c6 100755
--- a/frontend/express/public/core/app-management/javascripts/countly.views.js
+++ b/frontend/express/public/core/app-management/javascripts/countly.views.js
@@ -392,10 +392,10 @@
label: data.name
});
self.$store.dispatch("countlyCommon/addToAllApps", data);
+ self.$store.dispatch("countlyCommon/updateActiveApp", data._id + "");
if (self.firstApp) {
countlyCommon.ACTIVE_APP_ID = data._id + "";
app.onAppManagementSwitch(data._id + "", data && data.type || "mobile");
- self.$store.dispatch("countlyCommon/updateActiveApp", data._id + "");
app.initSidebar();
}
self.firstApp = self.checkIfFirst();
@@ -849,6 +849,9 @@
return countlyGlobal.apps[key].plugins.consolidate.includes(self.selectedApp);
}
}) || [];
+ },
+ handleCancelForm: function() {
+ CountlyHelpers.goTo({url: "/manage/apps"});
}
},
mounted: function() {
diff --git a/frontend/express/public/core/app-management/templates/app-management.html b/frontend/express/public/core/app-management/templates/app-management.html
index 90f82ff8230..81707a3bedc 100644
--- a/frontend/express/public/core/app-management/templates/app-management.html
+++ b/frontend/express/public/core/app-management/templates/app-management.html
@@ -188,13 +188,22 @@
{{apps[selectedApp]
-
+
{{i18n( newApp ? 'common.create' : 'common.apply')}}
+ @click="handleCancelForm"
+ data-test-id="create-new-app-cancel-button"
+ type="secondary"
+ >
+ {{i18n('common.cancel')}}
+
+ {{i18n( newApp ? 'common.create' : 'common.apply')}}
+
+
-
-
- {{item.label}}
-
+ {{item.label}}
diff --git a/frontend/express/public/core/app-version/templates/app-version.html b/frontend/express/public/core/app-version/templates/app-version.html
index e6b198adb1b..4ea87e052ef 100644
--- a/frontend/express/public/core/app-version/templates/app-version.html
+++ b/frontend/express/public/core/app-version/templates/app-version.html
@@ -5,10 +5,7 @@
>
-
-
- {{item.label}}
-
+ {{item.label}}
diff --git a/frontend/express/public/core/carrier/templates/carrier.html b/frontend/express/public/core/carrier/templates/carrier.html
index ce0108ff4ee..5fd82b2867d 100644
--- a/frontend/express/public/core/carrier/templates/carrier.html
+++ b/frontend/express/public/core/carrier/templates/carrier.html
@@ -6,10 +6,7 @@
>
-
-
- {{item.label}}
-
+ {{item.label}}
diff --git a/frontend/express/public/core/date-presets/templates/preset-management.html b/frontend/express/public/core/date-presets/templates/preset-management.html
index da8f4d2b7af..278422a89a5 100755
--- a/frontend/express/public/core/date-presets/templates/preset-management.html
+++ b/frontend/express/public/core/date-presets/templates/preset-management.html
@@ -4,13 +4,13 @@
>
- {{i18n('management.preset.create-button')}}
+ {{i18n('management.preset.create-button')}}
-
{{ unescapeHtml(rowScope.row.name) }}
+
{{ unescapeHtml(rowScope.row.name) }}
-
-
-
+
+
+ {{ rowScope.row.range_label }}
+
+
+
+
+ {{ rowScope.row.owner_name }}
+
+
+
+
+ {{ rowScope.row.share_with }}
+
+
-
- {{ i18n('common.edit') }}
- {{ i18n('common.duplicate') }}
- {{ i18n('common.delete') }}
+
+ {{ i18n('common.edit') }}
+ {{ i18n('common.duplicate') }}
+ {{ i18n('common.delete') }}
diff --git a/frontend/express/public/core/device-and-type/javascripts/countly.views.js b/frontend/express/public/core/device-and-type/javascripts/countly.views.js
index ab4e7279ace..561c67ced3d 100644
--- a/frontend/express/public/core/device-and-type/javascripts/countly.views.js
+++ b/frontend/express/public/core/device-and-type/javascripts/countly.views.js
@@ -443,6 +443,20 @@ var GridComponent = countlyVue.views.create({
}
return val;
},
+ onWidgetCommand: function(event) {
+ if (event === 'add' || event === 'manage' || event === 'show') {
+ this.graphNotesHandleCommand(event);
+ return;
+ }
+ else if (event === 'zoom') {
+ this.triggerZoom();
+ return;
+ }
+ else {
+ this.$emit('command', event);
+ return;
+ }
+ },
}
});
diff --git a/frontend/express/public/core/device-and-type/templates/devices-and-types.html b/frontend/express/public/core/device-and-type/templates/devices-and-types.html
index 1717de75acc..bfcbcc2229f 100644
--- a/frontend/express/public/core/device-and-type/templates/devices-and-types.html
+++ b/frontend/express/public/core/device-and-type/templates/devices-and-types.html
@@ -5,10 +5,7 @@
>
-
-
- {{item.label}}
-
+ {{item.label}}
diff --git a/frontend/express/public/core/device-and-type/templates/devices-tab.html b/frontend/express/public/core/device-and-type/templates/devices-tab.html
index 4469e5b5017..85ba8f9da16 100644
--- a/frontend/express/public/core/device-and-type/templates/devices-tab.html
+++ b/frontend/express/public/core/device-and-type/templates/devices-tab.html
@@ -15,7 +15,7 @@
{{item.title}}
-
+
diff --git a/frontend/express/public/core/device-and-type/templates/technologyHomeWidget.html b/frontend/express/public/core/device-and-type/templates/technologyHomeWidget.html
index b13a501176d..8064d81cdcb 100644
--- a/frontend/express/public/core/device-and-type/templates/technologyHomeWidget.html
+++ b/frontend/express/public/core/device-and-type/templates/technologyHomeWidget.html
@@ -5,14 +5,14 @@
{{item.title}}
-
+
{{item2.name}}
-
{{formatNumber(item2.value)}} {{item2.value > 1 ? 'Users' : 'User'}} | {{item2.percent}}%
+
{{formatNumber(item2.value)}} {{item2.value > 1 ? i18n('common.sessions') : i18n('common.session')}} | {{item2.percent}}%
diff --git a/frontend/express/public/core/events/javascripts/countly.details.models.js b/frontend/express/public/core/events/javascripts/countly.details.models.js
index 13c4af6a146..913f7f34c62 100644
--- a/frontend/express/public/core/events/javascripts/countly.details.models.js
+++ b/frontend/express/public/core/events/javascripts/countly.details.models.js
@@ -544,6 +544,10 @@
if (eventsLength >= limits.event_limit) {
eventLimit.message = CV.i18n("events.max-event-key-limit", limits.event_limit);
eventLimit.show = true;
+ eventLimit.goTo = {
+ title: CV.i18n("common.go-to-settings"),
+ url: "#/manage/configurations/api"
+ };
limitAlert.push(eventLimit);
}
if (!context.state.selectedEventName.startsWith('[CLY]_group')) {
@@ -1077,7 +1081,7 @@
.then(function(resp) {
if (resp) {
context.commit("setSelectedEventsOverview", countlyAllEvents.helpers.getSelectedEventsOverview(context, resp) || {});
- context.commit("setLegendData", countlyAllEvents.helpers.getLegendData(context || {}));
+ context.commit("setLegendData", countlyAllEvents.helpers.getLegendData(context));
}
});
}
diff --git a/frontend/express/public/core/events/javascripts/countly.overview.models.js b/frontend/express/public/core/events/javascripts/countly.overview.models.js
index 1eb01e5ccfa..8937d9a97a0 100644
--- a/frontend/express/public/core/events/javascripts/countly.overview.models.js
+++ b/frontend/express/public/core/events/javascripts/countly.overview.models.js
@@ -107,9 +107,9 @@
return monitorData;
},
getOverviewConfigureList: function(eventsList, groupList) {
- var map = eventsList.map || {};
var allEvents = [];
if (eventsList && eventsList.list) {
+ var map = eventsList.map || {};
eventsList.list.forEach(function(item) {
if (!map[item] || (map[item] && (map[item].is_visible || map[item].is_visible === undefined))) {
var label;
@@ -141,9 +141,9 @@
return allEvents;
},
getEventMapping: function(eventsList, groupList) {
- var map = eventsList.map || {};
var mapping = {};
if (eventsList && eventsList.list) {
+ var map = eventsList.map || {};
eventsList.list.forEach(function(item) {
var obj = {
"eventKey": item,
diff --git a/frontend/express/public/core/events/templates/allEvents.html b/frontend/express/public/core/events/templates/allEvents.html
index 2e03847eea5..a7e7c348ba0 100644
--- a/frontend/express/public/core/events/templates/allEvents.html
+++ b/frontend/express/public/core/events/templates/allEvents.html
@@ -28,7 +28,7 @@
-
+
diff --git a/frontend/express/public/core/geo-countries/templates/countries.html b/frontend/express/public/core/geo-countries/templates/countries.html
index c6c4fc0bda2..c72526eb2df 100644
--- a/frontend/express/public/core/geo-countries/templates/countries.html
+++ b/frontend/express/public/core/geo-countries/templates/countries.html
@@ -5,10 +5,7 @@
>
-
-
- {{item.label}}
-
+ {{item.label}}
diff --git a/frontend/express/public/core/home/templates/sessionsWidget.html b/frontend/express/public/core/home/templates/sessionsWidget.html
index d75fcd6682e..48a5993b704 100644
--- a/frontend/express/public/core/home/templates/sessionsWidget.html
+++ b/frontend/express/public/core/home/templates/sessionsWidget.html
@@ -7,7 +7,7 @@
diff --git a/frontend/express/public/core/home/templates/widgetTitle.html b/frontend/express/public/core/home/templates/widgetTitle.html
index 3ee219d8fe5..bd216593858 100644
--- a/frontend/express/public/core/home/templates/widgetTitle.html
+++ b/frontend/express/public/core/home/templates/widgetTitle.html
@@ -1,7 +1,7 @@
{{i18n(widget.label)}}
-
+
{{i18n(widget.linkTo.label)}}
diff --git a/frontend/express/public/core/jobs/templates/jobs.html b/frontend/express/public/core/jobs/templates/jobs.html
index 61945eb7e00..0d0585488fc 100644
--- a/frontend/express/public/core/jobs/templates/jobs.html
+++ b/frontend/express/public/core/jobs/templates/jobs.html
@@ -5,37 +5,47 @@
-
+
+
+
+ {{ scope.row.name }}
+
+
-
+
+
+
- {{scope.row.scheduleLabel}}
- {{scope.row.scheduleDetail}}
+ {{scope.row.scheduleLabel}}
+ {{scope.row.scheduleDetail}}
- {{scope.row.nextRunDate}}
- {{scope.row.nextRunTime}}
+ {{scope.row.nextRunDate}}
+ {{scope.row.nextRunTime}}
-
+
+
+ {{scope.row.total}}
+
-
+
{{scope.row.status === 'SUSPENDED' ? i18n('jobs.schedule') : i18n('jobs.suspend')}}
diff --git a/frontend/express/public/core/onboarding/javascripts/countly.views.js b/frontend/express/public/core/onboarding/javascripts/countly.views.js
index 41864dcff72..222b069a9d8 100644
--- a/frontend/express/public/core/onboarding/javascripts/countly.views.js
+++ b/frontend/express/public/core/onboarding/javascripts/countly.views.js
@@ -99,6 +99,7 @@
countlyPopulator.setStartTime(countlyCommon.periodObj.start / 1000);
countlyPopulator.setEndTime(countlyCommon.periodObj.end / 1000);
countlyPopulator.setSelectedTemplate(selectedAppTemplate);
+ countlyPopulator.setSelectedFeatures("all");
countlyPopulator.getTemplate(selectedAppTemplate, function(template) {
countlyPopulator.generateUsers(10, template);
self.populatorProgress = 0;
diff --git a/frontend/express/public/core/platform/templates/platform.html b/frontend/express/public/core/platform/templates/platform.html
index eed63d3ea2c..f7c992f1cfd 100644
--- a/frontend/express/public/core/platform/templates/platform.html
+++ b/frontend/express/public/core/platform/templates/platform.html
@@ -5,10 +5,7 @@
>
-
-
- {{item.label}}
-
+ {{item.label}}
diff --git a/frontend/express/public/core/report-manager/javascripts/countly.views.js b/frontend/express/public/core/report-manager/javascripts/countly.views.js
index e1f5fae8232..0b33ac3874e 100644
--- a/frontend/express/public/core/report-manager/javascripts/countly.views.js
+++ b/frontend/express/public/core/report-manager/javascripts/countly.views.js
@@ -127,9 +127,6 @@
canDeleteReport: function() {
return countlyGlobal.member.global_admin || countlyGlobal.admin_apps[countlyCommon.ACTIVE_APP_ID];
},
- canStopReport: function() {
- return countlyGlobal.member.global_admin || countlyGlobal.admin_apps[countlyCommon.ACTIVE_APP_ID];
- },
query: function() {
var q = {};
if (this.fixedOrigin) {
@@ -316,14 +313,6 @@
isDownloadable: function(row) {
return ["views", "dbviewer", "tableExport"].includes(row.type);
},
- isStopable: function(row) {
- if (row.status === "running" && row.op_id && row.comment_id) {
- return true;
- }
- else {
- return false;
- }
- },
isReadyForView: function(row) {
if (row.linked_to) {
if (row.have_dashboard_widget) {
@@ -342,7 +331,6 @@
},
handleCommand: function(command, row) {
var id = row._id,
- op_id = row.op_id,
self = this;
if (id) {
@@ -378,23 +366,6 @@
});
}, [CV.i18n("common.no-dont-do-that"), CV.i18n("taskmanager.yes-rerun-task")], {title: CV.i18n("taskmanager.confirm-rerun-title"), image: "rerunning-task"});
}
- else if (command === "stop-task") {
- CountlyHelpers.confirm(CV.i18n("taskmanager.confirm-stop"), "popStyleGreen", function(result) {
- if (!result) {
- return true;
- }
- self.refresh();
- countlyTaskManager.stop(id, op_id, function(res, error) {
- if (res.result === "Success") {
- countlyTaskManager.monitor(id, true);
- self.refresh();
- }
- else {
- CountlyHelpers.alert(error, "red");
- }
- });
- }, [CV.i18n("common.no-dont-do-that"), CV.i18n("taskmanager.yes-stop-task")], {title: CV.i18n("taskmanager.confirm-stop-title"), image: "rerunning-task"});
- }
else if (command === "view-task") {
self.$emit("view-task", row);
if (!this.disableAutoNavigationToTask) {
diff --git a/frontend/express/public/core/session-overview/templates/sessionsHomeWidget.html b/frontend/express/public/core/session-overview/templates/sessionsHomeWidget.html
index 88373e60d8a..97a4df4e1b7 100644
--- a/frontend/express/public/core/session-overview/templates/sessionsHomeWidget.html
+++ b/frontend/express/public/core/session-overview/templates/sessionsHomeWidget.html
@@ -8,7 +8,7 @@
diff --git a/frontend/express/public/core/user-analytics-overview/templates/overview.html b/frontend/express/public/core/user-analytics-overview/templates/overview.html
index b759d12ab82..867f3c79944 100644
--- a/frontend/express/public/core/user-analytics-overview/templates/overview.html
+++ b/frontend/express/public/core/user-analytics-overview/templates/overview.html
@@ -9,10 +9,7 @@
{{i18n('user-analytics.overview-title')}}
-
-
- {{item.label}}
-
+ {{item.label}}
diff --git a/frontend/express/public/core/user-management/javascripts/countly.views.js b/frontend/express/public/core/user-management/javascripts/countly.views.js
index 9e8c1f6c046..011c875c7d2 100644
--- a/frontend/express/public/core/user-management/javascripts/countly.views.js
+++ b/frontend/express/public/core/user-management/javascripts/countly.views.js
@@ -949,8 +949,20 @@
watch: {
'groups': function() {
if (this.groups.length > 0) {
- // Remove global admin role if user is assigned to any group
- this.$refs.userDrawer.editedObject.global_admin = false;
+ // Remove global admin role if the assigned groups does not have global admin access
+ var groupHasGlobalAdmin = false;
+
+ this.groups.forEach(function(grpId) {
+ var group = groupsModel.data().find(function(grp) {
+ return grpId === grp._id;
+ });
+
+ if (group && group.global_admin === true) {
+ groupHasGlobalAdmin = true;
+ }
+ });
+
+ this.$refs.userDrawer.editedObject.global_admin = groupHasGlobalAdmin;
}
if (this.groups.length === 0) {
@@ -1152,4 +1164,4 @@
countlyVue.container.registerData("user-management/edit-user-drawer", {
component: Drawer
});
-})();
\ No newline at end of file
+})();
diff --git a/frontend/express/public/core/user-management/templates/data-table.html b/frontend/express/public/core/user-management/templates/data-table.html
index b8b8ec331aa..571376f6630 100644
--- a/frontend/express/public/core/user-management/templates/data-table.html
+++ b/frontend/express/public/core/user-management/templates/data-table.html
@@ -1,5 +1,6 @@
{{i18n('management-users.view-title')}}
+
+ {{rowScope.row.full_name}}
+
+
+ {{rowScope.row.username}}
{{i18n('management-users.view-title')}}
sortable="true"
:label="i18n('management-users.role')">
- {{rowScope.row.dispRole}}
+ {{rowScope.row.dispRole}}
+
+ {{rowScope.row.email}}
@@ -109,18 +117,18 @@ {{i18n('management-users.view-title')}}
prop="last_login"
:label="i18n('management-users.last_login')">
-
+
-
- {{ i18n('management-users.edit-user') }}
- {{ i18n('management-users.view-user-logs') }}
- {{ i18n('management-users.reset-failed-logins') }}
- {{ i18n('management-users.delete-user') }}
+
+ {{ i18n('management-users.edit-user') }}
+ {{ i18n('management-users.view-user-logs') }}
+ {{ i18n('management-users.reset-failed-logins') }}
+ {{ i18n('management-users.delete-user') }}
diff --git a/frontend/express/public/javascripts/countly/countly.auth.js b/frontend/express/public/javascripts/countly/countly.auth.js
index f544478410a..725c9aa2163 100644
--- a/frontend/express/public/javascripts/countly/countly.auth.js
+++ b/frontend/express/public/javascripts/countly/countly.auth.js
@@ -39,7 +39,7 @@
}
if (!member.global_admin) {
- var isPermissionObjectExistForAccessType = (typeof member.permission[accessType] === "object" && typeof member.permission[accessType][app_id] === "object");
+ var isPermissionObjectExistForAccessType = (member.permission && typeof member.permission[accessType] === "object" && typeof member.permission[accessType][app_id] === "object");
var memberHasAllFlag = member.permission && member.permission[accessType] && member.permission[accessType][app_id] && member.permission[accessType][app_id].all;
var memberHasAllowedFlag = false;
@@ -96,7 +96,7 @@
return false;
}
if (!member.global_admin) {
- var isPermissionObjectExistForRead = (typeof member.permission.r === "object" && typeof member.permission.r[app_id] === "object");
+ var isPermissionObjectExistForRead = (member.permission && typeof member.permission.r === "object" && typeof member.permission.r[app_id] === "object");
// TODO: make here better. create helper method for these checks
var memberHasAllFlag = member.permission && member.permission.r && member.permission.r[app_id] && member.permission.r[app_id].all;
var memberHasAllowedFlag = false;
diff --git a/frontend/express/public/javascripts/countly/countly.helpers.js b/frontend/express/public/javascripts/countly/countly.helpers.js
index c9f483ef2d3..50b3283efae 100644
--- a/frontend/express/public/javascripts/countly/countly.helpers.js
+++ b/frontend/express/public/javascripts/countly/countly.helpers.js
@@ -341,6 +341,7 @@
payload.autoHide = !msg.sticky;
payload.id = msg.id;
payload.width = msg.width;
+ payload.goTo = msg.goTo;
var colorToUse;
if (countlyGlobal.ssr) {
@@ -393,9 +394,20 @@
* title is the text that will be dispalyed for the backlink url.
*/
CountlyHelpers.goTo = function(options) {
- app.backlinkUrl = options.from;
- app.backlinkTitle = options.title;
- window.location.hash = options.url;
+ if (options.isExternalLink) {
+ window.open(options.url, '_blank', 'noopener,noreferrer');
+ }
+ else if (options.download) {
+ var a = document.createElement('a');
+ a.href = options.url;
+ a.download = options.download;
+ a.click();
+ }
+ else {
+ app.backlinkUrl = options.from;
+ app.backlinkTitle = options.title;
+ window.location.hash = options.url;
+ }
};
/**
diff --git a/frontend/express/public/javascripts/countly/countly.task.manager.js b/frontend/express/public/javascripts/countly/countly.task.manager.js
index 6b20c8126b2..c74ac8a18ca 100644
--- a/frontend/express/public/javascripts/countly/countly.task.manager.js
+++ b/frontend/express/public/javascripts/countly/countly.task.manager.js
@@ -313,7 +313,11 @@
CountlyHelpers.notify({
message: CV.i18n("assistant.taskmanager.longTaskAlreadyRunning.title") + " " + CV.i18n("assistant.taskmanager.longTaskAlreadyRunning.message"),
info: CV.i18n("assistant.taskmanager.longTaskTooLong.info"),
- type: "info"
+ type: "info",
+ goTo: {
+ title: CV.i18n("common.go-to-task-manager"),
+ url: "#/manage/tasks"
+ }
});
},
completed: function(fetchedTasks) {
diff --git a/frontend/express/public/javascripts/countly/vue/components/content.js b/frontend/express/public/javascripts/countly/vue/components/content.js
index a055ca445f6..cbcdd9d32f8 100644
--- a/frontend/express/public/javascripts/countly/vue/components/content.js
+++ b/frontend/express/public/javascripts/countly/vue/components/content.js
@@ -2,6 +2,11 @@
(function(countlyVue) {
Vue.component("cly-content-layout", countlyVue.components.create({
props: {
+ popperClass: {
+ type: String,
+ required: false,
+ default: null
+ },
backgroundColor: {
type: String,
required: false,
@@ -15,6 +20,9 @@
};
},
computed: {
+ containerClass() {
+ return this.popperClass || 'cly-vue-content-builder__layout-main';
+ }
},
template: CV.T('/javascripts/countly/vue/templates/content/content.html'),
methods: {
@@ -148,11 +156,6 @@
Vue.component("cly-content-body", countlyVue.components.create({
props: {
- currentTab: {
- type: String,
- required: false,
- default: null
- },
hideLeftSidebar: {
type: Boolean,
required: false,
@@ -192,7 +195,7 @@
data: function() {
return {
toggleTransition: 'stdt-slide-left',
- isLeftSidebarHidden: this.hideLeftSidebar,
+ isCollapsed: false,
scrollOps: {
vuescroll: {},
scrollPanel: {
@@ -217,7 +220,7 @@
methods: {
collapseBar: function(position) {
if (position === 'left') {
- this.isLeftSidebarHidden = !this.isLeftSidebarHidden;
+ this.isCollapsed = !this.isCollapsed;
}
},
onViewEntered: function() { //?
@@ -402,6 +405,7 @@
dropdown: 'el-select',
input: 'el-input',
switch: 'el-switch',
+ slider: 'el-slider',
'color-picker': 'cly-colorpicker',
'input-number': 'el-input-number',
};
@@ -421,6 +425,8 @@
v-bind="inputProps"
:value="localValue"
@input="updateValue"
+ :min="inputProps && inputProps.min"
+ :max="inputProps && inputProps.max"
class="cly-vue-content-builder__layout-step__component"
:style="[ position !== 'horizontal' ? {\'width\': \'100%\'} : {\'width\': width + \'px\'}]"
>
@@ -588,4 +594,4 @@
`
}));
-}(window.countlyVue = window.countlyVue || {}));
\ No newline at end of file
+}(window.countlyVue = window.countlyVue || {}));
diff --git a/frontend/express/public/javascripts/countly/vue/components/dropdown.js b/frontend/express/public/javascripts/countly/vue/components/dropdown.js
index e3fa09c3392..18cbdf66ca6 100644
--- a/frontend/express/public/javascripts/countly/vue/components/dropdown.js
+++ b/frontend/express/public/javascripts/countly/vue/components/dropdown.js
@@ -1,4 +1,4 @@
-/* global jQuery, Vue, ELEMENT, CV */
+/* global jQuery, Vue, ELEMENT, CV, CountlyHelpers */
(function(countlyVue) {
@@ -612,7 +612,12 @@
methods: {
handleMenuItemClick: function(command, instance) {
if (!this.disabled) {
- this.$emit('command', command, instance);
+ if (command && command.url) {
+ CountlyHelpers.goTo({url: command.url, download: !!command.download, isExternalLink: !!command.isExternalLink});
+ }
+ else {
+ this.$emit('command', command, instance);
+ }
this.$refs.dropdown.handleClose();
}
},
diff --git a/frontend/express/public/javascripts/countly/vue/components/helpers.js b/frontend/express/public/javascripts/countly/vue/components/helpers.js
index 5b56c4f6237..976ecba4dab 100644
--- a/frontend/express/public/javascripts/countly/vue/components/helpers.js
+++ b/frontend/express/public/javascripts/countly/vue/components/helpers.js
@@ -410,7 +410,7 @@
computed: {
tooltipConf: function() {
return {
- content: this.tooltip,
+ content: countlyCommon.unescapeHtml(this.tooltip),
placement: this.placement
};
}
@@ -985,34 +985,36 @@
class="cly-vue-notification__alert-box"
:class="dynamicClasses"
>
-
-
+
+
-
+
-
@@ -1050,6 +1052,7 @@
type: Object
},
customWidth: { default: "", type: String },
+ toast: { default: false, type: Boolean }
},
data: function() {
return {
@@ -1112,6 +1115,20 @@
return this.text;
}
return "";
+ },
+ dynamicStyle: function() {
+ let style = {
+ "display": "flex",
+ "flex-direction": this.toast ? "column" : "row",
+ "width": "100%"
+ };
+ if (this.toast) {
+ style.gap = "5px";
+ }
+ else {
+ style["justify-content"] = "space-between";
+ }
+ return style;
}
},
methods: {
@@ -1276,6 +1293,63 @@
'
});
+ Vue.component("cly-list-drawer", countlyBaseComponent.extend({
+ props: {
+ list: {
+ type: Array,
+ required: true,
+ },
+ dropdownText: {
+ type: String,
+ default: 'Listed item(s) will be affected by this action',
+ required: false,
+ },
+ },
+ data: function() {
+ return {
+ isOpen: false,
+ options: {
+ vuescroll: {
+ sizeStrategy: 'number',
+ },
+ scrollPanel: {
+ initialScrollX: false,
+ },
+ rail: {
+ gutterOfSide: "4px",
+ gutterOfEnds: "16px",
+ keepShow: false,
+ },
+ bar: {
+ background: "#A7AEB8",
+ size: "6px",
+ keepShow: false,
+ }
+ },
+ };
+ },
+ methods: {
+ toggleList: function() {
+ this.isOpen = !this.isOpen;
+ },
+ },
+ template: '
\
+
\
+ {{ dropdownText }}\
+ \
+
\
+
\
+
'
+ }));
+
Vue.component("cly-auto-refresh-toggle", countlyBaseComponent.extend({
template: "
\
\
diff --git a/frontend/express/public/javascripts/countly/vue/components/input.js b/frontend/express/public/javascripts/countly/vue/components/input.js
index 33833c1227d..834381d0a63 100644
--- a/frontend/express/public/javascripts/countly/vue/components/input.js
+++ b/frontend/express/public/javascripts/countly/vue/components/input.js
@@ -28,10 +28,13 @@
},
localValue: {
get: function() {
- return this.value.replace("#", "");
+ var rawValue = this.value || this.resetValue;
+
+ return rawValue.replace("#", "");
},
set: function(value) {
var colorValue = "#" + value.replace("#", "");
+
if (colorValue.match(HEX_COLOR_REGEX)) {
this.setColor({hex: colorValue});
}
@@ -43,10 +46,13 @@
},
methods: {
setColor: function(color) {
- this.$emit("input", color.hex8);
+ var finalColor = color.hex8 || color.hex;
+
+ this.$emit("input", finalColor);
},
reset: function() {
this.setColor({hex: this.resetValue});
+ this.close();
},
open: function() {
this.isOpened = true;
diff --git a/frontend/express/public/javascripts/countly/vue/components/layout.js b/frontend/express/public/javascripts/countly/vue/components/layout.js
index 7466b84c3ca..0e83ad0698e 100644
--- a/frontend/express/public/javascripts/countly/vue/components/layout.js
+++ b/frontend/express/public/javascripts/countly/vue/components/layout.js
@@ -137,7 +137,7 @@
var PersistentNotifications = {
template: '
\
- \
+ \
',
computed: {
persistentNotifications: function() {
diff --git a/frontend/express/public/javascripts/countly/vue/components/sidebar.js b/frontend/express/public/javascripts/countly/vue/components/sidebar.js
index 632b875f1cd..efd541993b3 100644
--- a/frontend/express/public/javascripts/countly/vue/components/sidebar.js
+++ b/frontend/express/public/javascripts/countly/vue/components/sidebar.js
@@ -742,6 +742,9 @@
},
helpCenterTarget: function() {
return this.enableGuides ? '_self' : "_blank";
+ },
+ isCommunityEdition: function() {
+ return countlyGlobal.countlyTypeCE;
}
},
methods: {
@@ -894,6 +897,12 @@
return menu;
});
+ },
+ handleButtonClick: function() {
+ CountlyHelpers.goTo({
+ url: "https://flex.countly.com",
+ isExternalLink: true
+ });
}
},
mounted: function() {
diff --git a/frontend/express/public/javascripts/countly/vue/core.js b/frontend/express/public/javascripts/countly/vue/core.js
index 8a36d1dc41d..1a9e40f53f6 100644
--- a/frontend/express/public/javascripts/countly/vue/core.js
+++ b/frontend/express/public/javascripts/countly/vue/core.js
@@ -713,7 +713,7 @@
var NotificationToastsView = {
template: '
\
- \
+ \
',
store: _vuex.getGlobalStore(),
computed: {
diff --git a/frontend/express/public/javascripts/countly/vue/templates/breakdown.html b/frontend/express/public/javascripts/countly/vue/templates/breakdown.html
index a15f6c23328..85f474834de 100644
--- a/frontend/express/public/javascripts/countly/vue/templates/breakdown.html
+++ b/frontend/express/public/javascripts/countly/vue/templates/breakdown.html
@@ -3,7 +3,7 @@
{{name}}
-
+
diff --git a/frontend/express/public/javascripts/countly/vue/templates/content/content-body.html b/frontend/express/public/javascripts/countly/vue/templates/content/content-body.html
index 26837f48863..27b48dd0e76 100644
--- a/frontend/express/public/javascripts/countly/vue/templates/content/content-body.html
+++ b/frontend/express/public/javascripts/countly/vue/templates/content/content-body.html
@@ -1,6 +1,6 @@
-
+
-
+
Screens
@@ -22,7 +22,14 @@ Screens
-
+
+
+
diff --git a/frontend/express/public/javascripts/countly/vue/templates/content/content-header.html b/frontend/express/public/javascripts/countly/vue/templates/content/content-header.html
index ac130e25d76..08e01102c7d 100644
--- a/frontend/express/public/javascripts/countly/vue/templates/content/content-header.html
+++ b/frontend/express/public/javascripts/countly/vue/templates/content/content-header.html
@@ -16,7 +16,7 @@
diff --git a/frontend/express/public/localization/dashboard/dashboard.properties b/frontend/express/public/localization/dashboard/dashboard.properties
index d8e92be3cc7..0eaa9b14346 100644
--- a/frontend/express/public/localization/dashboard/dashboard.properties
+++ b/frontend/express/public/localization/dashboard/dashboard.properties
@@ -233,8 +233,8 @@ common.custom-events = Custom Events
common.none = None
common.emtpy-view-title = ...hmm, seems empty here
common.emtpy-view-subtitle = No data found
-common.no-widget-text = Add a widget to create dashboard and see
some informative explanation
-common.no-dashboard-text = Create dashboard and see
some informative explanation
+common.no-widget-text = Add a widget to create dashboard and see some informative explanation
+common.no-dashboard-text = Create dashboard and see some informative explanation
common.create-dashboard = + Create a Dashboard
common.add-widget = + Add a Widget
common.sunday = Sunday
@@ -255,6 +255,11 @@ common.selected-with-count ={0} Selected
common.selected = Selected
common.select-all-with-count = Select all {0}
common.deselect = Deselect
+common.session = Session
+common.sessions = Sessions
+common.go-to-settings = Go to settings
+common.go-to-app-settings = Go to application management
+common.go-to-task-manager = Go to Task Manager
#vue
common.undo = Undo
@@ -346,7 +351,7 @@ assistant.taskmanager.longTaskTooLong.title = This request is running for too lo
assistant.taskmanager.longTaskTooLong.message = We have switched it to report manager and will notify you when it is finished.
assistant.taskmanager.longTaskTooLong.info = Check its status in Utilities -> Report Manager
assistant.taskmanager.longTaskAlreadyRunning.title = A similar report is already running.
-assistant.taskmanager.longTaskAlreadyRunning.message = Looks like report with same parameters already running in
report manager
+assistant.taskmanager.longTaskAlreadyRunning.message = Looks like report with same parameters already running in report manager
assistant.taskmanager.completed.title = Report completed for {1}
assistant.taskmanager.completed.message = Results are ready for {0} reports. Check report manager to view them.
assistant.taskmanager.errored.title = Failed to generate report for {0}
@@ -435,6 +440,9 @@ sidebar.dashboard-tooltip = Dashboards
sidebar.main-menu = Main Menu
sidebar.my-profile = My Profile
sidebar.copy-api-key-success-message = Api Key has been copied to clipboard!
+sidebar.banner.text = You are using a free plan.
+sidebar.banner.upgrade = Upgrade and get more.
+sidebar.banner.upgrade-button = Manage your plan
#dashboard
dashboard.apply = Apply
@@ -704,9 +712,9 @@ events.top-events.24hours = 24-Hours
events.top-events.30days = 30-Days
events.top-events.yesterday = Yesterday
events.top-events.info-text = Updated {0} hrs ago
-events.max-event-key-limit = Maximum limit of unique event keys ({0}) has been reached. Limit can be
adjusted .
-events.max-segmentation-limit = Maximum limit of segmentations ({0}) in current event "{1}" has been reached. Limit can be
adjusted .
-events.max-unique-value-limit = Maximum limit of unique values ({0}) in current event segmentation "{1}" has been reached. Limit can be
adjusted .
+events.max-event-key-limit = Maximum limit of unique event keys ({0}) has been reached. Limit can be adjusted.
+events.max-segmentation-limit = Maximum limit of segmentations ({0}) in current event "{1}" has been reached. Limit can be adjusted.
+events.max-unique-value-limit = Maximum limit of unique values ({0}) in current event segmentation "{1}" has been reached. Limit can be adjusted.
events.event-group-drawer-create = Create new Event Group
events.event-group-name = Event Group name
@@ -797,9 +805,9 @@ management-applications.app-locked = Application is locked.
management-applications.icon-error = Only jpg, png and gif image formats are allowed
management-applications.no-app-warning = In order to start collecting data you need to add an application to your account.
management-applications.app-key-change-warning-title = Changing the App key
-management-applications.app-key-change-warning = Changing the app key will cause all users from this point on to be recorded as new users even if they used your application before.
This action is only recommended if you are migrating an application from another server or changing the app key of a new application.
+management-applications.app-key-change-warning = Changing the app key will cause all users from this point on to be recorded as new users even if they used your application before. This action is only recommended if you are migrating an application from another server or changing the app key of a new application.
management-applications.app-key-change-warning-confirm = Continue, change the app key
-management-applications.app-key-change-warning-EE = Changing the app key will cause all users from this point on to be recorded as new users even if they used your application before.
This action is only recommended if you are migrating an application from another server or changing the app key of a new application.
If your intention was to change the app key to stop collecting data for this application, recommended way of doing so is using Filtering Rules plugin.
+management-applications.app-key-change-warning-EE = Changing the app key will cause all users from this point on to be recorded as new users even if they used your application before. This action is only recommended if you are migrating an application from another server or changing the app key of a new application. If your intention was to change the app key to stop collecting data for this application, recommended way of doing so is using Filtering Rules plugin.
management-applications.first-app-message2 = Great\! You can now embed Countly SDK into your application and start viewing your stats instantly. Don't forget to get your App Key from above.
management-applications.types.mobile = Mobile
management-applications.checksum-salt = Salt for checksum
@@ -1088,7 +1096,7 @@ token_manager.table.expiration-description = Set expiration time for token
token_manager.table.purpose = Description
token_manager.table.token-description = Token Description
token_manager.table.purpose-desc = Some information to help user identify created token.
-token_manager.table.endpoint-desc = You can limit token to a single or multiple endpoints.
Given endpoints are interpreted as regular expressions.
+token_manager.table.endpoint-desc = You can limit token to a single or multiple endpoints. Given endpoints are interpreted as regular expressions.
token_manager.table.multi-desc = Token can be used multiple times
token_manager.table.apps-title = Token Usage
token_manager.table.apps-limit = Allow token to be used only in some apps
@@ -1124,8 +1132,8 @@ token_manager.parameter = Parameter
token_manager.select-apps = Select Apps
token_manager.select-time-unit = Select time unit
token_manager.token-expiration-time = Expiration Time
-token_manager.LoginAuthToken-description = This token is created when creating dashboard screenshots.
If you are not currently rendering dashboard images, you can delete this token.
-token_manager.LoggedInAuth-description = This token is used for keeping users session.
Deleting it will log out user currently using it to keep session.
+token_manager.LoginAuthToken-description = This token is created when creating dashboard screenshots. If you are not currently rendering dashboard images, you can delete this token.
+token_manager.LoggedInAuth-description = This token is used for keeping users session. Deleting it will log out user currently using it to keep session.
version_history.page-title = Countly version history
diff --git a/frontend/express/public/stylesheets/styles/blocks/_sidebar.scss b/frontend/express/public/stylesheets/styles/blocks/_sidebar.scss
index 7f284565461..6c64cb344f5 100644
--- a/frontend/express/public/stylesheets/styles/blocks/_sidebar.scss
+++ b/frontend/express/public/stylesheets/styles/blocks/_sidebar.scss
@@ -378,6 +378,26 @@ $bg-color: #24292e;
width: 224px;
font-size: 10px;
background-color: #24292e;
+
+ &__banner {
+ background-color: #191C20;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ padding: 16px;
+ margin-bottom: 30%;
+ cursor: default;
+ .text {
+ font-size: 14px;
+ color: #fff;
+ }
+ .button {
+ width: 100%;
+ margin-top: 12px;
+ }
+ }
}
.cly-icon {
diff --git a/frontend/express/public/stylesheets/vue/clyvue.scss b/frontend/express/public/stylesheets/vue/clyvue.scss
index 035f00d3c81..d0e8a66a78f 100644
--- a/frontend/express/public/stylesheets/vue/clyvue.scss
+++ b/frontend/express/public/stylesheets/vue/clyvue.scss
@@ -4267,6 +4267,18 @@
.el-input-group__append {
padding: 0 8px !important;
}
+
+ /* .el-slider__bar {
+ background-color: #0166D6;
+ }
+
+ .el-slider__button {
+ border-color: #0166D6;
+ } */
+
+ .el-slider__runway {
+ background-color: #E2E4E8;
+ }
}
&__header {
font-family: Inter;
@@ -4324,7 +4336,7 @@
&__left {
display: flex;
align-items: center;
- width: 350px;
+ // width: 350px;
}
&__icon {
@@ -4354,6 +4366,7 @@
&__info-title {
h4 {
margin: 0;
+ max-width: 400px;
}
&__input {
width: 200px;
@@ -4501,6 +4514,47 @@
}
}
+.cly-list-drawer {
+ .cly-list-drawer__text-clickable {
+ color: #0166D6;
+ cursor: pointer;
+ }
+ .cly-list-drawer__text-clickable i {
+ transition: transform 0.3s, -webkit-transform .3s;
+ }
+ .rotate-icon {
+ transform: rotate(180deg);
+ }
+ .cly-io {
+ display: inline-block;
+ }
+ .__view {
+ min-height: auto !important;
+ }
+
+ &__list {
+ height: 150px;
+ background-color: #F6F6F6;
+ overflow: auto;
+ border-radius: 4px;
+ ul {
+ list-style: none;
+ padding: 0px;
+ margin: 0px;
+ li {
+ padding-left: 16px;
+ padding-top: 0.5rem;
+ }
+ li:first-child {
+ padding-top: 1rem;
+ }
+ li:last-child {
+ padding-bottom: 1rem;
+ }
+ }
+ }
+}
+
.cly-vue-device-selector {
background-color: #FFF;
border-radius: 10px;
@@ -4508,4 +4562,4 @@
cursor: pointer;
border-radius: 8px;
}
-}
\ No newline at end of file
+}
diff --git a/plugins/alerts/README.md b/plugins/alerts/README.md
new file mode 100644
index 00000000000..a2567227028
--- /dev/null
+++ b/plugins/alerts/README.md
@@ -0,0 +1,59 @@
+# Alerts plugin
+
+The Alerts plugin in Countly is a reactive tool designed to keep you informed about critical changes in your application’s metrics, even when you’re not monitoring dashboards. It sends email notifications when specific conditions on important metrics are met, enabling you to respond quickly to potential issues. This feature helps ensure your app maintains high performance and provides a positive user experience by alerting you to areas that may need immediate attention.
+
+## File structure
+File structure follows usual Countly plugin structure
+```
+alerts/
+├── api/
+ ├── alertModules/
+ │ ├── cohorts.js # cohort alert checker
+ │ ├── crashes.js # crash alert checker
+ │ ├── dataPoints.js # data points alert checker
+ │ ├── events.js # events alert checker
+ │ ├── nps.js # NPS alert checker
+ │ ├── rating.js # rating alert checker
+ │ ├── revenue.js # revenue alert checker
+ │ ├── sessions.js # sessions alert checker
+ │ ├── survey.js # survey alert checker
+ │ ├── users.js # users alert checker
+ │ └── views.js # views alert checker
+ ├── jobs/monitor.js # alert monitoring job
+ ├── parts/
+ │ ├── common-lib.js
+ │ └── utils.js
+ └── api.js # alert management API and all API endpoints
+├── frontend/
+│ ├── public/
+│ │ ├── javascripts
+│ │ │ ├── countly.models.js # model code. Facilitates requests to backend (CRUD)
+│ │ │ └── countly.views.js # views code. Alerts view, Dashboard home widget
+│ │ ├── localization # All localization files
+│ │ ├── stylesheets
+│ │ └── templates
+│ │ ├── email.html # template for email
+│ │ └── vue-main.html # template for Alerts view (including home and drawer)
+│ └── app.js
+├── install.js
+├── package.json
+├── README.md
+└── tests.js # plugin tests
+```
+
+## Key Features
+
+- **Customizable Alerts:** Define specific conditions for metrics such as crashes, cohorts, data points, events, Net Promoter Score (NPS), online users, rating, revenue, sessions, surveys, users, and views. Get notified whenever these conditions are met.
+- **Real-Time Notifications:** Receive email alerts for immediate awareness of changes in your metrics.
+- **Detailed Monitoring:** Track a broad range of metrics, including user engagement, performance, user feedback, and error rates.
+- **Easy Setup:** Simple configuration allows you to set and customize alerts quickly to fit your needs.
+
+## Example Use Case
+
+Imagine you’ve released a new version of your app. Although it passed all tests, some critical bugs may still slip through. These bugs might prevent users from fully using the app. By setting up alerts for sudden spikes in crashes or decreased user activity, you can catch these issues early and work to resolve them, ensuring minimal disruption.
+
+## Generate alerts job
+
+Job name: alerts:monitor
+
+Job is set to run each hour, each day or each month according to the configuration set in the creation of alerts. It checks all the alert conditions defined by the users periodically and triggers email notifications if any conditions are met.
\ No newline at end of file
diff --git a/plugins/alerts/api/api.js b/plugins/alerts/api/api.js
index a691ee982c8..481b45e5506 100644
--- a/plugins/alerts/api/api.js
+++ b/plugins/alerts/api/api.js
@@ -44,7 +44,11 @@ const PERIOD_TO_TEXT_EXPRESSION_MAPPER = {
if (typeof alertID === 'string') {
alertID = common.db.ObjectID(alertID);
}
- common.db.collection("jobs").remove({ 'data.alertID': alertID }, function() {
+ common.db.collection("jobs").remove({ 'data.alertID': alertID }, function(err) {
+ if (err) {
+ log.e('delete job failed, alertID:', alertID, err);
+ return;
+ }
log.d('delete job, alertID:', alertID);
if (callback) {
callback();
@@ -241,8 +245,8 @@ const PERIOD_TO_TEXT_EXPRESSION_MAPPER = {
);
}
catch (err) {
- log.e('Parse alert failed', alertConfig);
- common.returnMessage(params, 500, "Failed to create an alert");
+ log.e('Parse alert failed', alertConfig, err);
+ common.returnMessage(params, 500, "Failed to create an alert" + err.message);
}
});
return true;
@@ -284,8 +288,8 @@ const PERIOD_TO_TEXT_EXPRESSION_MAPPER = {
);
}
catch (err) {
- log.e('delete alert failed', alertID);
- common.returnMessage(params, 500, "Failed to delete an alert");
+ log.e('delete alert failed', alertID, err);
+ common.returnMessage(params, 500, "Failed to delete an alert" + err.message);
}
});
return true;
@@ -411,8 +415,8 @@ const PERIOD_TO_TEXT_EXPRESSION_MAPPER = {
});
}
catch (err) {
- log.e('get alert list failed');
- common.returnMessage(params, 500, "Failed to get alert list");
+ log.e('get alert list failed', err);
+ common.returnMessage(params, 500, "Failed to get alert list" + err.message);
}
});
return true;
diff --git a/plugins/alerts/frontend/public/javascripts/countly.views.js b/plugins/alerts/frontend/public/javascripts/countly.views.js
index 3147a38370c..1ba08964aa4 100644
--- a/plugins/alerts/frontend/public/javascripts/countly.views.js
+++ b/plugins/alerts/frontend/public/javascripts/countly.views.js
@@ -992,10 +992,10 @@
if (newState.alertBy === "email") {
- if (newState?.allGroups?.length) {
+ if (newState.allGroups?.length) {
this.selectedRadioButton = "toGroup";
}
- if (newState?.alertValues?.length) {
+ if (newState.alertValues?.length) {
this.selectedRadioButton = "specificAddress";
}
}
diff --git a/plugins/alerts/frontend/public/localization/alerts.properties b/plugins/alerts/frontend/public/localization/alerts.properties
index 79ec4f2f000..2601e3bdfe0 100644
--- a/plugins/alerts/frontend/public/localization/alerts.properties
+++ b/plugins/alerts/frontend/public/localization/alerts.properties
@@ -80,7 +80,7 @@ alert.add-number=Add Number
alert.add-alert=Add Alert
alert.save-alert=Save Alert
alert.save=Create
-alert.tips = Overview of all alerts set up. Create new alerts to receive emails when
specific conditions related to metrics are met.
+alert.tips = Overview of all alerts set up. Create new alerts to receive emails when specific conditions related to metrics are met.
alerts.application-tooltip= Data Points is the only data type available for your selected Application.
alerts.update-status-success = Successfully update status
alerts.save-alert-success= Alert saved
diff --git a/plugins/browser/frontend/public/templates/browser.html b/plugins/browser/frontend/public/templates/browser.html
index bf15e73a7c5..d681ae2b9db 100644
--- a/plugins/browser/frontend/public/templates/browser.html
+++ b/plugins/browser/frontend/public/templates/browser.html
@@ -5,10 +5,7 @@
>
-
-
- {{item.label}}
-
+ {{item.label}}
diff --git a/plugins/compare/frontend/public/javascripts/countly.models.js b/plugins/compare/frontend/public/javascripts/countly.models.js
index b9f3fbc2691..49a3c328a16 100644
--- a/plugins/compare/frontend/public/javascripts/countly.models.js
+++ b/plugins/compare/frontend/public/javascripts/countly.models.js
@@ -201,9 +201,9 @@
return lineLegend;
},
getAllEventsList: function(eventsList, groupList) {
- var map = eventsList.map || {};
var allEvents = [];
if (eventsList) {
+ var map = eventsList.map || {};
eventsList.list.forEach(function(item) {
if (!map[item] || (map[item] && (map[item].is_visible || map[item].is_visible === undefined))) {
var label;
@@ -245,9 +245,9 @@
return obj;
},
getTableStateMap: function(eventsList, groupList) {
- var map = eventsList.map || {};
var allEvents = {};
if (eventsList) {
+ var map = eventsList.map || {};
eventsList.list.forEach(function(item) {
if (!map[item] || (map[item] && (map[item].is_visible || map[item].is_visible === undefined))) {
allEvents[countlyCompareEvents.helpers.decode(item)] = true;
diff --git a/plugins/compliance-hub/README.md b/plugins/compliance-hub/README.md
new file mode 100644
index 00000000000..c302d86a98e
--- /dev/null
+++ b/plugins/compliance-hub/README.md
@@ -0,0 +1,44 @@
+# Compliance Hub plugin
+
+The Compliance Hub plugin for Countly is designed to manage user consent and related metrics, ensuring compliance with various data protection regulations. It provides a comprehensive API for handling user consent, integrating seamlessly with the Countly analytics platform. The plugin includes robust functionality for managing and displaying consent data, featuring templates for consent history, export history, and user consent data tables. Additionally, it offers a dashboard for visualizing consent metrics, helping organizations maintain transparency and compliance.
+
+## File structure
+File structure follows usual Countly plugin structure
+```
+compliance-hub/
+├── api/
+│ └── api.js # compliance hub API for managing user consent and related metrics
+├── frontend/
+│ ├── public
+│ │ ├── javascripts
+│ │ │ ├── countly.models.js # model code for consent management
+│ │ │ └── countly.views.js # views code.
+│ │ ├── localization # all localization files
+│ │ ├── stylesheets
+│ │ └── templates
+│ │ ├── consentHistory.html # template for consent history table
+│ │ ├── exportHistory.html # template for export history
+│ │ ├── main.html # compliance hub header
+│ │ ├── metrics.html # consent metrics dashboard
+│ │ ├── user.html # user consent data table
+│ │ └── userConsentHistory.html # user consent history table
+│ └── app.js
+├── install.js
+├── package.json
+├── README.md
+└── tests.js # plugin tests
+```
+
+## Key Features
+
+- **Collect User Consents:** Prompt first-time users to consent to data collection, detailing which types of data (e.g., sessions, crashes, views) will be collected. No data is sent unless the user opts in.
+- **Manage User Requests:** The "Consents" tab (available in Enterprise Edition) lets admins view and fulfill user requests for data export or deletion.
+- **SDK Integration:** Countly SDKs (iOS, Android, Node.js, Web) support flexible consent management, allowing opt-in/opt-out on a per-feature basis. SDKs default to opt-in for backward compatibility but can be configured to require opt-in consent at initialization.
+
+## Using the Compliance Hub
+
+Access the Compliance Hub via Main Menu > Utilities > Compliance Hub. The Compliance Hub offers the following views:
+1. **Metrics View:** Track opt-ins and opt-outs across various features (e.g., sessions, crashes) over time in a time-series graph.
+2. **Users View:** List users with consent histories. Each entry shows user ID, device info, app version, and consent types. Options include viewing consent history, exporting user data, and purging data if required.
+3. **Consent History:** A complete list of all opt-in and opt-out actions across metrics, allowing for easy tracking.
+4. **Export/Purge History:** See a record of all data export and deletion actions for compliance tracking.
\ No newline at end of file
diff --git a/plugins/compliance-hub/frontend/public/javascripts/countly.views.js b/plugins/compliance-hub/frontend/public/javascripts/countly.views.js
index 4b2bcffd527..ae448f49622 100644
--- a/plugins/compliance-hub/frontend/public/javascripts/countly.views.js
+++ b/plugins/compliance-hub/frontend/public/javascripts/countly.views.js
@@ -13,9 +13,6 @@
this.$store.dispatch("countlyConsentManager/fetchUserDataResource");
},
methods: {
- switchToConsentHistory: function(uid) {
- window.location.hash = "#/manage/compliance/history/" + uid;
- },
deleteUserData: function(uid) {
var self = this;
CountlyHelpers.confirm(this.i18n("app-users.delete-userdata-confirm"), "popStyleGreen", function(result) {
@@ -77,8 +74,21 @@
downloadExportedData: function(uid) {
var win = window.open(countlyCommon.API_PARTS.data.r + "/app_users/download/appUser_" + countlyCommon.ACTIVE_APP_ID + "_" + uid + "?auth_token=" + countlyGlobal.auth_token + "&app_id=" + countlyCommon.ACTIVE_APP_ID, '_blank');
win.focus();
+ },
+ handleCommand: function(command, uid) {
+ if (command === "deleteUserData") {
+ this.deleteUserData(uid);
+ }
+ else if (command === "exportUserData") {
+ this.exportUserData(uid);
+ }
+ else if (command === "deleteExport") {
+ this.deleteExport(uid);
+ }
+ else if (command === "downloadExportedData") {
+ this.downloadExportedData(uid);
+ }
}
-
}
});
var ConsentView = countlyVue.views.create({
diff --git a/plugins/compliance-hub/frontend/public/templates/consentHistory.html b/plugins/compliance-hub/frontend/public/templates/consentHistory.html
index e41920da5c3..72140802132 100644
--- a/plugins/compliance-hub/frontend/public/templates/consentHistory.html
+++ b/plugins/compliance-hub/frontend/public/templates/consentHistory.html
@@ -18,46 +18,46 @@
-
+
-
Device ID
+
Device ID
-
{{props.row.device_id}}
+
{{props.row.device_id}}
-
{{i18n("consent.opt-i")}}
+
{{i18n("consent.opt-i")}}
-
+
{{props.row.optin.join(',')}}
-
{{i18n("consent.opt-o")}}
+
{{i18n("consent.opt-o")}}
-
+
{{props.row.optout.join(',')}}
-
Device
+
Device
-
+
{{props.row.d + "(" + props.row.p + " " + props.row.pv + ")"}}
-
App version
+
App version
@@ -65,35 +65,42 @@
+
+
+ {{scope.row.device_id}}
+
+
+
+
+ {{scope.row.uid}}
+
+
-
{{i18n("consent.opt-i")}}
-
{{i18n("consent.opt-o")}}
+
{{i18n("consent.opt-i")}}
+
{{i18n("consent.opt-o")}}
-
-
-
{{i18n("consent.opt-i")}} for {{rowScope.row.optin.length}} feature(s)
+
{{i18n("consent.opt-i")}} for {{rowScope.row.optin.length}} feature(s)
-
{{i18n("consent.opt-o")}} from {{rowScope.row.optout.length}}
+
{{i18n("consent.opt-o")}} from {{rowScope.row.optout.length}}
feature(s)
-
-
+
{{rowScope.row.time}}
-
+
diff --git a/plugins/compliance-hub/frontend/public/templates/exportHistory.html b/plugins/compliance-hub/frontend/public/templates/exportHistory.html
index 701b1582903..7708143a7e2 100644
--- a/plugins/compliance-hub/frontend/public/templates/exportHistory.html
+++ b/plugins/compliance-hub/frontend/public/templates/exportHistory.html
@@ -22,18 +22,31 @@
-
+
+
+
+ {{scope.row.u}}
+
+
+
+ {{scope.row.ip}}
+
-
+
+
+
+ {{rowScope.row.time}}
+
+
diff --git a/plugins/compliance-hub/frontend/public/templates/metrics.html b/plugins/compliance-hub/frontend/public/templates/metrics.html
index 16385e17078..7e662313bbc 100644
--- a/plugins/compliance-hub/frontend/public/templates/metrics.html
+++ b/plugins/compliance-hub/frontend/public/templates/metrics.html
@@ -1,67 +1,67 @@
-
-
-
- Consent Requests for
-
-
-
-
-
-
-
+
+
+
+ Consent Requests for
+
+
-
-
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
{{userDatalegend.label}}
+
+
+
+
{{formatTableNumber(userDatalegend.value)}}
+
+
+
+
{{userDatalegend.percentage}}
+
+
+
+
+
+
+
+
-
{{userDatalegend.label}}
+
{{purgeDatalegend.label}}
-
{{formatTableNumber(userDatalegend.value)}}
-
-
-
-
{{userDatalegend.percentage}}
+
{{formatTableNumber(purgeDatalegend.value)}}
+
+
+
+
{{purgeDatalegend.percentage}}
-
-
-
-
-
-
-
{{purgeDatalegend.label}}
-
-
-
-
{{formatTableNumber(purgeDatalegend.value)}}
-
-
-
-
{{purgeDatalegend.percentage}}
-
-
-
-
-
-
+
+
-
\ No newline at end of file
+
+
\ No newline at end of file
diff --git a/plugins/compliance-hub/frontend/public/templates/user.html b/plugins/compliance-hub/frontend/public/templates/user.html
index a8a9a3bc892..06b24d2b8f2 100644
--- a/plugins/compliance-hub/frontend/public/templates/user.html
+++ b/plugins/compliance-hub/frontend/public/templates/user.html
@@ -1,30 +1,40 @@
-
+
+
+
+ {{scope.row.did}}
+
+
-
+
{{rowScope.row.d}}
-
+
-
+
+
+ {{rowScope.row.av}}
+
+
-
{{i18n("consent.opt-i")}}
-
-
{{i18n("consent.opt-o")}}
-
+
{{i18n("consent.opt-i")}}
+
+
{{i18n("consent.opt-o")}}
+
-
+
-
@@ -34,34 +44,19 @@
- {{rowScope.row.time}}
-
+
+ {{rowScope.row.time}}
+
-
-
- {{i18n("consent.go-history")}}
-
-
-
- {{i18n("app-users.export-userdata")}}
-
-
-
- {{i18n("app-users.download-export")}}
-
-
-
-
- {{i18n("app-users.delete-export")}}
-
-
-
-
- {{i18n("app-users.delete-userdata")}}
-
+
+ {{i18n("consent.go-history")}}
+ {{i18n("app-users.export-userdata")}}
+ {{i18n("app-users.download-export")}}
+ {{i18n("app-users.delete-export")}}
+ {{i18n("app-users.delete-userdata")}}
diff --git a/plugins/crashes/api/api.js b/plugins/crashes/api/api.js
index a080549993a..891f1d652c2 100644
--- a/plugins/crashes/api/api.js
+++ b/plugins/crashes/api/api.js
@@ -464,7 +464,12 @@ plugins.setConfigs("crashes", {
}
updateUser.hadAnyNonfatalCrash = report.ts;
}
- let updateData = { $inc: {} };
+
+ if ('app_version' in report && typeof report.app_version !== 'string') {
+ report.app_version += '';
+ }
+ let updateData = {$inc: {}};
+
updateData.$inc["data.crashes"] = 1;
if (Object.keys(updateUser).length) {
updateData.$set = updateUser;
diff --git a/plugins/crashes/frontend/public/javascripts/countly.views.js b/plugins/crashes/frontend/public/javascripts/countly.views.js
index 58f60fb34ed..2f32ce9f589 100644
--- a/plugins/crashes/frontend/public/javascripts/countly.views.js
+++ b/plugins/crashes/frontend/public/javascripts/countly.views.js
@@ -451,7 +451,9 @@
formatDate: function(row, col, cell) {
return moment(cell * 1000).format("lll");
},
- hasDrillPermission: countlyAuth.validateRead('drill')
+ hasDrillPermission: countlyAuth.validateRead('drill'),
+ showDeleteDialog: false,
+ selectedGroups: [],
};
},
computed: {
@@ -552,7 +554,17 @@
},
loading: function() {
return this.$store.getters["countlyCrashes/overview/loading"];
- }
+ },
+ confirmDialogTitle: function() {
+ var title = "crashes.confirm-action-title";
+ title = this.selectedGroups.length > 1 ? title + "-plural" : title;
+ return CV.i18n(title);
+ },
+ confirmDialogText: function() {
+ var text = "crashes.groups-confirm-delete";
+ text = this.selectedGroups.length > 1 ? text + "-plural" : text;
+ return CV.i18n(text, this.selectedGroups.length);
+ },
},
methods: {
refresh: function(force) {
@@ -575,7 +587,9 @@
},
handleSelectionChange: function(selectedRows, force = false) {
var self = this;
+ this.selectedGroups = [];
this.$data.selectedCrashgroups = selectedRows.map(function(row) {
+ self.selectedGroups.push(row.name);
return row._id;
});
if (force) {
@@ -605,33 +619,30 @@
promise = this.$store.dispatch("countlyCrashes/overview/setSelectedAsShown", this.$data.selectedCrashgroups);
}
else if (state === "delete") {
- CountlyHelpers.confirm(jQuery.i18n.prop("crashes.confirm-delete", 1), "red", function(result) {
- if (result) {
- self.$store.dispatch("countlyCrashes/overview/setSelectedAsDeleted", self.$data.selectedCrashgroups)
- .then(function(response) {
- if (Array.isArray(response.result)) {
- var itemList = response.result.reduce(function(acc, curr) {
- acc += "" + curr + " ";
- return acc;
- }, "");
- CountlyHelpers.alert("", "red", { title: CV.i18n("crashes.alert-fails") });
- }
- else {
- CountlyHelpers.notify({
- title: jQuery.i18n.map["systemlogs.action.crash_deleted"],
- message: jQuery.i18n.map["systemlogs.action.crash_deleted"]
- });
- }
- }).finally(function() {
- // Reset selection if command is delete or hide
- // if (["delete", "hide"].includes(state)) {
- self.selectedCrashgroups = [];
- self.$refs.dataTable.$refs.elTable.clearSelection();
- // }
+ self.$store.dispatch("countlyCrashes/overview/setSelectedAsDeleted", self.$data.selectedCrashgroups)
+ .then(function(response) {
+ if (Array.isArray(response.result)) {
+ var itemList = response.result.reduce(function(acc, curr) {
+ acc += "" + curr + " ";
+ return acc;
+ }, "");
+ CountlyHelpers.alert("", "red", { title: CV.i18n("crashes.alert-fails") });
+ }
+ else {
+ CountlyHelpers.notify({
+ title: jQuery.i18n.map["systemlogs.action.crash_deleted"],
+ message: jQuery.i18n.map["systemlogs.action.crash_group_deleted"]
});
- }
- });
-
+ }
+ }).finally(function() {
+ // Reset selection if command is delete or hide
+ // if (["delete", "hide"].includes(state)) {
+ self.selectedCrashgroups = [];
+ self.$refs.dataTable.$refs.elTable.clearSelection();
+ self.closeDeleteForm();
+ self.refresh();
+ // }
+ });
}
if (typeof promise !== "undefined") {
@@ -676,7 +687,13 @@
},
unpatchSelectedGroups: function() {
this.handleSelectionChange([], true);
- }
+ },
+ showDeleteForm: function() {
+ this.showDeleteDialog = true;
+ },
+ closeDeleteForm: function() {
+ this.showDeleteDialog = false;
+ },
},
beforeCreate: function() {
var query = {};
diff --git a/plugins/crashes/frontend/public/localization/crashes.properties b/plugins/crashes/frontend/public/localization/crashes.properties
index aa8ae840157..3144bead43b 100644
--- a/plugins/crashes/frontend/public/localization/crashes.properties
+++ b/plugins/crashes/frontend/public/localization/crashes.properties
@@ -2,7 +2,7 @@
crashes.title = Crashes
crashes.plugin-title = Crash analytics
crashes.plugin-description = See actionable information about crashes and exceptions including which users are impacted
-crashes.not-found = This crash report cannot be viewed. Possible reasons: You are trying to view a crash report that you do not have access to or Link to this crash is invalid.
+crashes.not-found = This crash report cannot be viewed. Possible reasons: You are trying to view a crash report that you do not have access to or the link to this crash is invalid.
crashes.search = Search for Error
crashes.all = All
crashes.show = Show
@@ -136,6 +136,9 @@ crashes.confirm-action-hide = Are you sure you want to hide {0} item(s)
crashes.confirm-action-show = Are you sure you want to unhide {0} item(s)
crashes.confirm-action-resolving = Are you sure you want to move {0} item(s) to resolving state?
+crashes.confirm-action-title = Delete Crash Group
+crashes.confirm-action-title-plural = Delete Crash Groups
+crashes.yes-delete = Yes, delete group
crashes.stacktrace = Stacktrace
crashes.download-stacktrace = Download stacktrace
@@ -177,6 +180,10 @@ crashes.help-free-users= Number of users who have not experienced a crash for th
crashes.help-free-sessions = Number of sessions during which the selected crash did not occur in the selected time period, expressed as a percentage of the total number of sessions within that time period.
crashes.help-crash-fatality = Number of fatal crashes, expressed as a percentage of the total number of crashes that have occurred.
+crashes.groups-confirm-delete= 1 item will be affected by this action
+crashes.groups-confirm-delete-plural= {0} items will be affected by this action
+crashes.groups-want-to-delete= Do you want to delete crash group(s) permanently? This action is not reversible.
+
crashes.report_limit = Amount of reports displayed
crashes.total_overall=OVERALL
crashes.fatal_crash_count=Fatal Crash Count
@@ -221,6 +228,7 @@ systemlogs.action.crash_added_comment = Crash Added Comment
systemlogs.action.crash_edited_comment = Crash Edited Comment
systemlogs.action.crash_deleted_comment = Crash Deleted Comment
systemlogs.action.crash_deleted = Crash Deleted
+systemlogs.action.crash_group_deleted = Crash group’s data has been deleted.
internal-events.[CLY]_crash = Crash
crashes.show-binary-images = Show binary images
crashes.binary-images = Binary Images
diff --git a/plugins/crashes/frontend/public/templates/crashgroup.html b/plugins/crashes/frontend/public/templates/crashgroup.html
index cf5d30d5d7f..6f30b2c8fd6 100644
--- a/plugins/crashes/frontend/public/templates/crashgroup.html
+++ b/plugins/crashes/frontend/public/templates/crashgroup.html
@@ -98,20 +98,14 @@
-
-
- {{i18n("crash_symbolication.should-upload")}}
-
+
+ {{i18n("crash_symbolication.should-upload")}}
-
-
- {{i18n("crashes.show-binary-images")}}
-
+
+ {{i18n("crashes.show-binary-images")}}
-
-
- {{i18n("crashes.download-stacktrace")}}
-
+
+ {{i18n("crashes.download-stacktrace")}}
@@ -332,20 +326,14 @@
{{!props.row.symbolicated ? i18n("crash_symbolication.symbolicate_error") : i18n("crash_symbolication.resymbolicate")}}
-
-
- {{i18n("crash_symbolication.should-upload")}}
-
+
+ {{i18n("crash_symbolication.should-upload")}}
-
-
- {{i18n('crashes.show-binary-images')}}
-
+
+ {{i18n('crashes.show-binary-images')}}
-
-
- {{i18n('crashes.download-stacktrace')}}
-
+
+ {{i18n('crashes.download-stacktrace')}}
diff --git a/plugins/crashes/frontend/public/templates/overview.html b/plugins/crashes/frontend/public/templates/overview.html
index 8592c30e240..f15857b75d6 100644
--- a/plugins/crashes/frontend/public/templates/overview.html
+++ b/plugins/crashes/frontend/public/templates/overview.html
@@ -77,7 +77,7 @@
{{ i18n('crashes.action-hide') }}
-
+
{{ i18n('crashes.action-delete') }}
@@ -89,6 +89,21 @@
+
+
+ {{i18n('crashes.groups-want-to-delete')}}
+
+
+
diff --git a/plugins/crashes/frontend/public/templates/tab-label.html b/plugins/crashes/frontend/public/templates/tab-label.html
index e1011452914..fc009c10dc8 100644
--- a/plugins/crashes/frontend/public/templates/tab-label.html
+++ b/plugins/crashes/frontend/public/templates/tab-label.html
@@ -2,7 +2,7 @@
diff --git a/plugins/crashes/tests.js b/plugins/crashes/tests.js
index 0012434f899..bc46a60e1e0 100644
--- a/plugins/crashes/tests.js
+++ b/plugins/crashes/tests.js
@@ -3144,6 +3144,35 @@ describe('Testing Crashes', function() {
});
});
+ describe('Crash app version', async() => {
+ it('should process crash app version as string', async() => {
+ const crashData = {
+ "_error": "error",
+ "_app_version": 123, // app version is number
+ "_os": "android",
+ };
+
+ await request.get('/i')
+ .query({ app_key: APP_KEY, device_id: DEVICE_ID, crash: JSON.stringify(crashData) })
+ .expect(200);
+
+ const crashGroupQuery = JSON.stringify({
+ latest_version: { $in: [`${crashData._app_version}`] },
+ });
+ let crashGroupResponse = await request
+ .get('/o')
+ .query({ method: 'crashes', api_key: API_KEY_ADMIN, app_id: APP_ID, query: crashGroupQuery });
+ const crashGroup = crashGroupResponse.body.aaData[0];
+ crashGroupResponse = await request
+ .get(`/o?`)
+ .query({ method: 'crashes', api_key: API_KEY_ADMIN, app_id: APP_ID, group: crashGroup._id });
+
+ const crash = crashGroupResponse.body.data[0];
+
+ crash.app_version.should.equal(`${crashData._app_version}`);
+ });
+ });
+
describe('Reset app', function() {
it('should reset data', function(done) {
var params = {app_id: APP_ID, period: "reset"};
diff --git a/plugins/dashboards/frontend/public/localization/dashboards.properties b/plugins/dashboards/frontend/public/localization/dashboards.properties
index 3434e52aca2..0fd999e11c4 100644
--- a/plugins/dashboards/frontend/public/localization/dashboards.properties
+++ b/plugins/dashboards/frontend/public/localization/dashboards.properties
@@ -117,7 +117,7 @@ dashboards.duplicate-dashboard = Duplicate
dashboards.yes-delete-dashboard = Yes, delete dashboard
dashboards.delete-dashboard-title = Delete dashboard?
-dashboards.delete-dashboard-text = Do you really want to delete dashboard called
{0} ?
+dashboards.delete-dashboard-text = Do you really want to delete dashboard called {0} ?
dashbaords.created = Created
dashbaords.created-by = by {0}
diff --git a/plugins/dashboards/frontend/public/templates/empty.html b/plugins/dashboards/frontend/public/templates/empty.html
index 4b506ce1641..8b7b1b57ac5 100644
--- a/plugins/dashboards/frontend/public/templates/empty.html
+++ b/plugins/dashboards/frontend/public/templates/empty.html
@@ -1,5 +1,7 @@
{{ i18n('common.emtpy-view-title') }}
-
+
+ {{ i18n('common.no-widget-text') }}
+
diff --git a/plugins/dashboards/frontend/public/templates/transient/no-dashboard.html b/plugins/dashboards/frontend/public/templates/transient/no-dashboard.html
index 8cc7bf91118..0c9218e546f 100644
--- a/plugins/dashboards/frontend/public/templates/transient/no-dashboard.html
+++ b/plugins/dashboards/frontend/public/templates/transient/no-dashboard.html
@@ -7,7 +7,9 @@
{{ i18n('common.emtpy-view-title') }}
-
+
+ {{ i18n('common.no-dashboard-text') }}
+
{{ i18n('common.create-dashboard') }}
diff --git a/plugins/dashboards/frontend/public/templates/transient/no-widget.html b/plugins/dashboards/frontend/public/templates/transient/no-widget.html
index 79c7d902f37..4a9f792800d 100644
--- a/plugins/dashboards/frontend/public/templates/transient/no-widget.html
+++ b/plugins/dashboards/frontend/public/templates/transient/no-widget.html
@@ -7,7 +7,9 @@
{{ i18n('common.emtpy-view-title') }}
-
+
+ {{ i18n('common.no-widget-text')}}
+
{{ i18n('common.add-widget') }}
diff --git a/plugins/data-manager/frontend/public/javascripts/countly.views.js b/plugins/data-manager/frontend/public/javascripts/countly.views.js
index 7c333f0bffc..c752e7f9e3b 100644
--- a/plugins/data-manager/frontend/public/javascripts/countly.views.js
+++ b/plugins/data-manager/frontend/public/javascripts/countly.views.js
@@ -1052,9 +1052,6 @@
else if (event === 'import-schema') {
this.importDialogVisible = true;
}
- else if (event === 'navigate-settings') {
- app.navigate("#/manage/configurations/data-manager", true);
- }
},
onSaveImport: function() {
var self = this;
@@ -1148,11 +1145,12 @@
}
if (doc.actionType === 'EVENT_MERGE' && doc.isRegexMerge === true) {
doc.actionType = 'merge-regex';
+ doc.eventTransformTargetRegex = doc.transformTarget[0];
}
else {
doc.actionType = doc.actionType.split('_')[1].toLowerCase();
}
- doc.isExistingEvent = 'true';
+ doc.isExistingEvent = doc.isExistingEvent ? 'true' : 'false';
// doc.tab;
// delete doc.transformType;
doc.name = countlyCommon.unescapeHtml(doc.name);
diff --git a/plugins/data-manager/frontend/public/localization/data-manager.properties b/plugins/data-manager/frontend/public/localization/data-manager.properties
index 4f6720b6205..694484d3f61 100644
--- a/plugins/data-manager/frontend/public/localization/data-manager.properties
+++ b/plugins/data-manager/frontend/public/localization/data-manager.properties
@@ -37,7 +37,7 @@ data-manager.empty-placeholder = -
data-manager.delete-transformation-title = Delete transformation?
data-manager.delete-data-type = Yes, delete property's type
data-manager.delete-data-type-title = Delete property's type?
-data-manager.confirm-delete-data-type = Are you sure you want to delete property's type? It will not delete data itself, but will remove property from segmentation inputs. But if SDK sends data again, it will appear here again
+data-manager.confirm-delete-data-type = Are you sure you want to delete property's type? It will not delete data itself, but will remove property from segmentation inputs. But if SDK sends data again, it will appear here again
data-manager.validation.error.data_type_mismatch = Data type planned on UI and sent via SDK don't match together.
data-manager.validation.error.unexpected-segment = Segment sent by SDK is not Live/Approved on the UI
@@ -115,13 +115,13 @@ configs.data-manager.manage-events = Manage Events
configs.data-manager.validation-settings = Validation Settings
data-manager.enableValidation = Perform Schema Validations for Incoming Data
-configs.help.data-manager-enableValidation = For disabled state, you don’t get any error for events, segments or data type validations. Only global PII regex works for disabled state.
+configs.help.data-manager-enableValidation = For disabled state, you don’t get any error for events, segments or data type validations. Only global PII regex works for disabled state.
data-manager.allowUnplannedEvents = Manage Events
-configs.help.data-manager-allowUnplannedEvents = Allow unplanned events : Unplanned events are visible on all features that use event data on Countly and tagged as “Unplanned” on Data Manager. Don't allow unplanned events : Unplanned events are NOT visible on all features that use event data on Countly and tagged as “Unplanned” on Data Manager. This option is recommended to keep your data clean.
+configs.help.data-manager-allowUnplannedEvents = Allow unplanned events : Unplanned events are visible on all features that use event data on Countly and tagged as “Unplanned” on Data Manager. Don't allow unplanned events : Unplanned events are NOT visible on all features that use event data on Countly and tagged as “Unplanned” on Data Manager. This option is recommended to keep your data clean.
data-manager.triggerUnplannedError = Trigger Validation For Unplanned Events
-configs.help.data-manager-triggerUnplannedError = You dont get any validation error from Data Manager for disabled state. Enabled state is highly recommended if Manage Events setting is selected as “Don't allow unplanned event” above.
+configs.help.data-manager-triggerUnplannedError = You dont get any validation error from Data Manager for disabled state. Enabled state is highly recommended if Manage Events setting is selected as “Don't allow unplanned event” above.
data-manager.segmentLevelValidationAction = Segment Level Validation Action
configs.help.data-manager-segmentLevelValidationAction = Action to take when a segment level regex matches.
diff --git a/plugins/data-manager/frontend/public/templates/event-groups.html b/plugins/data-manager/frontend/public/templates/event-groups.html
index d684cae4ea1..6d47475473c 100644
--- a/plugins/data-manager/frontend/public/templates/event-groups.html
+++ b/plugins/data-manager/frontend/public/templates/event-groups.html
@@ -2,6 +2,7 @@
- {{unescapeHtml(rowScope.row.name || rowScope.row.key || rowScope.row.e)}}
+ {{unescapeHtml(rowScope.row.name || rowScope.row.key || rowScope.row.e)}}
Last modified by {{rowScope.row.audit.userName}}
- {{rowScope.row.status}}
+ {{rowScope.row.status}}
-
+
@@ -75,7 +77,7 @@
- {{unescapeHtml(rowScope.row.categoryName)}}
+ {{unescapeHtml(rowScope.row.categoryName)}}
@@ -83,10 +85,10 @@
prop="lastModifiedts" column-key="lastModifiedDate" sortable="custom" min-width="275" :label="i18n('data-manager.last-modified')">
-
{{rowScope.row.lastModifiedDate || i18n('data-manager.empty-placeholder') }}
-
{{rowScope.row.lastModifiedTime}}
+
{{rowScope.row.lastModifiedDate || i18n('data-manager.empty-placeholder') }}
+
{{rowScope.row.lastModifiedTime}}
-
+
{{ i18n('data-manager.empty-placeholder') }}
@@ -105,28 +107,28 @@
- {{rowScope.row.totalCountFormatted || i18n('data-manager.empty-placeholder') }}
+ {{rowScope.row.totalCountFormatted || i18n('data-manager.empty-placeholder') }}
- {{unescapeHtml(rowScope.row.description || i18n('data-manager.empty-placeholder'))}}
+ {{unescapeHtml(rowScope.row.description || i18n('data-manager.empty-placeholder'))}}
- {{unescapeHtml(rowScope.row[col.value] || i18n('data-manager.empty-placeholder'))}}
+ {{unescapeHtml(rowScope.row[col.value] || i18n('data-manager.empty-placeholder'))}}
-
- {{i18n('common.edit')}}
- {{i18n('common.delete')}}
+
+ {{i18n('common.edit')}}
+ {{i18n('common.delete')}}
diff --git a/plugins/data-manager/frontend/public/templates/events.html b/plugins/data-manager/frontend/public/templates/events.html
index f60a5464ccd..4850c69d55f 100644
--- a/plugins/data-manager/frontend/public/templates/events.html
+++ b/plugins/data-manager/frontend/public/templates/events.html
@@ -33,9 +33,7 @@
{{i18n('data-manager.regenerate')}}
{{i18n('data-manager.export-schema')}}
{{i18n('data-manager.import-schema')}}
-
- {{i18n('plugins.configs')}}
-
+
{{i18n('plugins.configs')}}
diff --git a/plugins/dbviewer/api/api.js b/plugins/dbviewer/api/api.js
index 5ea7f771fef..46c45a19425 100644
--- a/plugins/dbviewer/api/api.js
+++ b/plugins/dbviewer/api/api.js
@@ -5,19 +5,88 @@ var common = require('../../../api/utils/common.js'),
countlyFs = require('../../../api/utils/countlyFs.js'),
_ = require('underscore'),
taskManager = require('../../../api/utils/taskmanager.js'),
- { getCollectionName, dbUserHasAccessToCollection, dbLoadEventsData, validateUser, getUserApps, validateGlobalAdmin, hasReadRight } = require('../../../api/utils/rights.js'),
+ { getCollectionName, dbUserHasAccessToCollection, dbLoadEventsData, validateUser, getUserApps, validateGlobalAdmin, hasReadRight, getBaseAppFilter } = require('../../../api/utils/rights.js'),
exported = {};
const { MongoInvalidArgumentError } = require('mongodb');
const { EJSON } = require('bson');
const FEATURE_NAME = 'dbviewer';
+const whiteListedAggregationStages = {
+ "$addFields": true,
+ "$bucket": true,
+ "$bucketAuto": true,
+ //"$changeStream": false,
+ //"$changeStreamSplitLargeEvents": false,
+ //"$collStats": false,
+ "$count": true,
+ //"$currentOp": false,
+ "$densify": true,
+ //"$documents": false
+ "$facet": true,
+ "$fill": true,
+ "$geoNear": true,
+ "$graphLookup": true,
+ "$group": true,
+ //"$indexStats": false,
+ "$limit": true,
+ //"$listLocalSessions": false
+ //"$listSampledQueries": false
+ //"$listSearchIndexes": false
+ //"$listSessions": false
+ //"$lookup": false
+ "$match": true,
+ //"$merge": false
+ //"$mergeCursors": false
+ //"$out": false
+ //"$planCacheStats": false,
+ "$project": true,
+ "$querySettings": true,
+ "$redact": true,
+ "$replaceRoot": true,
+ "$replaceWith": true,
+ "$sample": true,
+ "$search": true,
+ "$searchMeta": true,
+ "$set": true,
+ "$setWindowFields": true,
+ //"$sharedDataDistribution": false,
+ "$skip": true,
+ "$sort": true,
+ "$sortByCount": true,
+ //"$unionWith": false,
+ "$unset": true,
+ "$unwind": true,
+ "$vectorSearch": true //atlas specific
+};
var spawn = require('child_process').spawn,
child;
+
(function() {
plugins.register("/permissions/features", function(ob) {
ob.features.push(FEATURE_NAME);
});
+ /**
+ * Function removes not allowed aggregation stages from the pipeline
+ * @param {array} aggregation - current aggregation pipeline
+ * @returns {object} changes - object with information which operations were removed
+ */
+ function escapeNotAllowedAggregationStages(aggregation) {
+ var changes = {};
+ for (var z = 0; z < aggregation.length; z++) {
+ for (var key in aggregation[z]) {
+ if (!whiteListedAggregationStages[key]) {
+ changes[key] = true;
+ delete aggregation[z][key];
+ }
+ }
+ if (Object.keys(aggregation[z]).length === 0) {
+ aggregation.splice(z, 1);
+ z--;
+ }
+ }
+ return changes;
+ }
/**
* @api {get} /o/db Access database
@@ -179,6 +248,25 @@ var spawn = require('child_process').spawn,
filter = {};
}
+ var base_filter = {};
+ if (!params.member.global_admin) {
+ base_filter = getBaseAppFilter(params.member, dbNameOnParam, params.qstring.collection);
+ }
+
+ if (base_filter && Object.keys(base_filter).length > 0) {
+ for (var key in base_filter) {
+ if (filter[key]) {
+ filter.$and = filter.$and || [];
+ filter.$and.push({[key]: base_filter[key]});
+ filter.$and.push({[key]: filter[key]});
+ delete filter[key];
+ }
+ else {
+ filter[key] = base_filter[key];
+ }
+ }
+ }
+
if (dbs[dbNameOnParam]) {
try {
var cursor = dbs[dbNameOnParam].collection(params.qstring.collection).find(filter, { projection });
@@ -191,6 +279,7 @@ var spawn = require('child_process').spawn,
common.returnMessage(params, 400, "Invalid collection name: Collection names can not contain '$' or other invalid characters");
}
else {
+ log.e(error);
common.returnMessage(params, 500, "An unexpected error occurred.");
}
return false;
@@ -291,7 +380,7 @@ var spawn = require('child_process').spawn,
async.each(results, function(col, done) {
if (col.collectionName.indexOf("system.indexes") === -1 && col.collectionName.indexOf("sessions_") === -1) {
userHasAccess(params, col.collectionName, params.qstring.app_id, function(hasAccess) {
- if (hasAccess) {
+ if (hasAccess || col.collectionName === "events_data" || col.collectionName === "drill_events") {
ob = parseCollectionName(col.collectionName, lookup);
db.collections[ob.pretty] = ob.name;
}
@@ -318,8 +407,9 @@ var spawn = require('child_process').spawn,
* Get aggregated result by the parameter on the url
* @param {string} collection - collection will be applied related query
* @param {object} aggregation - aggregation object
+ * @param {object} changes - object referencing removed stages from pipeline
* */
- function aggregate(collection, aggregation) {
+ function aggregate(collection, aggregation, changes) {
if (params.qstring.iDisplayLength) {
aggregation.push({ "$limit": parseInt(params.qstring.iDisplayLength) });
}
@@ -339,6 +429,10 @@ var spawn = require('child_process').spawn,
else if (collection === 'auth_tokens') {
aggregation.splice(addProjectionAt, 0, {"$addFields": {"_id": "***redacted***"}});
}
+ else if ((collection === "events_data" || collection === "drill_events") && !params.member.global_admin) {
+ var base_filter = getBaseAppFilter(params.member, dbNameOnParam, params.qstring.collection);
+ aggregation.splice(0, 0, {"$match": base_filter});
+ }
// check task is already running?
taskManager.checkIfRunning({
db: dbs[dbNameOnParam],
@@ -375,7 +469,7 @@ var spawn = require('child_process').spawn,
},
outputData: function(aggregationErr, result) {
if (!aggregationErr) {
- common.returnOutput(params, { sEcho: params.qstring.sEcho, iTotalRecords: 0, iTotalDisplayRecords: 0, "aaData": result });
+ common.returnOutput(params, { sEcho: params.qstring.sEcho, iTotalRecords: 0, iTotalDisplayRecords: 0, "aaData": result, "removed": (changes || {}) });
}
else {
common.returnMessage(params, 500, aggregationErr);
@@ -409,7 +503,12 @@ var spawn = require('child_process').spawn,
if (appId) {
if (hasReadRight(FEATURE_NAME, appId, parameters.member)) {
- return dbUserHasAccessToCollection(parameters, collection, appId, callback);
+ if (collection === "events_data" || collection === "drill_events") {
+ return callback(true);
+ }
+ else {
+ return dbUserHasAccessToCollection(parameters, collection, appId, callback);
+ }
}
}
else {
@@ -485,10 +584,14 @@ var spawn = require('child_process').spawn,
}
else {
userHasAccess(params, params.qstring.collection, function(hasAccess) {
- if (hasAccess) {
+ if (hasAccess || params.qstring.collection === "events_data" || params.qstring.collection === "drill_events") {
try {
let aggregation = EJSON.parse(params.qstring.aggregation);
- aggregate(params.qstring.collection, aggregation);
+ var changes = escapeNotAllowedAggregationStages(aggregation);
+ if (changes && Object.keys(changes).length > 0) {
+ log.d("Removed stages from pipeline: ", JSON.stringify(changes));
+ }
+ aggregate(params.qstring.collection, aggregation, changes);
}
catch (e) {
common.returnMessage(params, 500, 'Aggregation object is not valid.');
@@ -508,7 +611,7 @@ var spawn = require('child_process').spawn,
}
else {
userHasAccess(params, params.qstring.collection, function(hasAccess) {
- if (hasAccess) {
+ if (hasAccess || params.qstring.collection === "events_data" || params.qstring.collection === "drill_events") {
dbGetCollection();
}
else {
diff --git a/plugins/dbviewer/frontend/public/javascripts/countly.views.js b/plugins/dbviewer/frontend/public/javascripts/countly.views.js
index 2436521175b..4f2a661466e 100644
--- a/plugins/dbviewer/frontend/public/javascripts/countly.views.js
+++ b/plugins/dbviewer/frontend/public/javascripts/countly.views.js
@@ -534,6 +534,13 @@
if (res.aaData.length) {
self.fields = Object.keys(map);
}
+ if (res.removed && typeof res.removed === 'object' && Object.keys(res.removed).length > 0) {
+ self.removed = CV.i18n('dbviewer.removed-warning') + Object.keys(res.removed).join(", ");
+
+ }
+ else {
+ self.removed = "";
+ }
}
if (err) {
var message = CV.i18n('dbviewer.server-error');
@@ -559,7 +566,7 @@
}
},
updatePath: function(query) {
- window.location.hash = "#/manage/db/aggregate/" + this.db + "/" + this.collection + "/" + query;
+ app.navigate("#/manage/db/aggregate/" + this.db + "/" + this.collection + "/" + query);
},
getCollectionName: function() {
var self = this;
diff --git a/plugins/dbviewer/frontend/public/localization/dbviewer.properties b/plugins/dbviewer/frontend/public/localization/dbviewer.properties
index 018c8d96649..eb102853b63 100644
--- a/plugins/dbviewer/frontend/public/localization/dbviewer.properties
+++ b/plugins/dbviewer/frontend/public/localization/dbviewer.properties
@@ -36,6 +36,7 @@ dbviewer.generate-aggregate-report= Generate aggregate report
dbviewer.back-to-dbviewer = Back to DB Viewer
dbviewer.invalid-pipeline = Invalid pipeline object, please check pipeline input.
dbviewer.server-error = There was a server error. There might be more information in logs.
+dbviewer.removed-warning = Some stages are removed from aggregation pipleine. Following stages are allowed only with global admin rights:
dbviewer.not-found-data = Couldn't find any results
dbviewer.execute-aggregation = Execute Aggregation on {0}
dbviewer.prepare-new-aggregation = Prepare New Aggregation
diff --git a/plugins/dbviewer/frontend/public/templates/aggregate.html b/plugins/dbviewer/frontend/public/templates/aggregate.html
index 63fa391b593..56c711e00ab 100755
--- a/plugins/dbviewer/frontend/public/templates/aggregate.html
+++ b/plugins/dbviewer/frontend/public/templates/aggregate.html
@@ -25,6 +25,7 @@
+
diff --git a/plugins/density/frontend/public/templates/density.html b/plugins/density/frontend/public/templates/density.html
index cb7962be416..1bd851eb0fa 100644
--- a/plugins/density/frontend/public/templates/density.html
+++ b/plugins/density/frontend/public/templates/density.html
@@ -5,11 +5,8 @@
>
-
-
- {{item.label}}
-
-
+ {{item.label}}
+
diff --git a/plugins/enterpriseinfo/frontend/public/localization/enterpriseinfo.properties b/plugins/enterpriseinfo/frontend/public/localization/enterpriseinfo.properties
index 18a1af74668..d012d38fa74 100644
--- a/plugins/enterpriseinfo/frontend/public/localization/enterpriseinfo.properties
+++ b/plugins/enterpriseinfo/frontend/public/localization/enterpriseinfo.properties
@@ -35,13 +35,13 @@ enterpriseinfo.learn-more = Learn More
enterpriseinfo.contact-sales = Contact Sales
enterpriseinfo.see-in-action = See in Action
enterpriseinfo.highlights = HIGHLIGHTS
-enterpriseinfo.drill-desc = Drill enables performing advanced analysis/segmentation on your data by applying AND, OR and BY filters to your segmentation properties as well as user properties such as Device, App Version, Platform, Platform Version, Session Count, Session Duration, First Session, Last Session, Country, City and Carrier. With Countly Drill, you can:View data on a line, pie or bar chart, whichever makes more sense for the current data Change the time bucket displayed on the chart and table to hourly, daily, weekly or monthly View how many times your custom event occurred as well as how many users performed it and an average (times/users) Drill is something every data analyst should have. Say goodbye to complex SQL queries!
-enterpriseinfo.funnels-desc = Using funnels you can understand where you lose your users throughout conversion paths/goals inside your application. Funnel view shows you the number of users passing through each step as well as how many times that action is performed. You can filter your funnel data using any metric including custom segments you send together with your events. This enables A/B testing and comparing conversion rates within different user groups.
-enterpriseinfo.retention-desc = Every company wants its customers to use their application everyday and engage with it. If they find it valuable, then they'll go on with using it, what we call "Retention". Retention is simply condition of keeping your customers. Retention page shows you active days (e.g days your customer used your application) after first session. On the retention page, you'll see a breakdown of daily, weekly and monthly retention. With retention page, you can consistently monitor how much impact does each application change affect your customer. This is one of the most important metrics for mobile app analytics and is available in Countly Enterprise.
-enterpriseinfo.revenue-desc = Revenue analytics lets you track in app purchase metrics with ease. Below is some of the information you will be getting from revenue analytics Total revenue Avg. revenue per user Avg. revenue per paying user Paying user count Paying/total users
-enterpriseinfo.user-profiles-desc = Countly Profiles enables you to track everything you need to know about your customer and how they use your apps. Track information like name, email, location and gender, or add custom data. Have an overview of who logged in, and what they’ve done in their session. Get detailed user behaviour information. Create a funnel and track user’s progress through that funnel
-enterpriseinfo.attribution-desc = Attribution analytics allows you to measure your marketing campaign performance by attributing installs from specific campaigns. Additionally you can target users from different platforms differently, by providing specific end URL for each platform and Countly will automatically redirect visitor to specific URL based on what platform visitor is using. You can also manage single campaign with single Countly generated URL for both Android and iOS users each pointing to their own app market link.
+enterpriseinfo.drill-desc = Drill enables performing advanced analysis/segmentation on your data by applying AND, OR and BY filters to your segmentation properties as well as user properties such as Device, App Version, Platform, Platform Version, Session Count, Session Duration, First Session, Last Session, Country, City and Carrier. With Countly Drill, you can: View data on a line, pie or bar chart, whichever makes more sense for the current data, Change the time bucket displayed on the chart and table to hourly, daily, weekly or monthly, View how many times your custom event occurred as well as how many users performed it and an average (times/users). Drill is something every data analyst should have. Say goodbye to complex SQL queries!
+enterpriseinfo.funnels-desc = Using funnels you can understand where you lose your users throughout conversion paths/goals inside your application. Funnel view shows you the number of users passing through each step as well as how many times that action is performed. You can filter your funnel data using any metric including custom segments you send together with your events. This enables A/B testing and comparing conversion rates within different user groups.
+enterpriseinfo.retention-desc = Every company wants its customers to use their application everyday and engage with it. If they find it valuable, then they'll go on with using it, what we call "Retention". Retention is simply condition of keeping your customers. Retention page shows you active days (e.g days your customer used your application) after first session. On the retention page, you'll see a breakdown of daily, weekly and monthly retention. With retention page, you can consistently monitor how much impact does each application change affect your customer. This is one of the most important metrics for mobile app analytics and is available in Countly Enterprise.
+enterpriseinfo.revenue-desc = Revenue analytics lets you track in app purchase metrics with ease. Here is some of the information you will be getting from revenue analytics: Total revenue, Avg. revenue per user, Avg. revenue per paying user, Paying user count, Paying/total users.
+enterpriseinfo.user-profiles-desc = Countly Profiles enables you to track everything you need to know about your customer and how they use your apps. Track information like name, email, location and gender, or add custom data. Have an overview of who logged in, and what they’ve done in their session. Get detailed user behaviour information. Create a funnel and track user’s progress through that funnel.
+enterpriseinfo.attribution-desc = Attribution analytics allows you to measure your marketing campaign performance by attributing installs from specific campaigns. Additionally you can target users from different platforms differently, by providing specific end URL for each platform and Countly will automatically redirect visitor to specific URL based on what platform visitor is using. You can also manage single campaign with single Countly generated URL for both Android and iOS users each pointing to their own app market link.
enterpriseinfo.flows-desc = A flow visualization is a graphic that a traces a route or a path. With Flows, it's possible to see the steps users take to reach each page, or each event, depending on whether you track web pages or mobile apps. You can apply any filter like device, city, country, local hour, carrier and more. Each bubble can represent number of sessions or number of users. Since it's possible to segment with campaign, it's possible how traffic is engaging with your content and look for especially overperforming or underperforming campaigns or sources.
-enterpriseinfo.scalability-desc = Countly Enterprise has a different architecture than Countly Lite, where it can serve tens of billions of hits per month on a scalable infrastructure and is available for customers in need of a bigdata solution for mobile analytics. To meet the sustained performance and scalability of ever increasing data and query, Countly Enterprise has a sharding and replicaset mechanism designed to distribute overhead to several machines. You can deploy any number of servers and scale to your desired environment while preserving single pane of monitoring and reporting.
-enterpriseinfo.support-desc = Countly Enterprise comes with support and SLA agreement from the same team who build Countly from ground up. You can also get 2-day training on-site where you'll learn foundations of Countly, scaling Countly, reporting, dashboard customization and mobile device SDKs thoroughly. A comprehensive support program for Countly Enterprise includes orientation programs, technical support for IT, one-on-one expert sessions, online tutorials and more.
-enterpriseinfo.raw-data-desc = It's important that you can access your Countly mobile analytics data when you want it, where you want it - whether is it to import it into another service or just create your own copy for your archives. Self-hosted Countly Enterprise does exactly that: You own your database, you own your data - it's solely your property. Countly Enterprise also allows you download your stored data in CSV and XLS formats, allowing your data scientists to visualize information the way your company managers want. This saves time and energy, instead of spending time with trying to extract data using command line utilities.
+enterpriseinfo.scalability-desc = Countly Enterprise has a different architecture than Countly Lite, where it can serve tens of billions of hits per month on a scalable infrastructure and is available for customers in need of a bigdata solution for mobile analytics. To meet the sustained performance and scalability of ever increasing data and query, Countly Enterprise has a sharding and replicaset mechanism designed to distribute overhead to several machines. You can deploy any number of servers and scale to your desired environment while preserving single pane of monitoring and reporting.
+enterpriseinfo.support-desc = Countly Enterprise comes with support and SLA agreement from the same team who build Countly from ground up. You can also get 2-day training on-site where you'll learn foundations of Countly, scaling Countly, reporting, dashboard customization and mobile device SDKs thoroughly. A comprehensive support program for Countly Enterprise includes orientation programs, technical support for IT, one-on-one expert sessions, online tutorials and more.
+enterpriseinfo.raw-data-desc = It's important that you can access your Countly mobile analytics data when you want it, where you want it - whether is it to import it into another service or just create your own copy for your archives. Self-hosted Countly Enterprise does exactly that: You own your database, you own your data - it's solely your property. Countly Enterprise also allows you download your stored data in CSV and XLS formats, allowing your data scientists to visualize information the way your company managers want. This saves time and energy, instead of spending time with trying to extract data using command line utilities.
diff --git a/plugins/guides/README.md b/plugins/guides/README.md
new file mode 100644
index 00000000000..3e1148787a4
--- /dev/null
+++ b/plugins/guides/README.md
@@ -0,0 +1,187 @@
+# Guides plugin
+
+## Overall behaviour
+
+The guides plugin is a core plugin that provides interactive, in-app walkthroughs and articles for users to help them navigate and utilise features within the Countly application.
+
+Guides are enabled if **enableGuides** is set to true in the CMS OR in the API config file (this allows us to enable them for a specific server). If Guides are disabled or no data is available for a specific plugin, we display the tooltip instead of the Guides button.
+
+The Guides plugin data comes from the Countly CMS. When fetched, it is stored in **Countly DB** under the **cms_cache** collection and is refreshed periodically based on the **UPDATE_INTERVAL** variable set in the backend:
+If cms_cache is empty or the UPDATE_INTERVAL has passed, the data is fetched from the CMS. We can also force a refresh by calling the **clearCache** endpoint which clears the cms_cache collection.
+
+## File structure
+
+File structure follows usual Countly plugin structure
+
+```
+plugins/guides
+├── api
+├── frontend/public
+│ ├── javascripts
+│ │ ├── countly.models.js # Guides model code; transforms backend data
+│ │ └── countly.views.js # Views code: handles all views/components
+│ ├── localization # Contains localization files
+│ ├── stylesheets # Contains stylesheets
+│ └── templates
+│ ├── guides.html # Main guides page template, covering all plugins
+│ ├── dialog.html # Template for the dialog that displays guides for each plugin
+│ ├── search.html # Template for the search bar within the guides section
+│ ├── search-result-tab.html # Template for the tab that shows search results in guides
+│ ├── tab.html # Generic tab template for organising walkthroughs/articles by section
+│ ├── articles-component.html # Component template for a list of articles
+│ ├── article-component.html # Component template for an individual article
+│ ├── walkthroughs-component.html # Component template for a list of walkthroughs
+│ ├── walkthrough-component.html # Component template for an individual walkthrough
+│ ├── overview-component.html # Component template for the overview section of guides
+│ └── overview-tab.html # Template for the tab that displays an overview of guides
+│
+├── install.js
+├── build.js
+├── package.json
+├── tests.js
+└── README.md
+```
+
+Other relevant files:
+
+```
+frontend
+│ ├── express/public/javascripts/countly
+│ └── countly.cms.js # Frontend file where requests to the backend are made
+│
+api
+├── parts/mgmt
+│ └── cms.js # Backend file where requests to the CMS are made
+└── config.js # API config file where we can enable guides
+
+```
+
+## Data structure
+
+The guides data is stored in the **Countly CMS** with the following structure:
+```
+{
+ "data": [
+ {
+ "id": 1,
+ "attributes": {
+ "sectionID": "/section",
+ "sectionTitle": "Section",
+ "createdAt": "date",
+ "updatedAt": "date",
+ "publishedAt": "date",
+ "locale": "en",
+ "walkthroughTitle": "Custom title for walkthroughs",
+ "walkthroughDescription": "Custom description for walkthroughs",
+ "articleTitle": "Custom title for articles",
+ "articleDescription": "Custom description for articles",
+ "articles": [
+ {
+ "id": 18,
+ "title": "title",
+ "url": "https://support.count.ly/path/to/article"
+ }
+ ],
+ "walkthroughs": [
+ {
+ "id": 17,
+ "title": "title",
+ "url": "https://demo.arcade.software/walkthrough"
+ },
+ {
+ "id": 5,
+ "title": "title",
+ "url": "https://demo.arcade.software/walkthrough"
+ }
+ ],
+ "localizations": {
+ "data":[]
+ }
+ }
+ }
+ ....other sections
+ ],
+ "meta": {
+ "pagination": {
+ "page": 1,
+ "pageSize": 100,
+ "pageCount": 1,
+ "total": 1
+ }
+ }
+}
+
+```
+
+When fetched, the data is transformed and stored in the **Countly DB (cms_cache collection)**. The data for each section is stored in a separate document with the _id: "server-guides_{sectionID}"
+
+The document has the following structure:
+```
+{
+ "_id": "server-guides_1",
+ "lu": timestamp,
+ "sectionID": "/section",
+ "sectionTitle": "Section",
+ "createdAt": "date",
+ "publishedAt": "date",
+ "updatedAt": "date",
+ "locale": "en",
+ "articleDescription": "Custom description for articles",
+ "articleTitle": "Custom title for articles",
+ "walkthroughDescription": null,
+ "walkthroughTitle": null,
+ "articles": [
+ {
+ "id": 18,
+ "title": "title",
+ "url": "https://support.count.ly/path/to/article"
+ }
+ ],
+ "walkthroughs": [
+ {
+ "id": 17,
+ "title": "title",
+ "url": "https://demo.arcade.software/walkthrough"
+ },
+ {
+ "id": 5,
+ "title": "title",
+ "url": "https://demo.arcade.software/walkthrough"
+ }
+ ],
+ "localizations": {
+ "data": []
+ }
+}
+
+```
+In cms_cache, we also store the Guides configurations (such as whether the plugin is enabled, titles, descriptions etc) with the following structure:
+```
+{
+ "_id": "server-guide-config_1",
+ "enableGuides": true,
+ "lu": timestamp,
+ "createdAt": "date",
+ "publishedAt": "date",
+ "updatedAt": "date",
+ "articleDescription": "Description",
+ "articleTitle": "Title",
+ "walkthroughDescription": "Description",
+ "walkthroughTitle": "Title"
+}
+```
+
+Lastly, in cms_cache, we store metadata for guides and guides configurations with the following structure:
+The lu (last updated) field lets us know when it is time to refresh the data from the CMS
+```
+{
+ "_id": "server-guides_meta",
+ "lu": timestamp
+}
+```
+```
+{
+ "_id": "server-guide-config_meta",
+ "lu": timestamp
+}
+```
\ No newline at end of file
diff --git a/plugins/guides/frontend/public/javascripts/countly.views.js b/plugins/guides/frontend/public/javascripts/countly.views.js
index 1112e692b17..d0f305f79b0 100755
--- a/plugins/guides/frontend/public/javascripts/countly.views.js
+++ b/plugins/guides/frontend/public/javascripts/countly.views.js
@@ -229,7 +229,6 @@
}
},
onClose: function() {
- // EMRE: View Guides button closing animation flag here
this.isDialogVisible = false;
let mainViewContainer = document.getElementById('main-views-container');
mainViewContainer.getElementsByClassName('main-view')[0].style.setProperty('overflow', 'auto', 'important');
diff --git a/plugins/guides/frontend/public/localization/guides.properties b/plugins/guides/frontend/public/localization/guides.properties
index 1c26ce48916..6c2d76d58dd 100755
--- a/plugins/guides/frontend/public/localization/guides.properties
+++ b/plugins/guides/frontend/public/localization/guides.properties
@@ -17,7 +17,7 @@ guides.articles.all = All Articles
guides.back-to-guides = Back to Countly Guides
guides.help-center = Help Center
guides.go-to-help-center = Go to Help Center
-guides.feedback = Do you have any feedback ?
+guides.feedback = Do you have any feedback?
guides.read-more = Read more
guides.see-all = See all
diff --git a/plugins/guides/frontend/public/templates/dialog.html b/plugins/guides/frontend/public/templates/dialog.html
index 5e103838a14..c834d8923e2 100755
--- a/plugins/guides/frontend/public/templates/dialog.html
+++ b/plugins/guides/frontend/public/templates/dialog.html
@@ -1,5 +1,4 @@
-
@@ -71,7 +70,9 @@ {{ guideData.sectionTitle || "" }}
-
+
+ {{ i18n('guides.feedback') }}
+
{{ i18n('guides.help-center') }}
diff --git a/plugins/hooks/README.md b/plugins/hooks/README.md
new file mode 100644
index 00000000000..97a82046be6
--- /dev/null
+++ b/plugins/hooks/README.md
@@ -0,0 +1,134 @@
+# Countly Hooks Plugin
+
+The Hooks plugin provides powerful automation for integrating Countly data with external systems. This plugin can trigger external HTTP endpoints based on internal events and incoming data, and send automated email notifications for events like user profile updates or users entering a cohort. Hooks offers a new way to feed external systems with Countly data, enabling real-time interactions and automating workflows.
+
+## File Structure
+
+```javascript
+hooks/ # Main hooks plugin directory
+├── api/ # Backend API logic
+│ ├── jobs/ # Job scheduling logic
+│ │ └── schedule.js # Handles scheduling tasks
+│ └── parts/ # Logic for effects and triggers
+│ ├── effects/ # Different types of effects used in hooks
+│ │ ├── custom_code.js # Handles custom code execution
+│ │ ├── email.js # Manages email-related hooks
+│ │ ├── http.js # HTTP requests for hooks
+│ │ └── index.js # Effect index for organization
+│ └── triggers/ # Triggers for executing hooks
+│ ├── api_endpoint.js # API endpoint trigger
+│ ├── incoming_data.js # Triggers for incoming data
+│ ├── internal_event.js # Internal event trigger
+│ ├── scheduled.js # Trigger for scheduled tasks
+│ └── index.js # Trigger index file
+├── api.js # Main API logic for backend requests
+├── testData.js # Sample test data for hooks
+├── utils.js # Utility functions for hooks
+├── frontend/ # Frontend resources
+│ ├── public/ # Publicly accessible files
+│ │ ├── javascripts/ # JavaScript for frontend logic
+│ │ │ ├── countly.hooks.effects.js # Effect logic for frontend hooks
+│ │ │ ├── countly.models.js # Model definitions for hooks
+│ │ │ └── countly.views.js # View logic for rendering hooks
+│ │ ├── localization/ # Localization files for translations
+│ │ ├── stylesheets/ # CSS and SCSS for styling hooks UI
+│ │ │ ├── vue-main.css # Compiled CSS for UI
+│ │ │ └── vue-main.scss # Source SCSS file for styling
+│ │ └── templates/ # HTML templates for UI components
+│ │ ├── vue-drawer.html # Drawer UI for hooks
+│ │ ├── vue-effects.html # Effects UI template
+│ │ ├── vue-hooks-detail-error-table.html # Template for error table
+│ │ ├── vue-hooks-detail.html # Detail view of individual hooks
+│ │ ├── vue-main.html # Main template for hooks
+│ │ └── vue-table.html # Table template for hooks display
+│ └── app.js # Main frontend application logic
+├── install.js # Installation script for the plugin
+├── package-lock.json # Lock file for Node.js dependencies
+├── package.json # Package configuration for Node.js
+├── tests.js # Test scripts for validating hooks functionality
+└── uninstall.js # Uninstallation script for removing the plugin
+```
+
+## Installation
+
+1. Navigate to the directory where the Hooks plugin is located. This could be a relative or absolute path depending on your environment setup:
+
+ ```bash
+ cd /path/to/your/project/hooks
+ ```
+
+2. Install dependencies:
+
+ ```bash
+ npm install
+ ```
+
+## Using Hooks
+
+First, ensure that Hooks is enabled. In the Sidebar, navigate to Management > Feature Management and switch on the Hooks toggle.
+
+## Creating a New Hook
+
+To create a new Hook in the Countly Hooks Plugin, follow these steps:
+
+### Step 1: Start a New Hook
+
+- Click on the **+ New Hook** button located in the top right corner of the default view. A new drawer will open where you’ll need to fill out the following fields.
+
+### Step 2: Fill in Hook Details
+
+1. **Hook Name**: Enter a concise and descriptive name for your Hook. This field is required and should adhere to SDK naming limitations.
+2. **Description** (optional): Provide a brief explanation of the Hook’s purpose. This can help your colleagues understand why it was created. Avoid long descriptions and special characters.
+3. **Source App**: Select the application where the Hook will apply. You can use the search bar to find your app, but only one app can be chosen per Hook. Click on **Next Step** to proceed to trigger selection.
+
+### Step 3: Select the Trigger Type
+
+From the dropdown, choose a trigger type for your Hook. Options include:
+
+- **Tracked Data**
+- **Internal Actions**
+- **API Endpoint**
+
+#### For Tracked Data
+
+- Choose a specific data point from the list or dropdown.
+- Set a filtering rule if needed; you may add multiple conditions.
+
+#### For Internal Actions
+
+- Select a trigger from the dropdown.
+- Depending on the chosen trigger, specify the details using the subsequent dropdown (e.g., select a specific cohort from the list).
+
+#### For API Endpoint
+
+- Choose a trigger from the dropdown.
+- Specify further using the new dropdown that appears (e.g., select a cohort).
+
+### Step 4: Set up a Recurring Trigger (Optional)
+
+Select one of the following frequencies for the trigger:
+
+- **Every hour** (includes time zone selection)
+- **Every day** (includes time zone selection)
+- **Every week** (select day of the week, hour, and time zone)
+- **Every month** (select day of the month from 1 to 28, hour, and time zone)
+
+Then, specify when the trigger should run.
+
+### Step 5: Choose an Action Type
+
+Choose an action from the dropdown options:
+
+- **Send Email**
+- **Make HTTP Request**
+- **Custom Code**
+
+You can add multiple actions by clicking **Add Action**.
+
+### Step 6: Test the Hook (Optional)
+
+- To verify if the Hook functions as expected, click the **Test the Hook** button. For further guidance, refer to the “Testing a Hook” section below.
+
+### Step 7: Save the Hook
+
+After completing all fields, click **Save**. A success message will confirm that your Hook has been saved successfully.
diff --git a/plugins/hooks/api/api.js b/plugins/hooks/api/api.js
index 7a20bb3cffd..4edf78272e5 100644
--- a/plugins/hooks/api/api.js
+++ b/plugins/hooks/api/api.js
@@ -272,7 +272,6 @@ plugins.register("/permissions/features", function(ob) {
plugins.register("/i/hook/save", function(ob) {
let paramsInstance = ob.params;
-
validateCreate(ob.params, FEATURE_NAME, function(params) {
let hookConfig = params.qstring.hook_config;
if (!hookConfig) {
@@ -283,50 +282,57 @@ plugins.register("/i/hook/save", function(ob) {
try {
hookConfig = JSON.parse(hookConfig);
hookConfig = sanitizeConfig(hookConfig);
- if (!(common.validateArgs(hookConfig, CheckHookProperties(hookConfig)))) {
- common.returnMessage(params, 400, 'Not enough args');
- return true;
- }
+ if (hookConfig) {
+ // Null check for hookConfig
+ if (!(common.validateArgs(hookConfig, CheckHookProperties(hookConfig)))) {
+ common.returnMessage(params, 400, 'Not enough args');
+ return true;
+ }
- if (hookConfig.effects && !validateEffects(hookConfig.effects)) {
- common.returnMessage(params, 400, 'Invalid configuration for effects');
- return true;
- }
+ if (hookConfig.effects && !validateEffects(hookConfig.effects)) {
+ common.returnMessage(params, 400, 'Invalid configuration for effects');
+ return true;
+ }
- if (hookConfig._id) {
- const id = hookConfig._id;
- delete hookConfig._id;
- return common.db.collection("hooks").findAndModify(
- { _id: common.db.ObjectID(id) },
- {},
- {$set: hookConfig},
- {new: true},
- function(err, result) {
- if (!err) {
- // Audit log: Hook updated
- if (result && result.value) {
- plugins.dispatch("/systemlogs", {
- params: params,
- action: "hook_updated",
- data: {
- updatedHookID: result.value._id,
- updatedBy: params.member._id,
- updatedHookName: result.value.name
- }
- });
+ if (hookConfig._id) {
+ const id = hookConfig._id;
+ delete hookConfig._id;
+ return common.db.collection("hooks").findAndModify(
+ { _id: common.db.ObjectID(id) },
+ {},
+ {$set: hookConfig},
+ {new: true},
+ function(err, result) {
+ if (!err) {
+ // Audit log: Hook updated
+ if (result && result.value) {
+ plugins.dispatch("/systemlogs", {
+ params: params,
+ action: "hook_updated",
+ data: {
+ updatedHookID: result.value._id,
+ updatedBy: params.member._id,
+ updatedHookName: result.value.name
+ }
+ });
+ }
+ else {
+ common.returnMessage(params, 500, "No result found");
+ }
+ common.returnOutput(params, result && result.value);
}
else {
- common.returnMessage(params, 500, "No result found");
+ common.returnMessage(params, 500, "Failed to save an hook");
}
- common.returnOutput(params, result && result.value);
}
- else {
- common.returnMessage(params, 500, "Failed to save an hook");
- }
- });
+ );
+ }
+
+ }
+ if (hookConfig) {
+ hookConfig.createdBy = params.member._id; // Accessing property now with proper check
+ hookConfig.created_at = new Date().getTime();
}
- hookConfig.createdBy = params.member._id;
- hookConfig.created_at = new Date().getTime();
return common.db.collection("hooks").insert(
hookConfig,
function(err, result) {
@@ -496,8 +502,8 @@ plugins.register("/o/hook/list", function(ob) {
});
}
catch (err) {
- log.e('get hook list failed');
- common.returnMessage(params, 500, "Failed to get hook list");
+ log.e('get hook list failed', err);
+ common.returnMessage(params, 500, "Failed to get hook list" + err.message);
}
}, paramsInstance);
return true;
@@ -545,6 +551,9 @@ plugins.register("/i/hook/status", function(ob) {
data: { updatedHooksCount: Object.keys(statusList).length, requestedBy: params.member._id }
});
common.returnOutput(params, true);
+ }).catch(function(err) {
+ log.e('Failed to update hook statuses: ', err);
+ common.returnMessage(params, 500, "Failed to update hook statuses: " + err.message);
});
}, paramsInstance);
return true;
@@ -593,8 +602,8 @@ plugins.register("/i/hook/delete", function(ob) {
);
}
catch (err) {
- log.e('delete hook failed', hookID);
- common.returnMessage(params, 500, "Failed to delete an hook");
+ log.e('delete hook failed', hookID, err);
+ common.returnMessage(params, 500, "Failed to delete an hook" + err.message);
}
}, paramsInstance);
return true;
@@ -608,43 +617,54 @@ plugins.register("/i/hook/test", function(ob) {
let hookConfig = params.qstring.hook_config;
if (!hookConfig) {
common.returnMessage(params, 400, 'Invalid hookConfig');
- return true;
+ return;
}
try {
hookConfig = JSON.parse(hookConfig);
+ if (!hookConfig) {
+ common.returnMessage(params, 400, 'Parsed hookConfig is invalid');
+ return;
+ }
hookConfig = sanitizeConfig(hookConfig);
const mockData = JSON.parse(params.qstring.mock_data);
if (!(common.validateArgs(hookConfig, CheckHookProperties(hookConfig)))) {
- common.returnMessage(params, 403, "hook config invalid");
+ common.returnMessage(params, 403, "hook config invalid" + JSON.stringify(hookConfig));
+ return; // Add return to exit early
}
+ // Null check for effects
if (hookConfig.effects && !validateEffects(hookConfig.effects)) {
common.returnMessage(params, 400, 'Config invalid');
- return true;
+ return; // Add return to exit early
}
+
// trigger process
log.d(JSON.stringify(hookConfig), "[hook test config]");
const results = [];
// build mock data
const trigger = hookConfig.trigger;
+ if (!trigger) {
+ common.returnMessage(params, 400, 'Trigger is missing');
+ return;
+ }
hookConfig._id = null;
+
log.d("[hook test mock data]", mockData);
const obj = {
is_mock: true,
params: mockData,
rule: hookConfig
};
-
log.d("[hook test config data]", obj);
const t = new Triggers[trigger.type]({
rules: [hookConfig],
});
- // out put trigger result
+ // output trigger result
const triggerResult = await t.process(obj);
log.d("[hook trigger test result]", triggerResult);
results.push(JSON.parse(JSON.stringify(triggerResult)));
@@ -675,8 +695,8 @@ plugins.register("/i/hook/test", function(ob) {
return false;
}
catch (e) {
- log.e("hook test error", e);
- common.returnMessage(params, 503, "Hook test failed.");
+ log.e("hook test error", e, hookConfig);
+ common.returnMessage(params, 503, "Hook test failed." + e.message);
return;
}
}, paramsInstance);
diff --git a/plugins/hooks/frontend/public/localization/hooks.properties b/plugins/hooks/frontend/public/localization/hooks.properties
index 163ece4cc88..d5c34189a8b 100644
--- a/plugins/hooks/frontend/public/localization/hooks.properties
+++ b/plugins/hooks/frontend/public/localization/hooks.properties
@@ -46,7 +46,6 @@ configs.help.hooks-timeWindowForRequestLimit=The time window for the request lim
hooks.InternalEventTrigger = Internal Actions
hooks.trigger-api-endpoint-uri= API Endpoint
hooks.trigger-introduction = Introduction
-hooks.trigger-api-endpoint-intro-content = Send a GET request with query string parameter “payload” as a JSON string to the below URL: {0}
hooks.APIEndPointTrigger = API Endpoint
hooks.internal-event-selector-title = Internal Actions
hooks.internal-event-selector-placeholder = Please select an internal action
@@ -80,13 +79,13 @@ hooks.http-method-get = GET
hooks.http-method-post = POST
hooks.http-effect-description= Use {{payload_json}} to include the entire trigger data as a JSON object in your request body, or {{payload_string}} to include a stringified JSON in your query string. You can also use individual properties within the trigger data such as {{user}}.
-hooks.intro-hooks-trigger = /hooks/trigger will capture triggered data from the selected hook trigger. Output: Trigger output data from selected hook.
-hooks.intro-i-app_users-delete= /i/app_users/delete will capture deleted user profiles. Output: Individual user profile data
-hooks.intro-i-app_users-update = /i/app_users/update will capture updated user profiles. Output: Individual user profile data
-hooks.intro-i-app_users-create = /i/app_users/create will capture created user profiles. Output: Individual user profile data
-hooks.intro-cohort-exit = /cohort/exit will capture users who exit from the selected cohort. Output: Individual user profile data
-hooks.intro-cohort-enter = /cohort/enter will capture users who enter into the selected cohort. Output: Individual user profile data
-hooks.intro-incoming-data = Will capture event data when match filter rules. Output: Event data & user profile data.
+hooks.intro-hooks-trigger = /hooks/trigger will capture triggered data from the selected hook trigger. Output: Trigger output data from selected hook.
+hooks.intro-i-app_users-delete= /i/app_users/delete will capture deleted user profiles.Output: Individual user profile data
+hooks.intro-i-app_users-update = /i/app_users/update will capture updated user profiles.Output: Individual user profile data
+hooks.intro-i-app_users-create = /i/app_users/create will capture created user profiles.Output: Individual user profile data
+hooks.intro-cohort-exit = /cohort/exit will capture users who exit from the selected cohort. Output: Individual user profile data
+hooks.intro-cohort-enter = /cohort/enter will capture users who enter into the selected cohort. Output: Individual user profile data
+hooks.intro-incoming-data = Will capture event data when match filter rules. Output: Event data & user profile data.
hooks.copy-notify-message = API Endpoint URL is copied.
hooks.copy-notify-title = Copy URL
hooks.Select_country = Select Country
diff --git a/plugins/hooks/frontend/public/templates/vue-table.html b/plugins/hooks/frontend/public/templates/vue-table.html
index 57019468a6d..389d069f389 100644
--- a/plugins/hooks/frontend/public/templates/vue-table.html
+++ b/plugins/hooks/frontend/public/templates/vue-table.html
@@ -1,5 +1,6 @@
-
+
-
@@ -39,8 +40,8 @@
-
{{scope.row.name}}
-
{{scope.row.description}}
+
{{scope.row.name}}
+
{{scope.row.description}}
@@ -50,7 +51,7 @@
{{i18n('hooks.trigger')}} {{ i18n('hooks.effects') }}
-
+
@@ -58,11 +59,17 @@
+
+ {{scope.row.triggerCount}}
+
+
+ {{scope.row.lastTriggerTimestampString}}
+
@@ -71,10 +78,10 @@
-
+
{{scope.row.createdByUser || "-"}}
-
+
{{ scope.row.created_at_string }}
@@ -83,7 +90,7 @@
-
+
{{i18n('hooks.edit')}}
@@ -100,4 +107,4 @@
-
+
\ No newline at end of file
diff --git a/plugins/hooks/tests.js b/plugins/hooks/tests.js
index fba6dd206d2..b883fb96041 100644
--- a/plugins/hooks/tests.js
+++ b/plugins/hooks/tests.js
@@ -136,6 +136,16 @@ describe('Testing Hooks', function() {
done();
});
});
+ it('should fail to fetch hook details with invalid hook ID', function(done) {
+ const invalidHookId = "invalid-id"; // Invalid hook ID
+ request.get(getRequestURL('/o/hook/list') + '&id=' + invalidHookId)
+ .expect(404) // Not found error for invalid hook ID
+ .end(function(err, res) {
+ // Test response
+ res.body.should.have.property('hooksList').which.is.an.Array().and.have.lengthOf(0);
+ done();
+ });
+ });
});
diff --git a/plugins/locale/frontend/public/templates/language.html b/plugins/locale/frontend/public/templates/language.html
index abf25177d22..c11d4630004 100644
--- a/plugins/locale/frontend/public/templates/language.html
+++ b/plugins/locale/frontend/public/templates/language.html
@@ -6,10 +6,7 @@
>
-
-
- {{item.label}}
-
+ {{item.label}}
diff --git a/plugins/logger/frontend/public/javascripts/countly.views.js b/plugins/logger/frontend/public/javascripts/countly.views.js
index 7bb92f6c16e..51960e56d33 100644
--- a/plugins/logger/frontend/public/javascripts/countly.views.js
+++ b/plugins/logger/frontend/public/javascripts/countly.views.js
@@ -118,6 +118,12 @@
},
showTurnedOff: function() {
return this.isTurnedOff;
+ },
+ goTo: function() {
+ return {
+ title: CV.i18n("common.go-to-settings"),
+ url: "#/" + this.appId + "/manage/configurations"
+ };
}
},
mixins: [
@@ -130,6 +136,44 @@
formatExportFunction: function() {
var tableData = this.logsData;
var table = [];
+ var sanitizeQueryData = function(data) {
+ try {
+ // If data is already a string, parse it first
+ let queryObject = typeof data === 'string' ? JSON.parse(data) : data;
+
+ // Handle nested JSON strings within the object
+ Object.keys(queryObject).forEach(key => {
+ if (typeof queryObject[key] === 'string') {
+ // Try to parse if it looks like JSON
+ if (queryObject[key].startsWith('{') || queryObject[key].startsWith('[')) {
+ try {
+ queryObject[key] = JSON.parse(queryObject[key]);
+ if (typeof queryObject[key] === 'object' && queryObject[key] !== null) {
+ queryObject[key] = sanitizeQueryData(queryObject[key]);
+ }
+ }
+ catch (e) {
+ // If parsing fails, keep decoded string
+ }
+ }
+ queryObject[key] = countlyCommon.unescapeHtml(queryObject[key]);
+ }
+ else if (typeof queryObject[key] === 'object' && queryObject[key] !== null) {
+ // Recursively handle nested objects
+ Object.keys(queryObject[key]).forEach(nestedKey => {
+ if (typeof queryObject[key][nestedKey] === 'string') {
+ queryObject[key][nestedKey] = countlyCommon.unescapeHtml(queryObject[key][nestedKey]);
+ }
+ });
+ }
+ });
+ return JSON.stringify(queryObject);
+ }
+ catch (err) {
+ return data; // Return original data if processing fails
+ }
+ };
+
for (var i = 0; i < tableData.length; i++) {
var item = {};
item[CV.i18n('logger.requests').toUpperCase()] = countlyCommon.formatTimeAgoText(tableData[i].reqts).text;
@@ -152,7 +196,7 @@
}
if (tableData[i].q) {
try {
- item[CV.i18n('logger.request-query').toUpperCase()] = JSON.stringify(tableData[i].q);
+ item[CV.i18n('logger.request-query').toUpperCase()] = sanitizeQueryData(tableData[i].q);
}
catch (err) {
item[CV.i18n('logger.request-header').toUpperCase()] = "-";
@@ -160,8 +204,7 @@
}
if (tableData[i].h) {
try {
- var stringifiedHeader = JSON.stringify(tableData[i].h);
- item["REQUEST HEADER"] = stringifiedHeader.replace(/"/g, '"');
+ item["REQUEST HEADER"] = sanitizeQueryData(tableData[i].h);
}
catch (err) {
item["REQUEST HEADER"] = "-";
diff --git a/plugins/logger/frontend/public/localization/logger.properties b/plugins/logger/frontend/public/localization/logger.properties
index 323bd689831..ccb5e5a2401 100644
--- a/plugins/logger/frontend/public/localization/logger.properties
+++ b/plugins/logger/frontend/public/localization/logger.properties
@@ -31,7 +31,7 @@ logger.limit = Set incoming data logging limit per minute
logger.state-on = On
logger.state-off = Off
logger.state-automatic = Automatic
-logger.state-off-warning = Incoming data logging is turned off. You can change it using application configuration page .
+logger.state-off-warning = Incoming data logging is turned off. You can change it on the settings page.
logger.platform = Platform
logger.device-id = Device Id
logger.location = Location
diff --git a/plugins/logger/frontend/public/templates/logger.html b/plugins/logger/frontend/public/templates/logger.html
index ce798cdde47..21f88b8484f 100644
--- a/plugins/logger/frontend/public/templates/logger.html
+++ b/plugins/logger/frontend/public/templates/logger.html
@@ -15,11 +15,12 @@
-
+
-
+
-
+
@@ -53,26 +54,26 @@
-
+
+ :location="rowScope.row.l" :data-test-id="'datatable-logger-details-' + rowScope.$index">
- {{i18n('logger.request-canceled')}}
- {{rowScope.row.c}}
-
- {{i18n('logger.problems')}}
- {{p}}
+ {{i18n('logger.request-canceled')}}
+ {{rowScope.row.c}}
+
+ {{i18n('logger.problems')}}
+ {{p}}
-
+
\ No newline at end of file
diff --git a/plugins/pluginManager.js b/plugins/pluginManager.js
index 656288e5eb4..8df7214face 100644
--- a/plugins/pluginManager.js
+++ b/plugins/pluginManager.js
@@ -74,7 +74,11 @@ var pluginManager = function pluginManager() {
countly_out: "../api/configs/config.db_out.js",
countly_fs: "../api/configs/config.db_fs.js"
};
-
+ /**
+ * TTL collections to clean up periodically
+ * @type {{collection: string, db: mongodb.Db, property: string, expireAfterSeconds: number}[]}
+ */
+ this.ttlCollections = [];
/**
* Custom configuration files for different databases for docker env
*/
diff --git a/plugins/plugins/frontend/public/localization/plugins.properties b/plugins/plugins/frontend/public/localization/plugins.properties
index 787abe4eaed..e3fb4408192 100644
--- a/plugins/plugins/frontend/public/localization/plugins.properties
+++ b/plugins/plugins/frontend/public/localization/plugins.properties
@@ -34,7 +34,7 @@ plugins.seconds = seconds
plugins.error = Error occurred
plugins.errors = Some errors occurred
plugins.errors-msg = Check logs for more information
-plugins.confirm = Disabling plugin will also disable the functionality that plugin is providing.
Are you sure you want to proceed?
+plugins.confirm = Disabling plugin will also disable the functionality that plugin is providing. Are you sure you want to proceed?
plugins.will-restart = Countly will reload after the changes take effect
plugins.please-wait = Please wait until the process is completed
plugins.dependents = Dependent Features
@@ -175,7 +175,7 @@ configs.help.frontend-countly_tracking = When enabled, Countly will be activated
configs.help.frontend-production = Initial load of dashboard should be faster, due to smaller files and smaller file amount, but when developing a plugin, you need to regenerate them to see changes
configs.help.frontend-theme = Selected theme will be available server-wide, for all apps and users
configs.help.frontend-session_timeout = User will be forced to logout after session timeout (in minutes) of inactivity. If you want to disable force logout, set to 0.
-configs.help.frontend-google_maps_api_key = Google requires an API key for Geocharts visualization used in views such as Overview and Analytics > Countries. Provide your API key to use this visualization without any limitations.
Learn how to get your API key.
+configs.help.frontend-google_maps_api_key = Google requires an API key for Geocharts visualization used in views such as Overview and Analytics > Countries. Provide your API key to use this visualization without any limitations.
Learn how to get your API key.
configs.help.security-login_tries = Account will be blocked for some time after provided number of incorrect login attempts. See below for time increments.
configs.help.security-login_wait = Incremental period of time account is blocked after provided number of incorrect login attempts (in seconds)
configs.help.security-password_rotation = Amount of previous passwords user should not be able to reuse
diff --git a/plugins/plugins/frontend/public/templates/configurations.html b/plugins/plugins/frontend/public/templates/configurations.html
index c699f68afd6..e0c933ceb65 100755
--- a/plugins/plugins/frontend/public/templates/configurations.html
+++ b/plugins/plugins/frontend/public/templates/configurations.html
@@ -8,12 +8,13 @@