From 94ee29c9f0f7ec6c22e2373dc89ad1aa6d5fb073 Mon Sep 17 00:00:00 2001 From: "George M. Dias" Date: Tue, 2 Jan 2024 13:05:05 -0600 Subject: [PATCH] Database load pagination (#5183) * Added script to run sequelizer in windows - updated README Signed-off-by: George Dias * db pagination work Signed-off-by: George Dias * pagination WIP Signed-off-by: George Dias * WIP - almost done Signed-off-by: George Dias * WIP - completed samples UI retrofit Signed-off-by: George Dias * WIP - completed db UI retrofit Signed-off-by: George Dias * WIP-Implementing the search capability logic, 50% completed Signed-off-by: George Dias * WIP-Implementing the search capability logic, 85% completed Signed-off-by: George Dias * Completed pagination, working on linting Signed-off-by: George Dias * testing linting and e2e tests Signed-off-by: George Dias * 2e2 tests updates Signed-off-by: George Dias * added search instructions Signed-off-by: George Dias * minor UI updates Signed-off-by: George Dias * removed commented out code Signed-off-by: George Dias * updated supported formats dialog Signed-off-by: George Dias * added a bonus capability Signed-off-by: George Dias * updates in response to sonalclould findings Signed-off-by: George Dias --------- Signed-off-by: George Dias --- .github/workflows/hdfconverter-tests.yml | 2 +- .github/workflows/push-to-npm.yml | 2 +- .vscode/settings.json | 25 + CHANGELOG | 8 +- README.md | 67 +- apps/backend/package.json | 1 + apps/backend/src/app.service.ts | 16 +- apps/backend/src/authn/authn.service.ts | 6 +- apps/backend/src/casl/casl-ability.factory.ts | 28 +- apps/backend/src/database/database.module.ts | 14 +- .../src/evaluations/dto/evaluation.dto.ts | 5 + .../src/evaluations/evaluations.controller.ts | 52 +- .../src/evaluations/evaluations.service.ts | 199 +++++- apps/backend/src/groups/groups.service.ts | 6 +- .../src/interceptors/logging.interceptor.ts | 7 +- apps/backend/test/evaluations.e2e-spec.ts | 6 +- apps/frontend/public/static/export/style.css | 2 +- .../src/components/generic/CopyButton.vue | 18 +- .../src/components/global/Sidebar.vue | 19 +- .../components/global/admin/Statistics.vue | 2 +- .../global/sidebaritems/DropdownContent.vue | 20 + .../global/sidebaritems/SidebarFileList.vue | 8 +- .../src/components/global/tags/TagRow.vue | 33 +- .../global/upload_tabs/DatabaseReader.vue | 131 +++- .../global/upload_tabs/FileReader.vue | 105 ++- .../global/upload_tabs/LoadFileList.vue | 625 +++++++++++++++--- .../global/upload_tabs/SampleList.vue | 226 +++++-- .../global/upload_tabs/splunk/FileList.vue | 7 + .../global/upload_tabs/tenable/FileList.vue | 9 +- apps/frontend/src/store/data_filters.ts | 7 +- apps/frontend/src/store/evaluations.ts | 33 +- apps/frontend/src/views/Login.vue | 4 +- heimdall2.code-workspace | 3 +- libs/hdf-converters/README.md | 6 +- .../schemas/checklist/README.md | 2 +- .../evaluation/evaluation.interface.ts | 14 + setup-dev-env.bat | 123 ++++ setup-dev-env.sh | 38 +- yarn.lock | 2 +- 39 files changed, 1609 insertions(+), 272 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 setup-dev-env.bat diff --git a/.github/workflows/hdfconverter-tests.yml b/.github/workflows/hdfconverter-tests.yml index 55017058aa..5a6b0d3f81 100644 --- a/.github/workflows/hdfconverter-tests.yml +++ b/.github/workflows/hdfconverter-tests.yml @@ -1,4 +1,4 @@ -name: Run HDF-Converters Tests +name: Run OHDF-Converters Tests on: push: diff --git a/.github/workflows/push-to-npm.yml b/.github/workflows/push-to-npm.yml index 36200ad13a..ba4332dd43 100644 --- a/.github/workflows/push-to-npm.yml +++ b/.github/workflows/push-to-npm.yml @@ -36,7 +36,7 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Publish HDF Converters to NPM + - name: Publish OHDF Converters to NPM if: always() run: npm publish --access public libs/hdf-converters/mitre-hdf-converters*.tgz env: diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..432d58b7d9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,25 @@ +{ + "cSpell.words": [ + "apikeygoeshere", + "casl", + "CREATEDB", + "DISA", + "distro", + "Evals", + "FEDRAMP", + "FISMA", + "fontface", + "headerapikey", + "headerprops", + "ldapauth", + "nestjs", + "nodesource", + "npmjs", + "openidconnect", + "prismjs", + "psql", + "srcset", + "Webserver", + "XCCDF" + ] +} \ No newline at end of file diff --git a/CHANGELOG b/CHANGELOG index 159625ab48..d12e0f54a4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -401,7 +401,7 @@ v2.8.1 - Node Version Update to 18 - requires minimum of node v18 now @HenryXiaoHX @charleshu-8 @Amndeep7 (#3850) - Conveyor mapper updates @rbogren-brock (#4892) -- Move HTML export feature to HDF Converters @charleshu-8 (#4834) +- Move HTML export feature to OHDF Converters @charleshu-8 (#4834) ## Dependency Updates @@ -491,9 +491,9 @@ v2.7.1 v2.7.0 - Unique Group Names @Rlin232 (#4722) - make sure to run the db migration scripts and be ready for your group names potentially being changed in order to enforce the uniqueness constraint -- Using Semver Compare for ASFF "Previously HDF" Special Casing @Rlin232 (#4767) +- Using Semver Compare for ASFF "Previously OHDF" Special Casing @Rlin232 (#4767) - Implemented OIDC external group import @akey77 (#3922) -- Map XCCDF result value of "notselected" to HDF impact of 0 @candrews (#4758) +- Map XCCDF result value of "notselected" to OHDF impact of 0 @candrews (#4758) - Remove jQuery import @charleshu-8 (#4752) ## Dependency Updates @@ -538,7 +538,7 @@ v2.6.58 - Group Descriptions @Rlin232 (#4695) - Reassign group owners upon user deletion @Rlin232 (#4403) -- Improve xccdf_results_mapper when converting XCCDF Results to HDF Results @candrews (#4255) +- Improve xccdf_results_mapper when converting XCCDF Results to OHDF Results @candrews (#4255) - Update Splunk links @charleshu-8 (#4716) diff --git a/README.md b/README.md index f58dc95539..526926acaa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Run E2E Backend + Frontend Tests](https://github.com/mitre/heimdall2/workflows/Run%20E2E%20Backend%20+%20Frontend%20Tests/badge.svg) ![Run Frontend Tests](https://github.com/mitre/heimdall2/workflows/Run%20Frontend%20Tests/badge.svg) ![Run Backend Tests](https://github.com/mitre/heimdall2/workflows/Run%20Backend%20Tests/badge.svg) -This repository contains the source code for Heimdall's [Backend](https://github.com/mitre/heimdall2/tree/master/apps/backend), [Frontend (AKA Heimdall Lite)](https://github.com/mitre/heimdall2/tree/master/apps/frontend), [HDF Converters](https://github.com/mitre/heimdall2/tree/master/libs/hdf-converters), and [InSpecJS](https://github.com/mitre/heimdall2/tree/master/libs/inspecjs). +This repository contains the source code for Heimdall's [Backend](https://github.com/mitre/heimdall2/tree/master/apps/backend), [Frontend (AKA Heimdall Lite)](https://github.com/mitre/heimdall2/tree/master/apps/frontend), [OHDF Converters](https://github.com/mitre/heimdall2/tree/master/libs/hdf-converters), and [InSpecJS](https://github.com/mitre/heimdall2/tree/master/libs/inspecjs). ## Contents @@ -296,7 +296,13 @@ If you would like to change Heimdall to your needs, you can use Heimdall's 'Deve sudo apt install nano # recommended installation sudo npm install -g yarn ``` - + + **NOTES** + + - The installation scripts setup_XX.x are no longer supported and are not needed anymore, as the installation process is straightforward for any RPM and DEB distro. + + - See the [Debian and Ubuntu based distributions](https://github.com/nodesource/distributions#debian-and-ubuntu-based-distributions) nodesource for nodejs supported version and additional installation information + OSX: - ```bash @@ -305,6 +311,25 @@ If you would like to change Heimdall to your needs, you can use Heimdall's 'Deve sudo npm install -g yarn ``` + WINDOWS: + - Install Node.js via MSI Installer + - Download the node release 18.xx installer (msi) from the [nodejs site](https://nodejs.org/en/blog/release) + - Open and run (double-click) the .msi file, the installation process begins, follow the installation instructions + - Node.js offers you options to install tools for native modules, we recommend checking the Automatically install the necessary tools check box. + - Verify the Node and npm version + ```shell + node --version + npm --version + ``` + + - Install Yarn via MSI Installer + - Download the Yarn installation file from [GitHub](https://github.com/yarnpkg/yarn/releases/) + - Open and run the installation file, follow the installation instructions + - Run the following command in the PowerShell to verify the installation: + ```shell + yarn --version + ``` + 2. Clone this repository: - ```bash @@ -351,7 +376,31 @@ If you would like to change Heimdall to your needs, you can use Heimdall's 'Deve # Switch back to your original OS user exit ``` - + WINDOWS: + - Start the postgres server base on the installation method + - Starting Postgres Server Using `net start` + ```sql + net start postgresql-[x32 or x64]-[version] + ``` + - Starting Postgres Server Using `pg_ctl` + ```sql + pg_ctl -D "C:\[path-to-postgres-installation]\PostgreSQL\[version]\data" start + ``` + - Starting Postgres Server Using Services Manager + - Press the `win key + R` to launch the `Run` window. + - Type `services.msc` and hit the `OK` button to open the Services Manager: + - Search for `Postgresql-[x32 or x64]-[version]`, select the service, and hit the `Start/play` button to start + - Create the database user + - Recommend using pgAdmin and follow instruction listed here + - Open a postgres shell terminal (path to postgres executable directory must be set) + ```sql + # Start the terminal + psql -U postgres + # Create the database user + CREATE USER with encrypted password ''; + ALTER USER CREATEDB; + \q + ``` 4. Install project dependencies: @@ -360,13 +409,19 @@ If you would like to change Heimdall to your needs, you can use Heimdall's 'Deve yarn install ``` -5. Edit your apps/backend/.env file using the provided `setup-dev-env.sh` script. Make sure to set a DATABASE_USERNAME and DATABASE_PASSWORD that match what you set for the PostgresDB in step 3. +5. Edit your apps/backend/.env file using the provided `setup-dev-env.sh or setup-dev-env.bat` script. Make sure to set a DATABASE_USERNAME and DATABASE_PASSWORD that match what you set for the PostgresDB in step 3. -You can also open the apps/backend/.env file in a text editor and set additional optional configuration values. For more info on configuration values see [Enviroment Variables Configuration](https://github.com/mitre/heimdall2/wiki/Environment-Variables-Configuration). +You can also open the apps/backend/.env file in a text editor and set additional optional configuration values. For more info on configuration values see [Environment Variables Configuration](https://github.com/mitre/heimdall2/wiki/Environment-Variables-Configuration). 6. Create the database: - ```bash + # Windows + yarn backend sequelize-cli-windows db:create + yarn backend sequelize-cli-windows db:migrate + yarn backend sequelize-cli-windows db:seed:all + + # All other OSs yarn backend sequelize-cli db:create yarn backend sequelize-cli db:migrate yarn backend sequelize-cli db:seed:all @@ -382,7 +437,7 @@ This will start both the frontend and backend in development mode, meaning any c ### Debugging Heimdall Server -If you are using Visual Studio Code, it is very simple to debug this application locally. First open up the Visual Studio Code workspace and ensure the [Node debuger Auto Attach](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_auto-attach) feature in Visual Studio Code is enabled. Next, open the integrated Visual Studio Code terminal and run: +If you are using Visual Studio Code, it is very simple to debug this application locally. First open up the Visual Studio Code workspace and ensure the [Node debugger Auto Attach](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_auto-attach) feature in Visual Studio Code is enabled. Next, open the integrated Visual Studio Code terminal and run: ``` yarn backend start:debug diff --git a/apps/backend/package.json b/apps/backend/package.json index 56bbe04936..077056e685 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -15,6 +15,7 @@ "build": "nest build", "lint": "eslint \"{src,migrations,seeders,test}/**/*.ts\" --fix", "lint:ci": "eslint \"{src,migrations,seeders,test}/**/*.ts\" --max-warnings 0", + "sequelize-cli-windows": "node --experimental-loader ts-node/esm node_modules/sequelize-cli/lib/sequelize", "sequelize-cli": "node --experimental-loader ts-node/esm node_modules/.bin/sequelize", "start": "node dist/src/main", "start:debug": "nest start --debug --watch", diff --git a/apps/backend/src/app.service.ts b/apps/backend/src/app.service.ts index 46cbbf545d..e40ee35e43 100644 --- a/apps/backend/src/app.service.ts +++ b/apps/backend/src/app.service.ts @@ -14,13 +14,27 @@ export class AppService BeforeApplicationShutdown, OnApplicationShutdown { + private readonly line = '____________________________________________\n'; + private colors = winston.addColors({ + info: 'cyan', + warn: 'yellow', + error: 'red', + verbose: 'blue' + }); + public logger = winston.createLogger({ transports: [new winston.transports.Console()], format: winston.format.combine( + winston.format.colorize({all: true}), winston.format.timestamp({ format: 'MMM-DD-YYYY HH:mm:ss Z' }), - winston.format.printf((info) => `[${[info.timestamp]}] ${info.message}`) + winston.format.errors({stack: true}), + winston.format.align(), + winston.format.printf( + (info) => + `${this.line}[${[info.timestamp]}] (App Service): ${info.message}` + ) ) }); diff --git a/apps/backend/src/authn/authn.service.ts b/apps/backend/src/authn/authn.service.ts index 05eb11394d..b060cf8b4a 100644 --- a/apps/backend/src/authn/authn.service.ts +++ b/apps/backend/src/authn/authn.service.ts @@ -21,6 +21,7 @@ import {UsersService} from '../users/users.service'; @Injectable() export class AuthnService { + private readonly line = '_______________________________________________\n'; public loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; public logger = winston.createLogger({ transports: [new winston.transports.Console()], @@ -28,7 +29,10 @@ export class AuthnService { winston.format.timestamp({ format: this.loggingTimeFormat }), - winston.format.printf((info) => `[${[info.timestamp]}] ${info.message}`) + winston.format.printf( + (info) => + `${this.line}[${[info.timestamp]}] (Authn Service): ${info.message}` + ) ) }); diff --git a/apps/backend/src/casl/casl-ability.factory.ts b/apps/backend/src/casl/casl-ability.factory.ts index bd7ced0504..7dd0ddc9f8 100644 --- a/apps/backend/src/casl/casl-ability.factory.ts +++ b/apps/backend/src/casl/casl-ability.factory.ts @@ -1,9 +1,9 @@ import { - Ability, AbilityBuilder, - AbilityClass, + createMongoAbility, ExtractSubjectType, - InferSubjects + InferSubjects, + MongoAbility } from '@casl/ability'; import {Injectable} from '@nestjs/common'; import {Evaluation} from '../evaluations/evaluation.model'; @@ -13,7 +13,8 @@ import {User} from '../users/user.model'; type AllTypes = typeof User | typeof Evaluation | typeof Group; -type Subjects = InferSubjects | 'all'; +type Subjects = InferSubjects | 'all'; +type PossibleAbilities = [Action, Subjects]; export enum Action { Manage = 'manage', // manage is a special keyword in CASL which represents "any" action. @@ -49,16 +50,14 @@ interface EvaluationQuery extends Evaluation { 'groups.users.id': User['id']; } -export type AppAbility = Ability<[Action, Subjects]>; +export type AppAbility = MongoAbility; @Injectable() export class CaslAbilityFactory { - createForUser(user: User): Ability { - const {can, cannot, build} = new AbilityBuilder< - Ability<[Action, Subjects]> - >(Ability as AbilityClass); - + createForUser(user: User): MongoAbility { + const {can, cannot, build} = new AbilityBuilder(createMongoAbility); if (user.role === 'admin') { + // all is a special keyword in CASL that represents "any subject". // read-write access to everything can(Action.Manage, 'all'); // Read statistics about this heimdall deployment @@ -94,7 +93,7 @@ export class CaslAbilityFactory { // it requires every evaluation to have a join on Groups and then another join on Users can([Action.Create], Evaluation); - can([Action.Read], Evaluation, {public: true}); + can(Action.Read, Evaluation, {public: true}); can([Action.Manage], Evaluation, { userId: user.id @@ -119,11 +118,8 @@ export class CaslAbilityFactory { // This provides the ability to use the same codepath for validating // user abilities and non-registered user abilities. Useful for the // few anonymous endpoints we have. - createForAnonymous(): Ability { - const {cannot, build} = new AbilityBuilder>( - Ability - ); - + createForAnonymous(): MongoAbility { + const {cannot, build} = new AbilityBuilder(createMongoAbility); cannot(Action.Manage, 'all'); return build(); diff --git a/apps/backend/src/database/database.module.ts b/apps/backend/src/database/database.module.ts index 0897acf85a..507dc3829b 100644 --- a/apps/backend/src/database/database.module.ts +++ b/apps/backend/src/database/database.module.ts @@ -5,13 +5,22 @@ import {ConfigModule} from '../config/config.module'; import {ConfigService} from '../config/config.service'; import {DatabaseService} from './database.service'; +const line = '________________________________________________\n'; const logger = winston.createLogger({ transports: [new winston.transports.Console()], format: winston.format.combine( + winston.format.colorize({ + all: true + }), winston.format.timestamp({ format: 'MMM-DD-YYYY HH:mm:ss Z' }), - winston.format.printf((info) => `[${[info.timestamp]}] ${info.message}`) + winston.format.errors({stack: true}), + winston.format.align(), + winston.format.printf( + (info) => + `${line}[${info.timestamp}] Query(${info.queryType}): ${info.message}` + ) ) }); @@ -49,7 +58,8 @@ function logQuery( logger.info({ message: `${sql} [${sanitize(connection.fields, connection.bind).join( ', ' - )}]` + )}]`, + queryType: connection.type }); } diff --git a/apps/backend/src/evaluations/dto/evaluation.dto.ts b/apps/backend/src/evaluations/dto/evaluation.dto.ts index 540dd3d9fc..b4b2ca9e0f 100644 --- a/apps/backend/src/evaluations/dto/evaluation.dto.ts +++ b/apps/backend/src/evaluations/dto/evaluation.dto.ts @@ -52,3 +52,8 @@ export class EvaluationDto implements IEvaluation { this.shareURL = shareURL; } } + +export interface IEvaluationResponse { + evaluations: EvaluationDto[]; + totalCount: number; +} diff --git a/apps/backend/src/evaluations/evaluations.controller.ts b/apps/backend/src/evaluations/evaluations.controller.ts index 767b8dfaa4..c3f7d3aef5 100644 --- a/apps/backend/src/evaluations/evaluations.controller.ts +++ b/apps/backend/src/evaluations/evaluations.controller.ts @@ -1,4 +1,5 @@ -import {ForbiddenError} from '@casl/ability'; +import {ForbiddenError, Subject} from '@casl/ability'; +import {IEvalPaginationParams} from '@heimdall/interfaces'; import { BadRequestException, Body, @@ -8,6 +9,7 @@ import { Param, Post, Put, + Query, Request, UploadedFiles, UseGuards, @@ -27,8 +29,9 @@ import {CreateEvaluationInterceptor} from '../interceptors/create-evaluation-int import {LoggingInterceptor} from '../interceptors/logging.interceptor'; import {User} from '../users/user.model'; import {CreateEvaluationDto} from './dto/create-evaluation.dto'; -import {EvaluationDto} from './dto/evaluation.dto'; +import {EvaluationDto, IEvaluationResponse} from './dto/evaluation.dto'; import {UpdateEvaluationDto} from './dto/update-evaluation.dto'; +import {Evaluation} from './evaluation.model'; import {EvaluationsService} from './evaluations.service'; @Controller('evaluations') @@ -40,6 +43,7 @@ export class EvaluationsController { private readonly configService: ConfigService, private readonly authz: AuthzService ) {} + @UseGuards(APIKeyOrJwtAuthGuard) @Get(':id') async findById( @@ -69,7 +73,7 @@ export class EvaluationsController { } @UseGuards(APIKeyOrJwtAuthGuard) - @Get() + @Get('e2e') async findAll(@Request() request: {user: User}): Promise { const abac = this.authz.abac.createForUser(request.user); let evaluations = await this.evaluationsService.findAll(); @@ -84,6 +88,48 @@ export class EvaluationsController { ); } + @UseGuards(APIKeyOrJwtAuthGuard) + @Get() + async findAndCountAll( + @Query() params: IEvalPaginationParams, + @Request() request: {user: User} + ): Promise { + const abac = this.authz.abac.createForUser(request.user); + let evaluations: Evaluation[] = []; + let totalItems = 0; + + if (params.useClause) { + const response = await this.evaluationsService.getEvaluationsWithClause( + params, + request.user.email, + request.user.role + ); + evaluations = response.evaluations; + totalItems = response.totalItems; + } else { + const response = await this.evaluationsService.getAllEvaluations( + params, + request.user.email, + request.user.role + ); + evaluations = response.evaluations; + totalItems = response.totalItems; + } + + // Show public evaluations or those created by logged in user + evaluations = evaluations.filter((evaluation: Subject) => + abac.can(Action.Read, evaluation) + ); + + return { + evaluations: evaluations.map( + (evaluation: Evaluation) => + new EvaluationDto(evaluation, abac.can(Action.Update, evaluation)) + ), + totalCount: totalItems + }; + } + @UseGuards(APIKeyOrJwtAuthGuard) @Post() @UseInterceptors( diff --git a/apps/backend/src/evaluations/evaluations.service.ts b/apps/backend/src/evaluations/evaluations.service.ts index 238f151d87..effd1abe1a 100644 --- a/apps/backend/src/evaluations/evaluations.service.ts +++ b/apps/backend/src/evaluations/evaluations.service.ts @@ -1,6 +1,7 @@ +import {IEvalPaginationParams} from '@heimdall/interfaces'; import {Injectable, NotFoundException} from '@nestjs/common'; import {InjectModel} from '@nestjs/sequelize'; -import {FindOptions} from 'sequelize/types'; +import {FindOptions, Op, WhereOptions} from 'sequelize'; import {DatabaseService} from '../database/database.service'; import {CreateEvaluationTagDto} from '../evaluation-tags/dto/create-evaluation-tag.dto'; import {EvaluationTag} from '../evaluation-tags/evaluation-tag.model'; @@ -9,6 +10,10 @@ import {User} from '../users/user.model'; import {UpdateEvaluationDto} from './dto/update-evaluation.dto'; import {Evaluation} from './evaluation.model'; +interface EvaluationsResponse { + totalItems: number; + evaluations: Evaluation[]; +} @Injectable() export class EvaluationsService { constructor( @@ -24,6 +29,198 @@ export class EvaluationsService { }); } + /* + NOTES: These notes are about the getAllEvaluations() and the + getEvaluationsWithClause() methods + + 1: The sequelize model is using eager loading, at the SQL level, this is a + query with one or more joins. This is done by using the include option + on a model finder query (findAll). Given that we are using multiple JOIN + operations, particularly one on the Groups, if a record has multiple + groups the query will return multiple rows one per group. This offsets + the LIMIT because the return data is in JSON format where the groups are + objects within the scan id, but are individual SQL records. + + For this reason there will be times where the number of records returned + are less than the asked by the LIMIT. + + 2: TypeScript is not able to infer OrderItem[]. + + The 'order' option in sequelize is defined as type OrderItem like: + string | fn | col | literal | [string | col | fn | literal, string] | + [Model | { model: Model, as: string }, string, string] | + [Model, Model, string, string] + + When using TypeScript the order variable is of type string[], which is not + supported by OrderItem. The only way to avoid it is to add string[] to the + list of accepted types in OrderItem. + + Hence the reason the order option is being initialized with array indices. + + 3: Not using sequelize findAndCountAll method because the count returned is + for all records found which includes multiple entries (due to JOIN) for + each record if evaluations have multiple Groups that belong to different + users. + + Using the findAll and calling specific queries to determine the total records. + + 4: Using ORDER BY on top-level and nested columns, for that reason we need + to reference nested columns by utilizing the '$nested.column$' syntax. + For that reason the params.order array can have 2 or 3 indices as + listed bellow. + + params.order values are as follows () represent index): + length (0) (1) (2) + 2 field name order (asc/desc) + 3 table name field name order (asc/desc) + + */ + async getAllEvaluations( + params: IEvalPaginationParams, + email: string, + role: string + ): Promise { + const queryResponse: EvaluationsResponse = { + totalItems: 0, + evaluations: [] + }; + const whereClause = this.getWhereClauseAll(role, email); + + await this.evaluationModel + .findAll({ + attributes: {exclude: ['data']}, + include: [EvaluationTag, User, Group], + offset: params.offset, + limit: params.limit, + order: + params.order.length == 2 + ? [[params.order[0], params.order[1]]] + : [[params.order[0], params.order[1], params.order[2]]], + subQuery: false, + where: whereClause + }) + .then(async (data) => { + const totalItems = await this.evaluationCount(email, role); + queryResponse.evaluations = data; + queryResponse.totalItems = totalItems; + }); + + return queryResponse; + } + + async getEvaluationsWithClause( + params: IEvalPaginationParams, + email: string, + role: string + ): Promise { + const queryResponse: EvaluationsResponse = { + totalItems: 0, + evaluations: [] + }; + const whereClause = this.getWhereClauseSearch( + params.searchFields == undefined ? [''] : params.searchFields, + params.operator == undefined ? 'OR' : params.operator, + email, + role + ); + + await this.evaluationModel + .findAll({ + attributes: {exclude: ['data']}, + include: [EvaluationTag, User, Group], + offset: params.offset, + limit: params.limit, + order: + params.order.length == 2 + ? [[params.order[0], params.order[1]]] + : [[params.order[0], params.order[1], params.order[2]]], + subQuery: false, + where: whereClause + }) + .then(async (data) => { + const totalItems = await this.searchItemsCount(whereClause); + queryResponse.evaluations = data; + queryResponse.totalItems = totalItems; + }); + + return queryResponse; + } + + getWhereClauseAll(role: string, email: string): WhereOptions { + const whereClause = this.getWhereClauseBaseCriteria(role, email); + return {[Op.or]: whereClause}; + } + + getWhereClauseBaseCriteria(role: string, email: string): WhereOptions { + const baseCriteria = []; + baseCriteria.push({public: {[Op.eq]: 'true'}}); + if (role == 'admin') { + baseCriteria.push({public: {[Op.eq]: 'false'}}); + } else { + baseCriteria.push({'$user.email$': {[Op.like]: `${email}`}}); + } + return baseCriteria; + } + + getWhereClauseSearch( + fields: string[], + operation: string, + email: string, + role: string + ): WhereOptions { + const searchFields = []; + const baseCriteria = this.getWhereClauseBaseCriteria(role, email); + + if (fields[0] != '()') { + searchFields.push({filename: {[Op.iRegexp]: `${fields[0]}`}}); + } + if (fields[1] != '()') { + searchFields.push({'$groups.name$': {[Op.iRegexp]: `${fields[1]}`}}); + } + if (fields[2] != '()') { + searchFields.push({ + '$evaluationTags.value$': {[Op.iRegexp]: `${fields[2]}`} + }); + } + + if (operation == 'AND') { + // Expected outcome: an OR baseCriteria AND an AND searchFields + return {[Op.or]: baseCriteria, [Op.and]: searchFields}; + } else { + // Expected outcome: an OR baseCriteria AND an OR searchFields + return { + [Op.and]: [{[Op.or]: baseCriteria}, {[Op.and]: {[Op.or]: searchFields}}] + }; + } + } + + async evaluationCount(userEmail: string, role: string): Promise { + if (role == 'admin') { + return this.evaluationModel.count(); + } else { + return this.evaluationModel.count({ + include: User, + where: { + [Op.or]: [ + {public: {[Op.eq]: 'true'}}, + {'$user.email$': {[Op.like]: `${userEmail}`}} + ] + }, + distinct: true, + col: 'id' + }); + } + } + + async searchItemsCount(whereClause: WhereOptions): Promise { + return this.evaluationModel.count({ + include: [EvaluationTag, User, Group], + where: whereClause, + distinct: true, + col: 'id' + }); + } + async count(): Promise { return this.evaluationModel.count(); } diff --git a/apps/backend/src/groups/groups.service.ts b/apps/backend/src/groups/groups.service.ts index f79e1777c7..a16d95c36c 100644 --- a/apps/backend/src/groups/groups.service.ts +++ b/apps/backend/src/groups/groups.service.ts @@ -16,13 +16,17 @@ import {UpdateGroupUserRoleDto} from './dto/update-group-user.dto'; import {Group} from './group.model'; @Injectable() export class GroupsService { + private readonly line = '_______________________________________________\n'; public logger = winston.createLogger({ transports: [new winston.transports.Console()], format: winston.format.combine( winston.format.timestamp({ format: 'MMM-DD-YYYY HH:mm:ss Z' }), - winston.format.printf((info) => `[${[info.timestamp]}] ${info.message}`) + winston.format.printf( + (info) => + `${this.line}[${[info.timestamp]}] (Group Service): ${info.message}` + ) ) }); constructor( diff --git a/apps/backend/src/interceptors/logging.interceptor.ts b/apps/backend/src/interceptors/logging.interceptor.ts index 365a5418f8..aafbfd799a 100644 --- a/apps/backend/src/interceptors/logging.interceptor.ts +++ b/apps/backend/src/interceptors/logging.interceptor.ts @@ -16,6 +16,7 @@ import {User} from '../users/user.model'; @Injectable() export class LoggingInterceptor implements NestInterceptor { private readonly configService: ConfigService; + private readonly line = '___________________________________________\n'; constructor(configService: ConfigService) { this.configService = configService; @@ -28,9 +29,9 @@ export class LoggingInterceptor implements NestInterceptor { }), winston.format.printf( (info) => - `[${[info.timestamp]}] ${info.ip} ${info.referer} ${info.userAgent} ${ - info.user - } ${info.message}` + `${this.line}[${[info.timestamp]}] (Interceptor): ${info.ip} ${ + info.referer + } ${info.userAgent} ${info.user} ${info.message}` ) ) }); diff --git a/apps/backend/test/evaluations.e2e-spec.ts b/apps/backend/test/evaluations.e2e-spec.ts index cd7ff811e6..cc197c115f 100644 --- a/apps/backend/test/evaluations.e2e-spec.ts +++ b/apps/backend/test/evaluations.e2e-spec.ts @@ -56,7 +56,7 @@ describe('/evaluations', () => { describe('Read', () => { it('should return 401 when unauthenticated', async () => { await request(app.getHttpServer()) - .get('/evaluations') + .get('/evaluations/e2e') .expect(HttpStatus.UNAUTHORIZED); }); }); @@ -115,7 +115,6 @@ describe('/evaluations', () => { expect(updatedDelta).toBeLessThanOrEqual(MINUTE_IN_MILLISECONDS); expect(response.body.id).toBeDefined(); expect(response.body.filename).toEqual(EVALUATION_1.filename); - expect(response.body.data).toEqual(EVALUATION_1.data); expect(response.body.evaluationTags).toEqual( EVALUATION_1.evaluationTags ); @@ -187,7 +186,7 @@ describe('/evaluations', () => { it('should get all evaluations', async () => { await request(app.getHttpServer()) - .get('/evaluations') + .get('/evaluations/e2e') .set('Authorization', 'bearer ' + jwtToken) .expect(HttpStatus.OK) .then((response) => { @@ -215,7 +214,6 @@ describe('/evaluations', () => { expect(updatedDelta).toBeLessThanOrEqual(MINUTE_IN_MILLISECONDS); expect(response.body.updatedAt); expect(response.body.data).toEqual(evaluation.data); - expect(response.body.version).toEqual(evaluation.version); }); }); }); diff --git a/apps/frontend/public/static/export/style.css b/apps/frontend/public/static/export/style.css index 78fe34acac..ce5f4198f9 100644 --- a/apps/frontend/public/static/export/style.css +++ b/apps/frontend/public/static/export/style.css @@ -1 +1 @@ -/*! tailwindcss v3.3.7 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;-webkit-box-sizing:border-box;box-sizing:border-box}:after,:before{--tw-content:""}html{-webkit-text-size-adjust:100%;-webkit-font-feature-settings:normal;font-feature-settings:normal;font-family:Roboto,sans-serif;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{-webkit-font-feature-settings:normal;font-feature-settings:normal;font-family:ui-monospace,monospace;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{-webkit-font-feature-settings:inherit;font-feature-settings:inherit;color:inherit;font-family:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-webkit-input-placeholder,textarea::-webkit-input-placeholder{color:#9ca3af;opacity:1}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input:-ms-input-placeholder,textarea:-ms-input-placeholder{color:#9ca3af;opacity:1}input::-ms-input-placeholder,textarea::-ms-input-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}input[type=range]::-webkit-slider-thumb{appearance:none;-moz-appearance:none;-webkit-appearance:none;background:#3b71ca;border:0;border-radius:9999px;cursor:pointer;height:1rem;width:1rem}input[type=range]:disabled::-webkit-slider-thumb,input[type=range]:disabled:focus::-webkit-slider-thumb{background:#a3a3a3}input[type=range]:disabled:active::-webkit-slider-thumb{background:#a3a3a3}input[type=range]::-moz-range-thumb{appearance:none;-moz-appearance:none;-webkit-appearance:none;background:#3b71ca;border:0;border-radius:9999px;cursor:pointer;height:1rem;width:1rem}input[type=range]:disabled::-moz-range-thumb{background:#a3a3a3}input[type=range]::-moz-range-progress{background:#3061af}input[type=range]::-ms-fill-lower{background:#3061af}input[type=range]:focus{outline:none}input[type=range]:focus::-webkit-slider-thumb{background:#3061af}input[type=range]:active::-webkit-slider-thumb{background:#285192}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::-ms-backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.collapse{visibility:collapse}.relative{position:relative}.top-0{top:0}.z-10{z-index:10}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-6{margin-left:1.5rem;margin-right:1.5rem}.my-1{margin-bottom:.25rem;margin-top:.25rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.-mr-1{margin-right:-.25rem}.mb-0{margin-bottom:0}.mb-2{margin-bottom:.5rem}.ml-auto{margin-left:auto}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.block{display:block}.inline-block{display:inline-block}.flex{display:-webkit-box;display:-ms-flexbox;display:flex}.inline-flex{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1{height:.25rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.w-1\/2{width:50%}.w-24{width:6rem}.w-28{width:7rem}.w-32{width:8rem}.w-36{width:9rem}.w-40{width:10rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-full{width:100%}.min-w-full{min-width:100%}.shrink{-ms-flex-negative:1;flex-shrink:1}.shrink-0{-ms-flex-negative:0;flex-shrink:0}.border-separate{border-collapse:separate}.border-spacing-x-2{--tw-border-spacing-x:0.5rem;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.rotate-\[-180deg\]{--tw-rotate:-180deg;-webkit-transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.grid-flow-row{grid-auto-flow:row}.grid-flow-col{grid-auto-flow:column}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-wrap{-ms-flex-wrap:wrap;flex-wrap:wrap}.items-center{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.justify-between{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.gap-x-2{-webkit-column-gap:.5rem;-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-6{-webkit-column-gap:1.5rem;-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-1{row-gap:.25rem}.gap-y-2{row-gap:.5rem}.overflow-hidden{overflow:hidden}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-xl{border-radius:.75rem}.rounded-t-xl{border-top-left-radius:.75rem;border-top-right-radius:.75rem}.border{border-width:1px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-black{--tw-border-opacity:1;border-color:rgb(0 0 0/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.fill-\[\#336dec\]{fill:#336dec}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-6{padding-top:1.5rem}.pb-6,.py-6{padding-bottom:1.5rem}.pl-3{padding-left:.75rem}.pl-4{padding-left:1rem}.pl-8{padding-left:2rem}.pr-1{padding-right:.25rem}.pt-1{padding-top:.25rem}.text-left{text-align:left}.text-center{text-align:center}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);-webkit-box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.transition{-webkit-transition-duration:.15s;transition-duration:.15s;-webkit-transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,-webkit-box-shadow,-webkit-transform,-webkit-filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,-webkit-box-shadow,-webkit-transform,-webkit-filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-box-shadow,-webkit-transform,-webkit-filter,-webkit-backdrop-filter;-webkit-transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{-webkit-transition-duration:.15s;transition-duration:.15s;-webkit-transition-property:-webkit-transform;transition-property:-webkit-transform;transition-property:transform;transition-property:transform,-webkit-transform;-webkit-transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{-webkit-transition-duration:.2s;transition-duration:.2s}.ease-in-out{-webkit-transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1)}.\[overflow-anchor\:none\]{overflow-anchor:none}.odd\:bg-gray-100:nth-child(odd){--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.even\:bg-white:nth-child(2n){--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.hover\:z-\[2\]:hover{z-index:2}.focus\:z-\[3\]:focus{z-index:3}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.group[data-te-collapse-collapsed] .group-\[\[data-te-collapse-collapsed\]\]\:mr-0{margin-right:0}.group[data-te-collapse-collapsed] .group-\[\[data-te-collapse-collapsed\]\]\:rotate-0{--tw-rotate:0deg;-webkit-transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group[data-te-collapse-collapsed] .group-\[\[data-te-collapse-collapsed\]\]\:fill-\[\#212529\]{fill:#212529}@media (prefers-reduced-motion:reduce){.motion-reduce\:transition-none{-webkit-transition-property:none;transition-property:none}}@media print{.print\:block{display:block}.print\:hidden{display:none}.print\:border-none{border-style:none}.print\:border-black{--tw-border-opacity:1;border-color:rgb(0 0 0/var(--tw-border-opacity))}.print\:bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.print\:text-left{text-align:left}.print\:text-2xl{font-size:1.5rem;line-height:2rem}}@media (min-width:640px){.sm\:block{display:block}.sm\:grid{display:grid}.sm\:hidden{display:none}.sm\:w-40{width:10rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-\[10\%_90\%\]{grid-template-columns:10% 90%}.sm\:break-words{overflow-wrap:break-word}.sm\:font-normal{font-weight:400}.sm\:no-underline{text-decoration-line:none}}@media (min-width:768px){.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:1024px){.lg\:sticky{position:sticky}.lg\:block{display:block}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:w-32{width:8rem}.lg\:w-36{width:9rem}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:break-normal{overflow-wrap:normal;word-break:normal}.lg\:pl-9{padding-left:2.25rem}}@media (min-width:1280px){.xl\:w-52{width:13rem}.xl\:grid-flow-col{grid-auto-flow:column}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:bg-blue-200:not([data-te-collapse-collapsed]){--tw-bg-opacity:1;background-color:rgb(191 219 254/var(--tw-bg-opacity))}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:text-blue-900:not([data-te-collapse-collapsed]){--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity))}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:\[box-shadow\:inset_0_-1px_0_rgba\(229\2c 231\2c 235\)\]:not([data-te-collapse-collapsed]){-webkit-box-shadow:inset 0 -1px 0 #e5e7eb;box-shadow:inset 0 -1px 0 #e5e7eb}.\[\&\[data-te-dropdown-show\]\]\:grid[data-te-dropdown-show]{display:grid} \ No newline at end of file +/*! tailwindcss v3.4.0 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;-webkit-box-sizing:border-box;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{-webkit-text-size-adjust:100%;-webkit-font-feature-settings:normal;font-feature-settings:normal;-webkit-tap-highlight-color:transparent;font-family:Roboto,sans-serif;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{-webkit-font-feature-settings:normal;font-feature-settings:normal;font-family:ui-monospace,monospace;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{-webkit-font-feature-settings:inherit;font-feature-settings:inherit;color:inherit;font-family:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-webkit-input-placeholder,textarea::-webkit-input-placeholder{color:#9ca3af;opacity:1}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input:-ms-input-placeholder,textarea:-ms-input-placeholder{color:#9ca3af;opacity:1}input::-ms-input-placeholder,textarea::-ms-input-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}input[type=range]::-webkit-slider-thumb{appearance:none;-moz-appearance:none;-webkit-appearance:none;background:#3b71ca;border:0;border-radius:9999px;cursor:pointer;height:1rem;width:1rem}input[type=range]:disabled::-webkit-slider-thumb,input[type=range]:disabled:focus::-webkit-slider-thumb{background:#a3a3a3}input[type=range]:disabled:active::-webkit-slider-thumb{background:#a3a3a3}input[type=range]::-moz-range-thumb{appearance:none;-moz-appearance:none;-webkit-appearance:none;background:#3b71ca;border:0;border-radius:9999px;cursor:pointer;height:1rem;width:1rem}input[type=range]:disabled::-moz-range-thumb{background:#a3a3a3}input[type=range]::-moz-range-progress{background:#3061af}input[type=range]::-ms-fill-lower{background:#3061af}input[type=range]:focus{outline:none}input[type=range]:focus::-webkit-slider-thumb{background:#3061af}input[type=range]:active::-webkit-slider-thumb{background:#285192}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::-ms-backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.collapse{visibility:collapse}.relative{position:relative}.top-0{top:0}.z-10{z-index:10}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-6{margin-left:1.5rem;margin-right:1.5rem}.my-1{margin-bottom:.25rem;margin-top:.25rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.-mr-1{margin-right:-.25rem}.mb-0{margin-bottom:0}.mb-2{margin-bottom:.5rem}.ml-auto{margin-left:auto}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.block{display:block}.inline-block{display:inline-block}.flex{display:-webkit-box;display:-ms-flexbox;display:flex}.inline-flex{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1{height:.25rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.w-1\/2{width:50%}.w-24{width:6rem}.w-28{width:7rem}.w-32{width:8rem}.w-36{width:9rem}.w-40{width:10rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-full{width:100%}.min-w-full{min-width:100%}.shrink{-ms-flex-negative:1;flex-shrink:1}.shrink-0{-ms-flex-negative:0;flex-shrink:0}.border-separate{border-collapse:separate}.border-spacing-x-2{--tw-border-spacing-x:0.5rem;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.rotate-\[-180deg\]{--tw-rotate:-180deg;-webkit-transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.grid-flow-row{grid-auto-flow:row}.grid-flow-col{grid-auto-flow:column}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-wrap{-ms-flex-wrap:wrap;flex-wrap:wrap}.items-center{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.justify-between{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.gap-x-2{-webkit-column-gap:.5rem;-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-6{-webkit-column-gap:1.5rem;-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-1{row-gap:.25rem}.gap-y-2{row-gap:.5rem}.overflow-hidden{overflow:hidden}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-xl{border-radius:.75rem}.rounded-t-xl{border-top-left-radius:.75rem;border-top-right-radius:.75rem}.border{border-width:1px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-black{--tw-border-opacity:1;border-color:rgb(0 0 0/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.fill-\[\#336dec\]{fill:#336dec}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-6{padding-top:1.5rem}.pb-6,.py-6{padding-bottom:1.5rem}.pl-3{padding-left:.75rem}.pl-4{padding-left:1rem}.pl-8{padding-left:2rem}.pr-1{padding-right:.25rem}.pt-1{padding-top:.25rem}.text-left{text-align:left}.text-center{text-align:center}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);-webkit-box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.transition{-webkit-transition-duration:.15s;transition-duration:.15s;-webkit-transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,-webkit-box-shadow,-webkit-transform,-webkit-filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,-webkit-box-shadow,-webkit-transform,-webkit-filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-box-shadow,-webkit-transform,-webkit-filter,-webkit-backdrop-filter;-webkit-transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{-webkit-transition-duration:.15s;transition-duration:.15s;-webkit-transition-property:-webkit-transform;transition-property:-webkit-transform;transition-property:transform;transition-property:transform,-webkit-transform;-webkit-transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{-webkit-transition-duration:.2s;transition-duration:.2s}.ease-in-out{-webkit-transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1)}.\[overflow-anchor\:none\]{overflow-anchor:none}.odd\:bg-gray-100:nth-child(odd){--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.even\:bg-white:nth-child(2n){--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.hover\:z-\[2\]:hover{z-index:2}.focus\:z-\[3\]:focus{z-index:3}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.group[data-te-collapse-collapsed] .group-\[\[data-te-collapse-collapsed\]\]\:mr-0{margin-right:0}.group[data-te-collapse-collapsed] .group-\[\[data-te-collapse-collapsed\]\]\:rotate-0{--tw-rotate:0deg;-webkit-transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group[data-te-collapse-collapsed] .group-\[\[data-te-collapse-collapsed\]\]\:fill-\[\#212529\]{fill:#212529}@media (prefers-reduced-motion:reduce){.motion-reduce\:transition-none{-webkit-transition-property:none;transition-property:none}}@media print{.print\:block{display:block}.print\:hidden{display:none}.print\:border-none{border-style:none}.print\:border-black{--tw-border-opacity:1;border-color:rgb(0 0 0/var(--tw-border-opacity))}.print\:bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.print\:text-left{text-align:left}.print\:text-2xl{font-size:1.5rem;line-height:2rem}}@media (min-width:640px){.sm\:block{display:block}.sm\:grid{display:grid}.sm\:hidden{display:none}.sm\:w-40{width:10rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-\[10\%_90\%\]{grid-template-columns:10% 90%}.sm\:break-words{overflow-wrap:break-word}.sm\:font-normal{font-weight:400}.sm\:no-underline{text-decoration-line:none}}@media (min-width:768px){.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:1024px){.lg\:sticky{position:sticky}.lg\:block{display:block}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:w-32{width:8rem}.lg\:w-36{width:9rem}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:break-normal{overflow-wrap:normal;word-break:normal}.lg\:pl-9{padding-left:2.25rem}}@media (min-width:1280px){.xl\:w-52{width:13rem}.xl\:grid-flow-col{grid-auto-flow:column}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:bg-blue-200:not([data-te-collapse-collapsed]){--tw-bg-opacity:1;background-color:rgb(191 219 254/var(--tw-bg-opacity))}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:text-blue-900:not([data-te-collapse-collapsed]){--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity))}.\[\&\:not\(\[data-te-collapse-collapsed\]\)\]\:\[box-shadow\:inset_0_-1px_0_rgba\(229\2c 231\2c 235\)\]:not([data-te-collapse-collapsed]){-webkit-box-shadow:inset 0 -1px 0 #e5e7eb;box-shadow:inset 0 -1px 0 #e5e7eb}.\[\&\[data-te-dropdown-show\]\]\:grid[data-te-dropdown-show]{display:grid} \ No newline at end of file diff --git a/apps/frontend/src/components/generic/CopyButton.vue b/apps/frontend/src/components/generic/CopyButton.vue index bc74f16763..5c1a5d2f24 100644 --- a/apps/frontend/src/components/generic/CopyButton.vue +++ b/apps/frontend/src/components/generic/CopyButton.vue @@ -1,5 +1,14 @@ diff --git a/apps/frontend/src/components/global/admin/Statistics.vue b/apps/frontend/src/components/global/admin/Statistics.vue index 137970d405..739488d38a 100644 --- a/apps/frontend/src/components/global/admin/Statistics.vue +++ b/apps/frontend/src/components/global/admin/Statistics.vue @@ -7,7 +7,7 @@ {{ toCapitalizedWords(name) }} - {{ value }} + {{ value.toLocaleString() }} diff --git a/apps/frontend/src/components/global/sidebaritems/DropdownContent.vue b/apps/frontend/src/components/global/sidebaritems/DropdownContent.vue index cca1947f6d..d2a793a3e4 100644 --- a/apps/frontend/src/components/global/sidebaritems/DropdownContent.vue +++ b/apps/frontend/src/components/global/sidebaritems/DropdownContent.vue @@ -34,6 +34,18 @@ + + + Remove selected results + mdi-text-box-remove-outline + + + - mdi-content-save + + mdi-content-save + - mdi-close + + mdi-playlist-remove + diff --git a/apps/frontend/src/components/global/tags/TagRow.vue b/apps/frontend/src/components/global/tags/TagRow.vue index ce26db8888..35967328bd 100644 --- a/apps/frontend/src/components/global/tags/TagRow.vue +++ b/apps/frontend/src/components/global/tags/TagRow.vue @@ -2,11 +2,25 @@
- mdi-tag-plus + + mdi-tag-plus +