diff --git a/.github/workflows/api-audit-test-coverage-response.yml b/.github/workflows/api-audit-test-coverage-response.yml index fb2b5e480..ec7af69aa 100644 --- a/.github/workflows/api-audit-test-coverage-response.yml +++ b/.github/workflows/api-audit-test-coverage-response.yml @@ -29,6 +29,7 @@ env: STIGMAN_SWAGGER_SERVER: http://localhost:64001/api STIGMAN_SWAGGER_REDIRECT: http://localhost:64001/api-docs/oauth2-redirect.html STIGMAN_DEV_RESPONSE_VALIDATION: logOnly + STIGMAN_EXPERIMENTAL_APPDATA: 'true' NODE_V8_COVERAGE: /home/runner/work/stig-manager/stig-manager/api/source/coverage/tmp/ permissions: diff --git a/.github/workflows/api-container-tests.yml b/.github/workflows/api-container-tests.yml index 47687a855..3d8634579 100644 --- a/.github/workflows/api-container-tests.yml +++ b/.github/workflows/api-container-tests.yml @@ -65,6 +65,7 @@ jobs: -e STIGMAN_DB_PASSWORD=stigman \ -e STIGMAN_API_AUTHORITY=http://127.0.0.1:8080/auth/realms/stigman \ -e STIGMAN_DEV_RESPONSE_VALIDATION=logOnly \ + -e STIGMAN_EXPERIMENTAL_APPDATA=true \ ${{ matrix.container.name }} - name: Install test dependencies diff --git a/api/source/controllers/Operation.js b/api/source/controllers/Operation.js index 8f816b43d..24401177d 100644 --- a/api/source/controllers/Operation.js +++ b/api/source/controllers/Operation.js @@ -33,6 +33,9 @@ module.exports.setConfigurationItem = async function setConfigurationItem (req, module.exports.getAppData = async function getAppData (req, res, next) { try { + if (!config.experimental.appData) { + throw new SmError.NotFoundError('endpoint disabled, to enable set STIGMAN_EXPERIMENTAL_APPDATA=true') + } let elevate = req.query.elevate if ( elevate ) { let collections = await Collection.exportCollections( ['grants', 'labels', 'stigs'], elevate, req.userObject ) @@ -81,6 +84,9 @@ module.exports.getAppData = async function getAppData (req, res, next) { module.exports.replaceAppData = async function replaceAppData (req, res, next) { try { + if (!config.experimental.appData) { + throw new SmError.NotFoundError('endpoint disabled, to enable set STIGMAN_EXPERIMENTAL_APPDATA=true') + } req.noCompression = true let elevate = req.query.elevate let appdata @@ -129,11 +135,11 @@ module.exports.getDefinition = async function getDefinition (req, res, next) { } } -module.exports.getDetails = async function getDetails (req, res, next) { +module.exports.getAppInfo = async function getAppInfo (req, res, next) { try { let elevate = req.query.elevate if ( elevate ) { - const response = await OperationService.getDetails() + const response = await OperationService.getAppInfo() res.json(response) } else { @@ -144,3 +150,5 @@ module.exports.getDetails = async function getDetails (req, res, next) { next(err) } } + +module.exports.getDetails = module.exports.getAppInfo diff --git a/api/source/index.js b/api/source/index.js index 98f1b1c1f..89f85933b 100644 --- a/api/source/index.js +++ b/api/source/index.js @@ -130,7 +130,7 @@ app.use((err, req, res, next) => { }) } // Expose selected error properties in the response - res.errorBody = { error: err.message, detail: err.detail, stack: err.stack} + res.errorBody = { error: err.message, code: err.code, detail: err.detail, stack: err.stack} if (!res._headerSent) { res.status(err.status || 500).header(err.headers).json(res.errorBody) } @@ -224,6 +224,9 @@ const STIGMAN = { privileges: "${config.oauth.claims.privileges}", email: "${config.oauth.claims.email}" } + }, + experimental: { + appData: "${config.experimental.appData}" } } } diff --git a/api/source/package-lock.json b/api/source/package-lock.json index 68fc8a44b..6a5897134 100644 --- a/api/source/package-lock.json +++ b/api/source/package-lock.json @@ -620,9 +620,9 @@ } }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "engines": { "node": ">= 0.6" } @@ -807,16 +807,16 @@ } }, "node_modules/express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", diff --git a/api/source/service/OperationService.js b/api/source/service/OperationService.js index 3d1501ddb..6c9c7ee79 100644 --- a/api/source/service/OperationService.js +++ b/api/source/service/OperationService.js @@ -1,10 +1,9 @@ 'use strict'; const dbUtils = require('./utils') const config = require('../utils/config') -const {privilegeGetter} = require('../utils/auth') const logger = require('../utils/logger') -const _ = require('lodash') - +const klona = require('../utils/klona') +const os = require('node:os') /** * Return version information @@ -549,9 +548,9 @@ exports.replaceAppData = async function (importOpts, appData, userObject, res ) } } -exports.getDetails = async function() { - const sqlAnalyze = `ANALYZE TABLE - collection, asset, review, review_history, user` +exports.getAppInfo = async function() { + const schema = 'stig-manager-appinfo-v1.0' + const sqlAnalyze = `ANALYZE TABLE collection, asset, review, review_history, user` const sqlInfoSchema = ` SELECT TABLE_NAME as tableName, @@ -559,7 +558,6 @@ exports.getDetails = async function() { TABLE_COLLATION as tableCollation, AVG_ROW_LENGTH as avgRowLength, DATA_LENGTH as dataLength, - MAX_DATA_LENGTH as maxDataLength, INDEX_LENGTH as indexLength, AUTO_INCREMENT as autoIncrement, CREATE_TIME as createTime, @@ -568,13 +566,13 @@ exports.getDetails = async function() { information_schema.TABLES WHERE TABLE_SCHEMA = ? + and TABLE_TYPE='BASE TABLE' ORDER BY - TABLE_NAME - ` + TABLE_NAME` const sqlCollectionAssetStigs = ` SELECT CAST(sub.collectionId as char) as collectionId, - sum(case when sub.assetId then 1 else 0 end) as assetCnt, + sum(case when sub.assetId is not null and sub.stigAssetCnt = 0 then 1 else 0 end) as range00, sum(case when sub.stigAssetCnt >= 1 and sub.stigAssetCnt <= 5 then 1 else 0 end) as range01to05, sum(case when sub.stigAssetCnt >= 6 and sub.stigAssetCnt <= 10 then 1 else 0 end) as range06to10, sum(case when sub.stigAssetCnt >= 11 and sub.stigAssetCnt <= 15 then 1 else 0 end) as range11to15, @@ -598,29 +596,22 @@ exports.getDetails = async function() { ORDER BY sub.collectionId ` - const sqlCountsByCollection = ` SELECT cast(c.collectionId as char) as collectionId, + c.name, c.state, - count(distinct a.assetId) as assetsTotal, - count( distinct - if(a.state = "disabled", a.assetId, null) - ) - as assetsDisabled, - count(distinct sa.benchmarkId) as uniqueStigs, - count(sa.saId) as stigAssignments, - coalesce(sum(rev.ruleCount),0) - as ruleCnt, - coalesce( - sum(sa.pass + sa.fail + sa.notapplicable + sa.notchecked + sa.notselected + sa.informational + sa.fixed + sa.unknown + sa.error),0) - as reviewCntTotal, - coalesce( - sum(if(a.state = "disabled", (sa.pass + sa.fail + sa.notapplicable + sa.notchecked + sa.notselected + sa.informational + sa.fixed + sa.unknown + sa.error), 0))) - as reviewCntDisabled + c.settings, + count(distinct if(a.state = "enabled", a.assetId, null)) as assets, + count(distinct if(a.state = "disabled", a.assetId, null)) as assetsDisabled, + count(distinct if(a.state = "enabled", sa.benchmarkId, null)) as uniqueStigs, + sum(if(a.state = "enabled" and sa.saId, 1, 0)) as stigAssignments, + sum(if(a.state = "enabled",rev.ruleCount,0)) as rules, + sum(if(a.state = "enabled", (sa.pass + sa.fail + sa.notapplicable + sa.notchecked + sa.notselected + sa.informational + sa.fixed + sa.unknown + sa.error), 0)) as reviews, + sum(if(a.state = "disabled", (sa.pass + sa.fail + sa.notapplicable + sa.notchecked + sa.notselected + sa.informational + sa.fixed + sa.unknown + sa.error), 0)) as reviewsDisabled FROM collection c - left join asset a on c.collectionId = a.collectionId + left join asset a on c.collectionId = a.collectionId left join stig_asset_map sa on a.assetId = sa.assetId left join default_rev dr on c.collectionId = dr.collectionId and sa.benchmarkId = dr.benchmarkId left join revision rev on dr.revId = rev.revId @@ -629,46 +620,66 @@ exports.getDetails = async function() { ORDER BY c.collectionId ` - const sqlLabelCountsByCollection = ` SELECT cast(c.collectionId as char) as collectionId, - count(distinct cl.clId) as collectionLabelCount, - count(distinct clam.assetId) as labeledAssetCount, - count(distinct clam.claId) as assetLabelCount + count(distinct cl.clId) as collectionLabels, + count(distinct clam.assetId) as labeledAssets, + count(distinct clam.claId) as assetLabels FROM collection c left join collection_label cl on cl.collectionId = c.collectionId left join collection_label_asset_map clam on clam.clId = cl.clId + left join asset a on clam.assetId = a.assetId and a.state = "enabled" GROUP BY c.collectionId ` - const sqlRestrictedGrantCountsByCollection = ` - select - collectionId, - json_arrayagg(perUser) as restrictedUserGrantCounts - from - (select + const sqlAclUserCountsByCollection = ` + with ctePerUser as (select a.collectionId, - json_object('user', usam.userId, 'stigAssetCount', count(usam.saId), 'uniqueAssets', count(distinct sam.assetId)) as perUser + json_object( + 'userId', usam.userId, + 'ruleCounts', json_object( + 'rw', count(usam.saId), + 'r', 0, + 'none', 0 + ), + 'uniqueAssets', count(distinct if(a.state = 'enabled', sam.assetId, null)), + 'uniqueAssetsDisabled', count(distinct if(a.state = 'disabled', sam.assetId, null)), + 'uniqueStigs', count(distinct if(a.state = 'enabled', sam.benchmarkId, null)), + 'uniqueStigsDisabled', count(distinct if(a.state = 'disabled', sam.benchmarkId, null)), + 'role', + case when cg.accessLevel = 1 then 'restricted' else + case when cg.accessLevel = 2 then 'full' else + case when cg.accessLevel = 3 then 'manage' else + case when cg.accessLevel = 4 then 'owner' + end + end + end + end + ) as perUser from user_stig_asset_map usam left join stig_asset_map sam on sam.saId=usam.saId left join asset a on a.assetId = sam.assetId + left join collection_grant cg on usam.userId = cg.userId and a.collectionId = cg.collectionId group by - userId, collectionId) - as sub - group by - sub.collectionId -` - + usam.userId, a.collectionId) + select + collectionId, + json_arrayagg(perUser) as aclCounts + from + ctePerUser + group by + collectionId + ` const sqlGrantCounts = ` SELECT collectionId, - SUM(CASE WHEN accessLevel = 1 THEN 1 ELSE 0 END) AS accessLevel1, - SUM(CASE WHEN accessLevel = 2 THEN 1 ELSE 0 END) AS accessLevel2, - SUM(CASE WHEN accessLevel = 3 THEN 1 ELSE 0 END) AS accessLevel3, - SUM(CASE WHEN accessLevel = 4 THEN 1 ELSE 0 END) AS accessLevel4 + SUM(CASE WHEN accessLevel = 1 THEN 1 ELSE 0 END) AS restricted, + SUM(CASE WHEN accessLevel = 2 THEN 1 ELSE 0 END) AS full, + SUM(CASE WHEN accessLevel = 3 THEN 1 ELSE 0 END) AS manage, + SUM(CASE WHEN accessLevel = 4 THEN 1 ELSE 0 END) AS owner FROM collection_grant GROUP BY @@ -676,27 +687,31 @@ exports.getDetails = async function() { ORDER BY collectionId ` - const sqlUserInfo = ` select - userId, - lastAccess, - JSON_UNQUOTE(lastClaims) as lastClaims + ud.userId, + ud.username, + ud.created, + ud.lastAccess, + coalesce( + JSON_EXTRACT(ud.lastClaims, "$.${config.oauth.claims.privilegesPath}"), + json_array() + ) as privileges, + json_object( + "restricted", sum(case when cg.accessLevel = 1 then 1 else 0 end), + "full", sum(case when cg.accessLevel = 2 then 1 else 0 end), + "manage", sum(case when cg.accessLevel = 3 then 1 else 0 end), + "owner", sum(case when cg.accessLevel = 4 then 1 else 0 end) + ) as roles from - stigman.user_data - ` - const sqlOrphanedReviews = ` - SELECT - count(distinct r.ruleId) as uniqueOrphanedRules - FROM - review r - where - r.ruleId not in (select ruleId from rule_version_check_digest) + user_data ud + left join collection_grant cg using (userId) + group by + ud.userId ` - const sqlMySqlVersion = `SELECT VERSION() as version` - const mysqlVarsInMbOnly = [ + const mySqlVariablesOnly = [ 'innodb_buffer_pool_size', 'innodb_log_buffer_size', 'innodb_log_file_size', @@ -709,72 +724,55 @@ exports.getDetails = async function() { 'read_rnd_buffer_size', 'join_buffer_size', 'binlog_cache_size', - 'tmp_table_size' - ] - - const mySqlVariablesRawOnly = [ + 'tmp_table_size', 'innodb_buffer_pool_instances' , 'innodb_io_capacity' , 'innodb_io_capacity_max' , 'innodb_flush_sync' , 'innodb_io_capacity_max' , - 'innodb_lock_wait_timeout' + 'innodb_lock_wait_timeout', + 'version', + 'version_compile_machine', + 'version_compile_os', + 'long_query_time' ] - - const sqlMySqlVariablesInMb = ` - SELECT - variable_name, - ROUND(variable_value / (1024 * 1024), 2) AS value - FROM - performance_schema.global_variables - WHERE - variable_name IN ( - ${mysqlVarsInMbOnly.map( v => `'${v}'`).join(',')} - ) - ORDER by variable_name - ` - const sqlMySqlVariablesRawValues = ` + const sqlMySqlVariablesValues = ` SELECT variable_name, variable_value as value FROM performance_schema.global_variables WHERE - variable_name IN ( - ${mysqlVarsInMbOnly.map( v => `'${v}'`).join(',')}, - ${mySqlVariablesRawOnly.map( v => `'${v}'`).join(',')} - ) + variable_name IN (${mySqlVariablesOnly.map(v => `'${v}'`).join(',')}) ORDER by variable_name ` - -const mySqlStatusRawOnly = [ -'Bytes_received', -'Bytes_sent', -'Handler_commit', -'Handler_update', -'Handler_write', -'Innodb_buffer_pool_bytes_data', -'Innodb_row_lock_waits', -'Innodb_rows_read', -'Innodb_rows_updated', -'Innodb_rows_inserted', -'Innodb_row_lock_time_avg', -'Innodb_row_lock_time_max', -'Created_tmp_files', -'Created_tmp_tables', -'Max_used_connections', -'Open_tables', -'Opened_tables', -'Queries', -'Select_full_join', -'Slow_queries', -'Table_locks_immediate', -'Table_locks_waited', -'Threads_created', -'Uptime' -] - - const sqlMySqlStatusRawValues = ` + const mySqlStatusOnly = [ + 'Bytes_received', + 'Bytes_sent', + 'Handler_commit', + 'Handler_update', + 'Handler_write', + 'Innodb_buffer_pool_bytes_data', + 'Innodb_row_lock_waits', + 'Innodb_rows_read', + 'Innodb_rows_updated', + 'Innodb_rows_inserted', + 'Innodb_row_lock_time_avg', + 'Innodb_row_lock_time_max', + 'Created_tmp_files', + 'Created_tmp_tables', + 'Max_used_connections', + 'Open_tables', + 'Opened_tables', + 'Queries', + 'Select_full_join', + 'Slow_queries', + 'Table_locks_immediate', + 'Table_locks_waited', + 'Threads_created', + 'Uptime' + ] + const sqlMySqlStatusValues = ` SELECT variable_name, variable_value as value @@ -782,226 +780,205 @@ const mySqlStatusRawOnly = [ performance_schema.global_status WHERE variable_name IN ( - ${mySqlStatusRawOnly.map( v => `'${v}'`).join(',')} + ${mySqlStatusOnly.map( v => `'${v}'`).join(',')} ) ORDER by variable_name ` - await dbUtils.pool.query(sqlAnalyze) - const [schemaInfoArray] = await dbUtils.pool.query(sqlInfoSchema, [config.database.schema]) - let [assetStigByCollection] = await dbUtils.pool.query(sqlCollectionAssetStigs) - let [countsByCollection] = await dbUtils.pool.query(sqlCountsByCollection) - let [labelCountsByCollection] = await dbUtils.pool.query(sqlLabelCountsByCollection) - let [restrictedGrantCountsByCollection] = await dbUtils.pool.query(sqlRestrictedGrantCountsByCollection) - let [grantCountsByCollection] = await dbUtils.pool.query(sqlGrantCounts) - const [orphanedReviews] = await dbUtils.pool.query(sqlOrphanedReviews) - let [userInfo] = await dbUtils.pool.query(sqlUserInfo) - const [mySqlVersion] = await dbUtils.pool.query(sqlMySqlVersion) - let [mySqlVariablesInMb] = await dbUtils.pool.query(sqlMySqlVariablesInMb) - let [mySqlVariablesRaw] = await dbUtils.pool.query(sqlMySqlVariablesRawValues) - let [mySqlStatusRaw] = await dbUtils.pool.query(sqlMySqlStatusRawValues) - - // remove lastClaims, replace non-stigman roles with "other" - userInfo = cleanUserData(userInfo) - //count role assignments and break out by lastAccess time periods - let userPrivilegeCounts = breakOutRoleUsage(userInfo) - - //create working copy of operational stats - let operationalStats = _.cloneDeep(logger.overallOpStats) + const tables = createObjectFromKeyValue(schemaInfoArray, "tableName") - // Obfuscate client names in stats if configured (default == true) - if (config.settings.obfuscateClientsInOptStats == "true") { - operationalStats = obfuscateClients(operationalStats) + const rowCountQueries = [] + for (const table in tables) { + rowCountQueries.push(dbUtils.pool.query(`SELECT "${table}" as tableName, count(*) as rowCount from ${table}`)) } - operationalStats.operationIdStats = sortObjectByKeys(operationalStats.operationIdStats) + let [ + [assetStigByCollection], + [countsByCollection], + [labelCountsByCollection], + [aclUserCountsByCollection], + [grantCountsByCollection], + [userInfo], + [mySqlVersion], + [mySqlVariables], + [mySqlStatus], + rowCountResults + ] = await Promise.all([ + dbUtils.pool.query(sqlCollectionAssetStigs), + dbUtils.pool.query(sqlCountsByCollection), + dbUtils.pool.query(sqlLabelCountsByCollection), + dbUtils.pool.query(sqlAclUserCountsByCollection), + dbUtils.pool.query(sqlGrantCounts), + dbUtils.pool.query(sqlUserInfo), + dbUtils.pool.query(sqlMySqlVersion), + dbUtils.pool.query(sqlMySqlVariablesValues), + dbUtils.pool.query(sqlMySqlStatusValues), + Promise.all(rowCountQueries) + ]) + + for (const result of rowCountResults) { + tables[result[0][0].tableName].rowCount = result[0][0].rowCount + } - for (const key in mySqlVariablesInMb){ - mySqlVariablesInMb[key].value = `${mySqlVariablesInMb[key].value}M` + // remove strings from user privileges array that are not meaningful to stigman + const stigmanPrivs = ['admin', 'create_collection'] + for (const user of userInfo ) { + user.privileges = user.privileges.filter(v => stigmanPrivs.includes(v)) } + //count privilege assignments and break out by lastAccess time periods + const userPrivilegeCounts = breakOutPrivilegeUsage(userInfo) + + //create working copy of operational stats + const requests = klona(logger.requestStats) + + requests.operationIds = sortObjectByKeys(requests.operationIds) + // Create objects keyed by collectionId from arrays of objects countsByCollection = createObjectFromKeyValue(countsByCollection, "collectionId") labelCountsByCollection = createObjectFromKeyValue(labelCountsByCollection, "collectionId") assetStigByCollection = createObjectFromKeyValue(assetStigByCollection, "collectionId") - restrictedGrantCountsByCollection = createObjectFromKeyValue(restrictedGrantCountsByCollection, "collectionId") + aclUserCountsByCollection = createObjectFromKeyValue(aclUserCountsByCollection, "collectionId") grantCountsByCollection = createObjectFromKeyValue(grantCountsByCollection, "collectionId") -//Bundle "byCollection" stats together by collectionId - for(let collectionId in countsByCollection) { - // Add assetStig data to countsByCollection + // Bundle "byCollection" stats together by collectionId + for(const collectionId in countsByCollection) { if (assetStigByCollection[collectionId]) { - countsByCollection[collectionId].assetStigByCollection = assetStigByCollection[collectionId] + countsByCollection[collectionId].assetStigRanges = assetStigByCollection[collectionId] } - // Add restrictedGrant data to countsByCollection - if (restrictedGrantCountsByCollection[collectionId]) { - countsByCollection[collectionId].restrictedGrantCountsByUser = restrictedGrantCountsByCollection[collectionId].restrictedUserGrantCounts - countsByCollection[collectionId].restrictedGrantCountsByUser = createObjectFromKeyValue(countsByCollection[collectionId].restrictedGrantCountsByUser, "user") + if (aclUserCountsByCollection[collectionId]) { + countsByCollection[collectionId].aclCounts = { + users: createObjectFromKeyValue(aclUserCountsByCollection[collectionId].aclCounts, "userId"), + groups: {} + } } else { - countsByCollection[collectionId].restrictedGrantCountsByUser = 0 + countsByCollection[collectionId].aclCounts = { + users: {}, + groups: {} + } } - // Add grant data to countsByCollection if (grantCountsByCollection[collectionId]) { countsByCollection[collectionId].grantCounts = grantCountsByCollection[collectionId] } else { - countsByCollection[collectionId].grantCounts = 0 + countsByCollection[collectionId].grantCounts = { + restricted: 0, + full: 0, + manage: 0, + owner: 0 + } } - // Add labelCounts data to countsByCollection if (labelCountsByCollection[collectionId]) { countsByCollection[collectionId].labelCounts = labelCountsByCollection[collectionId] } } - - return ({ - dateGenerated: new Date().toISOString(), - stigmanVersion: config.version, - stigmanCommit: config.commit, - dbInfo: { - tables: createObjectFromKeyValue(schemaInfoArray, "tableName"), + const returnObj = { + date: new Date().toISOString(), + schema, + version: config.version, + collections: countsByCollection, + requests, + users: { + userInfo: createObjectFromKeyValue(userInfo, "userId", null), + userPrivilegeCounts }, - countsByCollection, - uniqueRuleCountOfOrphanedReviews: orphanedReviews[0].uniqueOrphanedRules, - userInfo: createObjectFromKeyValue(userInfo, "userId"), - userPrivilegeCounts, - operationalStats, - nodeUptime: getNodeUptime(), - nodeMemoryUsageInMb: getNodeMemoryUsage(), - mySqlVersion: mySqlVersion[0].version, - mySqlVariablesInMb: createObjectFromKeyValue(mySqlVariablesInMb, "variable_name", "value"), - mySqlVariablesRaw: createObjectFromKeyValue(mySqlVariablesRaw, "variable_name", "value"), - mySqlStatusRaw: createObjectFromKeyValue(mySqlStatusRaw, "variable_name", "value") - }) -} - -// Reduce an array of objects to a single object, using the value of one property as keys -// and either assigning the rest of the object or the value of a second property as the value. -function createObjectFromKeyValue(data, keyPropertyName, valuePropertyName = null) { - return data.reduce((acc, item) => { - const { [keyPropertyName]: key, ...rest } = item - acc[key] = valuePropertyName ? item[valuePropertyName] : rest - return acc - }, {}) -} - -function obfuscateClients(operationalStats) { - const obfuscationMap = {} - let obfuscatedCounter = 1 + mysql: { + version: mySqlVersion[0].version, + tables, + variables: createObjectFromKeyValue(mySqlVariables, "variable_name", "value"), + status: createObjectFromKeyValue(mySqlStatus, "variable_name", "value") + }, + nodejs: getNodeValues() + } + return returnObj + + // Reduce an array of objects to a single object, using the value of one property as keys + // and either assigning the rest of the object or the value of a second property as the value. + function createObjectFromKeyValue(data, keyPropertyName, valuePropertyName = null, includeKey = false) { + return data.reduce((acc, item) => { + const { [keyPropertyName]: key, ...rest } = item + acc[key] = valuePropertyName ? item[valuePropertyName] : includeKey ? item : rest + return acc + }, {}) + } - function getObfuscatedKey(client) { - if (client === "unknown") { - return client - } - if (!obfuscationMap[client]) { - obfuscationMap[client] = `client${obfuscatedCounter++}` + function sortObjectByKeys(obj) { + // Create a new object and add properties in sorted order + const sortedObj = {} + for (const key of Object.keys(obj).sort()) { + sortedObj[key] = obj[key] } - return obfuscationMap[client] + return sortedObj } - const operationIdStats = operationalStats.operationIdStats - - for (const operationId in operationIdStats) { - if (operationIdStats[operationId].clients) { - const clients = operationIdStats[operationId].clients - const newClients = {} - - for (const clientName in clients) { - const obfuscatedName = getObfuscatedKey(clientName) - newClients[obfuscatedName] = clients[clientName] + function breakOutPrivilegeUsage(userInfo) { + let privilegeCounts = { + overall: {none:0}, + activeInLast30Days: {none:0}, + activeInLast90Days: {none:0} + } + + // Calculate the timestamps for 30 and 90 days ago + const currentTime = Math.floor(Date.now() / 1000) + const thirtyDaysAgo = currentTime - (30 * 24 * 60 * 60) + const ninetyDaysAgo = currentTime - (90 * 24 * 60 * 60) + const updateCounts = (categoryCounts, userPrivs) => { + if (userPrivs.length === 0) { + categoryCounts.none++ + } + for (const privilege of userPrivs) { + categoryCounts[privilege] = categoryCounts[privilege] ? categoryCounts[privilege] + 1 : 1 } - - operationIdStats[operationId].clients = newClients } - } - - return operationalStats -} - -function sortObjectByKeys(obj) { - // Extract property names and sort them - const sortedKeys = Object.keys(obj).sort() - // Create a new object and add properties in sorted order - const sortedObj = {} - for (const key of sortedKeys) { - sortedObj[key] = obj[key] - } - return sortedObj -} -function breakOutRoleUsage(userInfo) { - let roleCounts = { - overall: {}, - activeInLast30Days: {}, - activeInLast90Days: {} - } - - // Calculate the timestamps for 30 and 90 days ago - const currentTime = Math.floor(Date.now() / 1000) - const thirtyDaysAgo = currentTime - (30 * 24 * 60 * 60) - const ninetyDaysAgo = currentTime - (90 * 24 * 60 * 60) - - userInfo.forEach(user => { - // Function to update counts - const updateCounts = (roleCounts, roles) => { - roles.forEach(role => { - if (roleCounts[role]) { - roleCounts[role]++ - } else { - roleCounts[role] = 1 - } - }) - } - // Update overall counts - updateCounts(roleCounts.overall, user.roles) + for (const user of userInfo) { + updateCounts(privilegeCounts.overall, user.privileges) // Update counts for the last 30 and 90 days based on lastAccess if (user.lastAccess >= ninetyDaysAgo) { - updateCounts(roleCounts.activeInLast90Days, user.roles) + updateCounts(privilegeCounts.activeInLast90Days, user.privileges) } if (user.lastAccess >= thirtyDaysAgo) { - updateCounts(roleCounts.activeInLast30Days, user.roles) + updateCounts(privilegeCounts.activeInLast30Days, user.privileges) } } - ) - return roleCounts -} - -// Replace non-stigman roles with "other" -function replaceRoles(roles) { - return roles.map(role => (role !== 'admin' && role !== 'create_collection') ? 'other' : role) -} + return privilegeCounts + } -// Clean up user info -function cleanUserData(userInfo) { - return userInfo.map(user => { - if (user.lastClaims) { - user.roles = replaceRoles(privilegeGetter(JSON.parse(user.lastClaims))) - delete user.lastClaims + function getNodeValues() { + const {environmentVariables, header, resourceUsage} = process.report.getReport() + + const environment = {} + for (const [key, value] of Object.entries(environmentVariables)) { + if (/^(NODE|STIGMAN)_/.test(key)) { + environment[key] = key === 'STIGMAN_DB_PASSWORD' ? '***' : value + } + } + const {platform, arch, nodejsVersion, cpus, osMachine, osName, osRelease} = header + for (let x = 0; x < cpus.length; x++) { + cpus[x] = {model: cpus[x].model, speed: cpus[x].speed} + } + const loadAverage = os.loadavg().join(', ') + + const memory = process.memoryUsage() + memory.maxRss = resourceUsage.maxRss + return { + version: nodejsVersion.substring(1), + uptime: process.uptime(), + os: { + platform, + arch, + osMachine, + osName, + osRelease, + loadAverage + }, + environment, + memory, + cpus } - return user - }) -} - -function getNodeUptime() { - let uptime = process.uptime() - let days = Math.floor(uptime / 86400) - uptime %= 86400 - let hours = Math.floor(uptime / 3600) - uptime %= 3600 - let minutes = Math.floor(uptime / 60) - let seconds = Math.floor(uptime % 60) - return `${days} days, ${hours} hours, ${minutes} minutes, ${seconds} seconds` -} - -function getNodeMemoryUsage() { - const memoryData = process.memoryUsage() - const formatMemoryUsage = (data) => `${Math.round(data / 1024 / 1024 * 100) / 100}` - return { - rss: `${formatMemoryUsage(memoryData.rss)}`, //Resident Set Size - total memory allocated for the process execution - heapTotal: `${formatMemoryUsage(memoryData.heapTotal)}`, // total size of the allocated heap - heapUsed: `${formatMemoryUsage(memoryData.heapUsed)}`, // actual memory used during the execution - external: `${formatMemoryUsage(memoryData.external)}` // V8 external memory } } + diff --git a/api/source/specification/stig-manager.yaml b/api/source/specification/stig-manager.yaml index 70ef12798..54fa727f0 100644 --- a/api/source/specification/stig-manager.yaml +++ b/api/source/specification/stig-manager.yaml @@ -3031,6 +3031,24 @@ paths: security: - oauth: - stig-manager:op + /op/appinfo: + get: + tags: + - Operation + summary: Return information about the application deployment + operationId: getAppInfo + parameters: + - $ref: '#/components/parameters/ElevateQuery' + responses: + '200': + description: AppInfo response + content: + application/json: + schema: + $ref: '#/components/schemas/AppInfo' + security: + - oauth: + - stig-manager:op:read /op/configuration: get: tags: @@ -3065,7 +3083,7 @@ paths: get: tags: - Operation - summary: Return deployment details + summary: "DEPRECATED: replaced by /op/appinfo" operationId: getDetails parameters: - $ref: '#/components/parameters/ElevateQuery' @@ -3823,6 +3841,257 @@ components: - type: array ApiVersion: $ref: '#/components/schemas/Version' + AppInfo: + type: object + properties: + date: + type: string + format: date-time + schema: + $ref: '#/components/schemas/String255' + version: + $ref: '#/components/schemas/ApiVersion' + collections: + additionalProperties: + $ref: '#/components/schemas/AppInfoCollection' + requests: + $ref: '#/components/schemas/AppInfoRequests' + users: + $ref: '#/components/schemas/AppInfoUsers' + mysql: + $ref: '#/components/schemas/AppInfoMySql' + nodejs: + $ref: '#/components/schemas/AppInfoNodejs' + required: + - date + - schema + - version + - collections + - requests + - users + - mysql + - nodejs + AppInfoNodejs: + type: object + properties: + version: + $ref: '#/components/schemas/ApiVersion' + uptime: + type: number + os: + type: object + properties: + platform: + $ref: '#/components/schemas/String255' + arch: + $ref: '#/components/schemas/String255' + osMachine: + $ref: '#/components/schemas/String255' + osName: + $ref: '#/components/schemas/String255' + osRelease: + $ref: '#/components/schemas/String255' + loadAverage: + $ref: '#/components/schemas/String255' + environment: + additionalProperties: + type: string + memory: + type: object + properties: + rss: + type: number + heapTotal: + type: number + heapUsed: + type: number + external: + type: number + arrayBuffers: + type: number + maxRss: + type: number + cpus: + type: array + items: + type: object + properties: + model: + $ref: '#/components/schemas/String255' + speed: + type: number + AppInfoMySql: + type: object + properties: + version: + $ref: '#/components/schemas/ApiVersion' + tables: + type: object + variables: + type: object + status: + type: object + AppInfoUsers: + type: object + properties: + userInfo: + additionalProperties: + $ref: '#/components/schemas/AppInfoUserInfo' + userPrivilegeCounts: + type: object + properties: + overall: + additionalProperties: + type: number + activeInLast30Days: + additionalProperties: + type: number + activeInLast90Days: + additionalProperties: + type: number + AppInfoUserInfo: + type: object + properties: + username: + $ref: '#/components/schemas/Username' + created: + type: string + format: date-time + lastAccess: + type: number + nullable: true + privileges: + type: array + items: + $ref: '#/components/schemas/String255' + AppInfoRequests: + type: object + properties: + totalRequests: + type: number + totalApiRequests: + type: number + totalRequestDuration: + type: number + operationIds: + additionalProperties: + $ref: '#/components/schemas/AppInfoOperation' + AppInfoOperation: + type: object + properties: + totalRequests: + type: number + totalDuration: + type: number + elevatedRequests: + type: number + minDuration: + type: number + maxDuration: + type: number + maxDurationUpdates: + type: number + retried: + type: number + averageRetries: + type: number + totalResLength: + type: number + minResLength: + type: number + maxResLength: + type: number + totalReqLength: + type: number + minReqLength: + type: number + maxReqLength: + type: number + projections: + type: object + clients: + type: object + additionalProperties: + type: number + users: + type: object + additionalProperties: + type: number + AppInfoCollection: + type: object + properties: + name: + $ref: '#/components/schemas/CollectionName' + state: + type: string + enum: + - enabled + - disabled + assetsTotal: + type: number + assetsDisabled: + type: number + uniqueStigs: + type: number + stigAssignments: + type: number + ruleCnt: + type: number + reviewCntTotal: + type: number + reviewCntDisabled: + type: number + assetStigRanges: + $ref: '#/components/schemas/AppInfoAssetStigRanges' + restrictedUsers: + type: object + additionalProperties: + $ref: '#/components/schemas/AppInfoRestrictedUsers' + grantCounts: + $ref: '#/components/schemas/AppInfoGrantCounts' + labelCounts: + $ref: '#/components/schemas/AppInfoLabelCounts' + AppInfoLabelCounts: + type: object + properties: + collectionLabels: + type: number + labeledAssets: + type: number + assetLabels: + type: number + AppInfoGrantCounts: + type: object + properties: + accessLevel1: + type: number + accessLevel2: + type: number + accessLevel3: + type: number + accessLevel4: + type: number + AppInfoRestrictedUsers: + type: object + properties: + uniqueAssets: + type: number + stigAsstCount: + type: number + AppInfoAssetStigRanges: + type: object + properties: + range00: + type: number + range01to05: + type: number + range06to10: + type: number + range11to15: + type: number + range16plus: + type: number + Asset: additionalProperties: false type: object diff --git a/api/source/utils/config.js b/api/source/utils/config.js index 42793d3fb..a797f713a 100644 --- a/api/source/utils/config.js +++ b/api/source/utils/config.js @@ -1,6 +1,6 @@ const package = require("../package.json") -let config = { +const config = { version: package.version, commit: { branch: process.env.COMMIT_BRANCH || 'na', @@ -14,9 +14,7 @@ let config = { // Supported STIGMAN_DEV_RESPONSE_VALIDATION values: // "logOnly" (logs failing response, but still sends them) // "none"(no validation performed) - responseValidation: process.env.STIGMAN_DEV_RESPONSE_VALIDATION || "none", - obfuscateClientsInOptStats: process.env.STIGMAN_DEV_OPT_STATS_OBFUSCATE_CLIENTS || "true", - + responseValidation: process.env.STIGMAN_DEV_RESPONSE_VALIDATION || "none" }, client: { clientId: process.env.STIGMAN_CLIENT_ID || "stig-manager", @@ -79,14 +77,17 @@ let config = { servicename: process.env.STIGMAN_JWT_SERVICENAME_CLAIM, name: process.env.STIGMAN_JWT_NAME_CLAIM || process.env.STIGMAN_JWT_USERNAME_CLAIM || "name", privileges: formatChain(process.env.STIGMAN_JWT_PRIVILEGES_CLAIM || "realm_access.roles"), + privilegesPath: process.env.STIGMAN_JWT_PRIVILEGES_CLAIM || "realm_access.roles", email: process.env.STIGMAN_JWT_EMAIL_CLAIM || "email" } }, log: { level: parseInt(process.env.STIGMAN_LOG_LEVEL) || 3, mode: process.env.STIGMAN_LOG_MODE || 'combined', - // if STIGMAN_DEV_OPT_STATS_IN_LOGS = true, add performance stats to logs: - optStats: process.env.STIGMAN_DEV_LOG_OPT_STATS || "false" + optStats: process.env.STIGMAN_DEV_LOG_OPT_STATS === "true" + }, + experimental: { + appData: process.env.STIGMAN_EXPERIMENTAL_APPDATA === "true" } } diff --git a/api/source/utils/klona.js b/api/source/utils/klona.js new file mode 100644 index 000000000..3dbf7a826 --- /dev/null +++ b/api/source/utils/klona.js @@ -0,0 +1,32 @@ +module.exports = function klona(val) { + // MIT License + // Copyright (c) Luke Edwards (lukeed.com) + // https://github.com/lukeed/klona + + let k, out, tmp + + if (Array.isArray(val)) { + out = Array(k = val.length) + while (k--) out[k] = (tmp = val[k]) && typeof tmp === 'object' ? klona(tmp) : tmp + return out + } + + if (Object.prototype.toString.call(val) === '[object Object]') { + out = {} // null + for (k in val) { + if (k === '__proto__') { + Object.defineProperty(out, k, { + value: klona(val[k]), + configurable: true, + enumerable: true, + writable: true, + }) + } else { + out[k] = (tmp = val[k]) && typeof tmp === 'object' ? klona(tmp) : tmp + } + } + return out + } + + return val +} \ No newline at end of file diff --git a/api/source/utils/logger.js b/api/source/utils/logger.js index fafdc399e..ae97e4205 100644 --- a/api/source/utils/logger.js +++ b/api/source/utils/logger.js @@ -32,11 +32,11 @@ const writeError = config.log.level >= 1 ? function writeError () { // Stats for all requests -const overallOpStats = { +const requestStats = { totalRequests: 0, totalApiRequests: 0, totalRequestDuration: 0, - operationIdStats: {} + operationIds: {} } // All messages to STDOUT are handled here @@ -102,16 +102,20 @@ function requestLogger (req, res, next) { res._startTime = undefined res.svcStatus = {} - // Response body handling for privileged requests - let responseBody = undefined - if (req.query.elevate === true || req.query.elevate === 'true' ) { - responseBody = '' - const originalSend = res.send - res.send = function (chunk) { - responseBody += chunk - originalSend.apply(res, arguments) - res.end() + // Response body length for appinfo and content for privileged requests + let responseBody + res.sm_responseLength = 0 + responseBody = '' + const originalSend = res.send + res.send = function (chunk) { + if (chunk !== undefined) { + if (req.query.elevate === true || req.query.elevate === 'true' ) { + responseBody += chunk + } + res.sm_responseLength += chunk.length || 0 } + originalSend.apply(res, arguments) + res.end() } // record request start @@ -124,12 +128,12 @@ function requestLogger (req, res, next) { function logResponse () { res._startTime = res._startTime ?? new Date() - overallOpStats.totalRequests += 1 + requestStats.totalRequests += 1 const durationMs = Number(res._startTime - req._startTime) - overallOpStats.totalRequestDuration += durationMs + requestStats.totalRequestDuration += durationMs const operationId = res.req.openapi?.schema.operationId - let operationalStats = { + let operationStats = { operationId, retries: res.svcStatus?.retries, durationMs @@ -138,17 +142,16 @@ function requestLogger (req, res, next) { //if operationId is defined, this is an api endpoint response so we can track some stats if (operationId ) { trackOperationStats(operationId, durationMs, res) - // If including stats in log entries, add to operationalStats object - if (config.log.optStats === 'true') { - operationalStats = { - ...operationalStats, - ...overallOpStats.operationIdStats[operationId] + // If including stats in log entries, add to operationStats object + if (config.log.optStats) { + operationStats = { + ...operationStats, + ...requestStats.operationIds[operationId] } } } if (config.log.mode === 'combined') { - writeInfo(req.component || 'rest', 'transaction', { request: serializeRequest(res.req), response: { @@ -159,7 +162,7 @@ function requestLogger (req, res, next) { errorBody: res.errorBody, responseBody, }, - operationalStats + operationStats }) } else { @@ -168,7 +171,7 @@ function requestLogger (req, res, next) { status: res.statusCode, headers: res.getHeaders(), errorBody: res.errorBody, - operationalStats + operationStats }) } } @@ -181,7 +184,6 @@ function requestLogger (req, res, next) { next() } - function serializeEnvironment () { let env = {} for (const [key, value] of Object.entries(process.env)) { @@ -193,57 +195,99 @@ function serializeEnvironment () { } function trackOperationStats(operationId, durationMs, res) { + + const acceptsRequestBody = (res.req.method === 'POST' || res.req.method === 'PUT' || res.req.method === 'PATCH') + //increment total api requests - overallOpStats.totalApiRequests++ - // Ensure the operationIdStats object exists for the operationId - if (!overallOpStats.operationIdStats[operationId]) { - overallOpStats.operationIdStats[operationId] = { + requestStats.totalApiRequests++ + // Ensure the operationIds object exists for the operationId + if (!requestStats.operationIds[operationId]) { + requestStats.operationIds[operationId] = { totalRequests: 0, totalDuration: 0, elevatedRequests: 0, minDuration: Infinity, maxDuration: 0, maxDurationUpdates: 0, - get averageDuration() { - return this.totalRequests ? Math.round(this.totalDuration / this.totalRequests) : 0; - }, + retried: 0, + averageRetries: 0, + totalResLength: 0, + minResLength: Infinity, + maxResLength: 0, clients: {}, users: {}, - }; + errors: {} + } + if (acceptsRequestBody) { + requestStats.operationIds[operationId].totalReqLength = 0 + requestStats.operationIds[operationId].minReqLength = Infinity + requestStats.operationIds[operationId].maxReqLength = 0 + } } - // Get the stats object for this operationId - const stats = overallOpStats.operationIdStats[operationId]; - // Increment total requests and total duration for this operationId - stats.totalRequests++; - stats.totalDuration += durationMs; + const stats = requestStats.operationIds[operationId] + + // errors + if (res.statusCode >= 500) { + const code = res.errorBody?.code || 'nocode' + stats.errors[code] = (stats.errors[code] || 0) + 1 + } - // Update min and max duration - stats.minDuration = Math.min(stats.minDuration, durationMs); + // Update max duration + stats.minDuration = Math.min(stats.minDuration, durationMs) if (durationMs > stats.maxDuration) { - stats.maxDuration = durationMs; - stats.maxDurationUpdates++; + stats.maxDuration = durationMs + stats.maxDurationUpdates++ } + // Increment total requests and total duration for this operationId + stats.totalRequests++ + stats.totalDuration += durationMs + + stats.totalResLength += res.sm_responseLength + // Update max response length + stats.minResLength = Math.min(stats.minResLength, res.sm_responseLength) + if (res.sm_responseLength > stats.maxResLength) { + stats.maxResLength = res.sm_responseLength + } + + if (acceptsRequestBody) { + const requestLength = parseInt(res.req.headers['content-length'] ?? '0') + stats.totalReqLength += requestLength + stats.minReqLength = Math.min(stats.minReqLength, requestLength) + if (requestLength > stats.maxReqLength) { + stats.maxReqLength = requestLength + } + } + + // Update retries + if (res.svcStatus?.retries) { + stats.retried++ + stats.averageRetries = runningAverage({ + currentAvg: stats.averageRetries, + counter: stats.retried, + newValue: res.svcStatus.retries + }) + } // Check token for userid - let userId = res.req.userObject?.userId || 'unknown'; + let userId = res.req.userObject?.userId || 'unknown' // Increment user count for this operationId - stats.users[userId] = (stats.users[userId] || 0) + 1; + stats.users[userId] = (stats.users[userId] || 0) + 1 // Check token for client id - let client = res.req.access_token?.azp || 'unknown'; + let client = res.req.access_token?.azp || 'unknown' // Increment client count for this operationId - stats.clients[client] = (stats.clients[client] || 0) + 1; + stats.clients[client] = (stats.clients[client] || 0) + 1 // Increment elevated request count if elevate query param is true if (res.req.query?.elevate === true) { - stats.elevatedRequests = (stats.elevatedRequests || 0) + 1; + stats.elevatedRequests = (stats.elevatedRequests || 0) + 1 } // If projections are defined, track stats for each projection if (res.req.query?.projection?.length > 0) { - stats.projections = stats.projections || {}; + stats.projections = stats.projections || {} for (const projection of res.req.query.projection) { // Ensure the projection stats object exists stats.projections[projection] = stats.projections[projection] || { @@ -251,20 +295,31 @@ function trackOperationStats(operationId, durationMs, res) { minDuration: Infinity, maxDuration: 0, totalDuration: 0, + retried: 0, + averageRetries: 0, get averageDuration() { - return this.totalRequests ? Math.round(this.totalDuration / this.totalRequests) : 0; + return this.totalRequests ? Math.round(this.totalDuration / this.totalRequests) : 0 } - }; + } - const projStats = stats.projections[projection]; + const projStats = stats.projections[projection] // Increment projection count and update duration stats - projStats.totalRequests++; - projStats.minDuration = Math.min(projStats.minDuration, durationMs); - projStats.maxDuration = Math.max(projStats.maxDuration, durationMs); - projStats.totalDuration += durationMs; + projStats.totalRequests++ + projStats.minDuration = Math.min(projStats.minDuration, durationMs) + projStats.maxDuration = Math.max(projStats.maxDuration, durationMs) + projStats.totalDuration += durationMs + + // Update retries + if (res.svcStatus?.retries) { + projStats.retried++ + projStats.averageRetries = projStats.averageRetries + (res.svcStatus.retries - projStats.averageRetries) / projStats.retried + } } } + function runningAverage({currentAvg, counter, newValue}) { + return currentAvg + (newValue - currentAvg) / counter + } } module.exports = { @@ -276,6 +331,5 @@ module.exports = { writeWarn, writeInfo, writeDebug, - overallOpStats - + requestStats } diff --git a/client/build.sh b/client/build.sh index bea384cbf..03d8a71b5 100755 --- a/client/build.sh +++ b/client/build.sh @@ -66,6 +66,7 @@ ext/resources/images/gray/tabs/tab-close.gif ext/resources/images/gray/tabs/scroll-left.gif ext/resources/images/gray/tabs/scroll-right.gif ext/resources/images/gray/window/icon-question.gif +ext/ux/css/LockingGridView.css ext/ux/fileuploadfield/css/fileuploadfield.css" tar cf - -C $SrcDir --files-from <(echo "${ExtResources}") | tar xf - -C $DistDir @@ -138,6 +139,7 @@ uglifyjs \ 'SM/CollectionGrant.js' \ 'SM/CollectionPanel.js' \ 'SM/MetaPanel.js' \ +'LockingGridView.js' \ 'SM/ColumnFilters.js' \ 'SM/FindingsPanel.js' \ 'SM/Assignments.js' \ @@ -150,12 +152,13 @@ uglifyjs \ 'SM/StigRevision.js' \ 'SM/Inventory.js' \ 'SM/AssetSelection.js' \ +'SM/AppInfo.js' \ +'SM/AppData.js' \ 'library.js' \ 'userAdmin.js' \ 'collectionAdmin.js' \ 'collectionManager.js' \ 'stigAdmin.js' \ -'appDataAdmin.js' \ 'completionStatus.js' \ 'findingsSummary.js' \ 'review.js' \ diff --git a/client/src/css/dark-mode.css b/client/src/css/dark-mode.css index 274f4dda5..6787387b4 100644 --- a/client/src/css/dark-mode.css +++ b/client/src/css/dark-mode.css @@ -110,9 +110,16 @@ input:-webkit-autofill, select:-webkit-autofill, textarea:-webkit-autofill { background-color: hsl(0 0% 12% / 1) } -.sm-round-inner-panel .x-panel-body { +.sm-round-panel .sm-round-inner-panel .x-panel-body { background-color: #1a1d1e } +.sm-round-panel .x-panel-body, .sm-round-panel.x-window { + background-color: #1a1d1e; +} +.sm-round-panel.x-window-dlg { + background-color: #1a1d1e; +} + .x-grid3-row-expanded .x-grid3-row-body { background-color: hsl(37deg 10% 23%) @@ -669,7 +676,16 @@ td.x-date-mp-month a, td.x-date-mp-month a:hover, td.x-date-mp-year a, td.x-date } .x-layout-split { - background-color: transparent + background-color: transparent; + transition-property: background-color; + transition-duration: 100ms; + transition-delay: 0s; +} + +.x-layout-split:hover { + background-color: #243385; + transition-duration: 100ms; + transition-delay: 0s; } .ext-strict .ext-ie6 .x-layout-split { @@ -1176,14 +1192,6 @@ td.sort-asc .x-grid3-hd-inner, td.sort-desc .x-grid3-hd-inner, td.x-grid3-hd-men background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMiIgaGVpZ2h0PSIyMiI+PGRlZnM+PGZpbHRlciBpZD0iZGFya3JlYWRlci1pbWFnZS1maWx0ZXIiPjxmZUNvbG9yTWF0cml4IHR5cGU9Im1hdHJpeCIgdmFsdWVzPSIwLjMzMyAtMC42NjcgLTAuNjY3IDAuMDAwIDEuMDAwIC0wLjY2NyAwLjMzMyAtMC42NjcgMC4wMDAgMS4wMDAgLTAuNjY3IC0wLjY2NyAwLjMzMyAwLjAwMCAxLjAwMCAwLjAwMCAwLjAwMCAwLjAwMCAxLjAwMCAwLjAwMCIgLz48L2ZpbHRlcj48L2RlZnM+PGltYWdlIHdpZHRoPSIyIiBoZWlnaHQ9IjIyIiBmaWx0ZXI9InVybCgjZGFya3JlYWRlci1pbWFnZS1maWx0ZXIpIiB4bGluazpocmVmPSJkYXRhOmltYWdlL2dpZjtiYXNlNjQsUjBsR09EbGhBZ0FXQUxNQUFOcmEydWJtNXVYbDVlTGk0dDNkM2R2YjI5N2UzdC9mMzd5OHZPSGg0ZVRrNU9QajQrRGc0TnpjM0FBQUFBQUFBQ0g1QkFBQUFBQUFMQUFBQUFBQ0FCWUFBQVFZTU1ncHFsQnFyY0ZIU2d4empFWkpuRVJUckVEcklrZ0VBRHM9IiAvPjwvc3ZnPg==") } -.sort-asc .x-grid3-sort-icon { - background-image: url("../ext/resources/images/gray/grid/sort_asc.gif") -} - -.sort-desc .x-grid3-sort-icon { - background-image: url("../ext/resources/images/gray/grid/sort_desc.gif") -} - .x-grid3-cell-text, .x-grid3-hd-text { color: #e8e6e3 } @@ -1259,14 +1267,6 @@ td.sort-asc .x-grid3-hd-inner, td.sort-desc .x-grid3-hd-inner, td.x-grid3-hd-men background-color: #181a1b!important } -.xg-hmenu-sort-asc .x-menu-item-icon { - background-image: url("../ext/resources/images/default/grid/hmenu-asc.gif") -} - -.xg-hmenu-sort-desc .x-menu-item-icon { - background-image: url("../ext/resources/images/default/grid/hmenu-desc.gif") -} - .xg-hmenu-lock .x-menu-item-icon { background-image: url("../ext/resources/images/default/grid/hmenu-lock.gif") } @@ -1315,10 +1315,6 @@ td.sort-asc .x-grid3-hd-inner, td.sort-desc .x-grid3-hd-inner, td.x-grid3-hd-men background-image: url("../ext/resources/images/default/grid/group-by.gif") } -.x-cols-icon { - background-image: url("../ext/resources/images/default/grid/columns.gif") -} - .x-show-groups-icon { background-image: url("../ext/resources/images/default/grid/group-by.gif") } @@ -2334,13 +2330,13 @@ td.sort-asc, td.sort-desc, td.x-grid3-hd-menu-open, td.x-grid3-hd-over { } .x-border-layout-ct { - background-color: hsl(211deg 25% 7%) -} - -.sm-round-panel { - background-color: #181a1b + background-color: hsl(0 0% 8% / 1); } +/* .sm-round-panel { + background-color: #276d90; +} + */ .sm-round-panel .x-panel-header { background-color: #35393b; background-image: none @@ -2350,7 +2346,7 @@ td.sort-asc, td.sort-desc, td.x-grid3-hd-menu-open, td.x-grid3-hd-over { background-color: hsl(200 5% 17% / 1) } -.sm-home-widget-body { +.sm-round-panel .sm-home-widget-body { background-color: #2b2e30 } @@ -3172,4 +3168,15 @@ embed[type="application/pdf"] { .sm-grabbing *, .sm-grabbing .sm-grid3-draggable .x-grid3-row-selected *, .sm-grabbing .sm-grid3-draggable .x-grid3-row-selected { cursor: url("../img/drag-drop-dark.svg"), grabbing; } +.sm-appinfo-message { + background-color: #2d2d2d; + color: #999999; +} +.sm-render-zero { + color: #444444 +} +.sm-whats-new img { + border: 1px solid hsl(0 0% 20% / 1) +} + \ No newline at end of file diff --git a/client/src/css/jsonview.bundle.css b/client/src/css/jsonview.bundle.css index 7d7c2e312..0a3988da1 100644 --- a/client/src/css/jsonview.bundle.css +++ b/client/src/css/jsonview.bundle.css @@ -1,7 +1,7 @@ .json-container { font-family: 'Open Sans'; font-size: 11px; - background-color: #fff; + background-color: transparent; color: #808080; box-sizing: border-box; } diff --git a/client/src/css/stigman.css b/client/src/css/stigman.css index 5851884b8..e2318e46a 100644 --- a/client/src/css/stigman.css +++ b/client/src/css/stigman.css @@ -186,9 +186,9 @@ .sm-round-inner-panel { border-radius: 6px; overflow: hidden; - margin: 12px + margin: 12px; } -.sm-round-inner-panel .x-panel-body { +.sm-round-panel .sm-round-inner-panel .x-panel-body { background-color: #fff; border-bottom-left-radius: 6px; border-bottom-right-radius: 6px @@ -1041,13 +1041,18 @@ td.x-grid3-hd-over .x-grid3-hd-inner { background-color: #fcfcfc } .x-border-layout-ct { - background-color: #586574 + background-color: hsl(220 8% 50% / 1); } .sm-round-panel { border-radius: 6px; - background-color: #fff; overflow: hidden } +.sm-round-panel.x-window-dlg { + background-color: hsl(0 0% 95% / 1); +} +.sm-round-panel .x-panel-body, .sm-round-panel.x-window { + background-color: hsl(0 0% 95% / 1); +} .sm-round-panel .x-panel-header { background-color: #ccc; background-image: none; @@ -1060,7 +1065,7 @@ td.x-grid3-hd-over .x-grid3-hd-inner { border-radius: 6px 6px 0 0; padding: 5px 8px 4px 8px; } -.sm-home-widget-body { +.sm-round-panel .sm-home-widget-body { background-color: #dedede; border-radius: 25px; border: none; @@ -1336,6 +1341,7 @@ td.x-grid3-hd-over .x-grid3-hd-inner { } .sm-copy-icon { background-image: url(../img/copy.svg)!important; + background-repeat: no-repeat; background-size: 16px 16px } .sm-clone-icon { @@ -2101,6 +2107,18 @@ td.x-grid3-hd-over .x-grid3-hd-inner { background-repeat: no-repeat; background-size: 14px 14px; } +.x-tool.x-tool-expand-grid { + background-image: url(../img/expand.svg); + background-repeat: no-repeat; + background-size: 14px 14px; + padding-right: 3px; +} +.x-tool.x-tool-collapse-grids { + background-image: url(../img/collapse.svg); + background-repeat: no-repeat; + background-size: 14px 14px; + padding-right: 3px; +} .x-tool.x-tool-toggle.x-tool-collapse-west { background-image: url(../img/collapse-left.svg); background-repeat: no-repeat; @@ -2113,6 +2131,18 @@ td.x-grid3-hd-over .x-grid3-hd-inner { background-size: 10px 10px; background-position: 4px 4px; } +.x-tool.x-tool-toggle.x-tool-collapse-east { + background-image: url(../img/collapse-right.svg); + background-repeat: no-repeat; + background-size: 10px 10px; + background-position: 2px 3px; +} +.x-tool.x-tool-expand-east { + background-image: url(../img/collapse-left.svg); + background-repeat: no-repeat; + background-size: 10px 10px; + background-position: 4px 4px; +} .x-tool:hover { filter: brightness(50%) } @@ -2273,4 +2303,102 @@ td.x-grid3-hd-over .x-grid3-hd-inner { .sm-match-word-icon { background-image: url(../img/match-word.svg); background-size: contain; -} \ No newline at end of file +} +.sm-browser-icon { + background-image: url(../img/browser.svg); + background-size: 16px; +} +.sm-nodejs-icon { + background-image: url(../img/jsIconGreen.svg); + background-size: 16px; +} +.sm-mysql-icon { + background-image: url(../img/mysql.svg); + background-size: 16px; +} +.sm-json-icon { + background-image: url(../img/json.svg); + background-size: 16px; +} + +.x-layout-split { + background-color: transparent; + transition-property: background-color; + transition-duration: 0.25s; + transition-delay: 0s; +} + +.x-layout-split:hover { + background-color: hsl(0 0% 66% / 1); + transition-duration: 0.25s; + transition-delay: 0s; +} +.sm-row-disabled { + color: #a15e5e +} +.sm-share-icon { + background-image: url(../img/share.svg); + background-size: 16px; +} +.xg-hmenu-sort-asc .x-menu-item-icon { + background-image: url("../img/up.svg"); + background-size: 16px 16px; +} + +.xg-hmenu-sort-desc .x-menu-item-icon { + background-image: url("../img/down.svg"); + background-size: 16px 16px; +} +.x-cols-icon { + background-image: url("../img/columns.svg"); + background-size: 16px 16px; +} +.sort-asc .x-grid3-sort-icon { + background-image: url("../img/up.svg"); + background-size: 10px; + background-position: center +} + +.sort-desc .x-grid3-sort-icon { + background-image: url("../img/down.svg"); + background-size: 10px; + background-position: center +} +.sm-circle-icon { + background-image: url("../img/circle.svg"); + background-size: 7px 7px; + background-position: top 5px left 5px; +} +.x-menu { + background-image: none +} +.sm-appinfo-message { + background-color: #e0e0e0; + width: 330px; + height: 60px; + color: #484848; + padding: 7px; +} + +.sm-info-circle-icon { + background-image: url(../img/info-circle.svg)!important; + background-size: 16px 16px; +} +.sm-render-zero { + color: #cccccc +} +.sm-appinfo-message a:link { + text-decoration: none +} +.sm-appinfo-message .sm-share-icon { + padding-left: 14px; + background-size: 12px 12px; + background-repeat:no-repeat; + background-position:left; + font-weight:600; +} + +.sm-appinfo-message .sm-email { + font-weight:600; + color: cadetblue; +} \ No newline at end of file diff --git a/client/src/ext/ux/css/LockingGridView.css b/client/src/ext/ux/css/LockingGridView.css index 1659107f2..a70d65010 100644 --- a/client/src/ext/ux/css/LockingGridView.css +++ b/client/src/ext/ux/css/LockingGridView.css @@ -24,7 +24,8 @@ Build date: 2013-04-03 15:07:25 } .x-grid3-locked { - border-right: 1px solid #99BBE8; + border-right: 1px solid #808080; + box-sizing: border-box; } .x-grid3-locked .x-grid3-scroller { diff --git a/client/src/img/browser.svg b/client/src/img/browser.svg new file mode 100644 index 000000000..efc2f8f52 --- /dev/null +++ b/client/src/img/browser.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/client/src/img/circle.svg b/client/src/img/circle.svg new file mode 100644 index 000000000..9edaa9abd --- /dev/null +++ b/client/src/img/circle.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/client/src/img/collapse.svg b/client/src/img/collapse.svg new file mode 100644 index 000000000..2685f838f --- /dev/null +++ b/client/src/img/collapse.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/src/img/columns.svg b/client/src/img/columns.svg new file mode 100644 index 000000000..cbe974061 --- /dev/null +++ b/client/src/img/columns.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/client/src/img/cpu.svg b/client/src/img/cpu.svg new file mode 100644 index 000000000..cfc8a548d --- /dev/null +++ b/client/src/img/cpu.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/src/img/down.svg b/client/src/img/down.svg new file mode 100644 index 000000000..080e61315 --- /dev/null +++ b/client/src/img/down.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/client/src/img/expand-collapse.svg b/client/src/img/expand-collapse.svg new file mode 100644 index 000000000..3ab6a20d8 --- /dev/null +++ b/client/src/img/expand-collapse.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/client/src/img/expand.svg b/client/src/img/expand.svg new file mode 100644 index 000000000..c7c6321ff --- /dev/null +++ b/client/src/img/expand.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/client/src/img/info-circle.svg b/client/src/img/info-circle.svg new file mode 100644 index 000000000..ad9655c56 --- /dev/null +++ b/client/src/img/info-circle.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/client/src/img/jsIconGreen.svg b/client/src/img/jsIconGreen.svg new file mode 100644 index 000000000..4d140b28e --- /dev/null +++ b/client/src/img/jsIconGreen.svg @@ -0,0 +1 @@ + diff --git a/client/src/img/json.svg b/client/src/img/json.svg new file mode 100644 index 000000000..8156878d3 --- /dev/null +++ b/client/src/img/json.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/client/src/img/mysql.svg b/client/src/img/mysql.svg new file mode 100644 index 000000000..efb658b84 --- /dev/null +++ b/client/src/img/mysql.svg @@ -0,0 +1,44 @@ + + + + + + + + + diff --git a/client/src/img/rest-api.svg b/client/src/img/rest-api.svg new file mode 100644 index 000000000..3ae70c411 --- /dev/null +++ b/client/src/img/rest-api.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/client/src/img/share.svg b/client/src/img/share.svg new file mode 100644 index 000000000..428d9995c --- /dev/null +++ b/client/src/img/share.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/client/src/img/up.svg b/client/src/img/up.svg new file mode 100644 index 000000000..506111937 --- /dev/null +++ b/client/src/img/up.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/client/src/img/whatsnew/2024-10-09-app-info-share.png b/client/src/img/whatsnew/2024-10-09-app-info-share.png new file mode 100644 index 000000000..740c06f38 Binary files /dev/null and b/client/src/img/whatsnew/2024-10-09-app-info-share.png differ diff --git a/client/src/js/LockingGridView.js b/client/src/js/LockingGridView.js new file mode 100644 index 000000000..ded8cc2c0 --- /dev/null +++ b/client/src/js/LockingGridView.js @@ -0,0 +1,945 @@ +/* +This file is part of Ext JS 3.4 + +Copyright (c) 2011-2013 Sencha Inc + +Contact: http://www.sencha.com/contact + +GNU General Public License Usage +This file may be used under the terms of the GNU General Public License version 3.0 as +published by the Free Software Foundation and appearing in the file LICENSE included in the +packaging of this file. + +Please review the following information to ensure the GNU General Public License version 3.0 +requirements will be met: http://www.gnu.org/copyleft/gpl.html. + +If you are unsure which license is appropriate for your use, please contact the sales department +at http://www.sencha.com/contact. + +Build date: 2013-04-03 15:07:25 +*/ +Ext.ns('Ext.ux.grid'); + +Ext.ux.grid.LockingGridView = Ext.extend(Ext.grid.GridView, { + lockText : 'Lock', + unlockText : 'Unlock', + rowBorderWidth : 1, + lockedBorderWidth : 1, + + /* + * This option ensures that height between the rows is synchronized + * between the locked and unlocked sides. This option only needs to be used + * when the row heights aren't predictable. + */ + syncHeights: false, + + initTemplates : function(){ + var ts = this.templates || {}; + + if (!ts.masterTpl) { + ts.masterTpl = new Ext.Template( + '
', + '
', + '
{lockedHeader}
', + '
{lockedBody}
', + '
', + '
', + '
{header}
', + '
{body}
', + '
', + '
 
', + '
 
', + '
' + ); + } + + this.templates = ts; + + Ext.ux.grid.LockingGridView.superclass.initTemplates.call(this); + }, + + getEditorParent : function(ed){ + return this.el.dom; + }, + + initElements : function(){ + var el = Ext.get(this.grid.getGridEl().dom.firstChild), + lockedWrap = el.child('div.x-grid3-locked'), + lockedHd = lockedWrap.child('div.x-grid3-header'), + lockedScroller = lockedWrap.child('div.x-grid3-scroller'), + mainWrap = el.child('div.x-grid3-viewport'), + mainHd = mainWrap.child('div.x-grid3-header'), + scroller = mainWrap.child('div.x-grid3-scroller'); + + if (this.grid.hideHeaders) { + lockedHd.setDisplayed(false); + mainHd.setDisplayed(false); + } + + if(this.forceFit){ + scroller.setStyle('overflow-x', 'hidden'); + } + + Ext.apply(this, { + el : el, + mainWrap: mainWrap, + mainHd : mainHd, + innerHd : mainHd.dom.firstChild, + scroller: scroller, + mainBody: scroller.child('div.x-grid3-body'), + focusEl : scroller.child('a'), + resizeMarker: el.child('div.x-grid3-resize-marker'), + resizeProxy : el.child('div.x-grid3-resize-proxy'), + lockedWrap: lockedWrap, + lockedHd: lockedHd, + lockedScroller: lockedScroller, + lockedBody: lockedScroller.child('div.x-grid3-body'), + lockedInnerHd: lockedHd.child('div.x-grid3-header-inner', true) + }); + + this.focusEl.swallowEvent('click', true); + }, + + getLockedRows : function(){ + return this.hasRows() ? this.lockedBody.dom.childNodes : []; + }, + + getLockedRow : function(row){ + return this.getLockedRows()[row]; + }, + + getCell : function(row, col){ + var lockedLen = this.cm.getLockedCount(); + if(col < lockedLen){ + return this.getLockedRow(row).getElementsByTagName('td')[col]; + } + return Ext.ux.grid.LockingGridView.superclass.getCell.call(this, row, col - lockedLen); + }, + + getHeaderCell : function(index){ + var lockedLen = this.cm.getLockedCount(); + if(index < lockedLen){ + return this.lockedHd.dom.getElementsByTagName('td')[index]; + } + return Ext.ux.grid.LockingGridView.superclass.getHeaderCell.call(this, index - lockedLen); + }, + + addRowClass : function(row, cls){ + var lockedRow = this.getLockedRow(row); + if(lockedRow){ + this.fly(lockedRow).addClass(cls); + } + Ext.ux.grid.LockingGridView.superclass.addRowClass.call(this, row, cls); + }, + + removeRowClass : function(row, cls){ + var lockedRow = this.getLockedRow(row); + if(lockedRow){ + this.fly(lockedRow).removeClass(cls); + } + Ext.ux.grid.LockingGridView.superclass.removeRowClass.call(this, row, cls); + }, + + removeRow : function(row) { + Ext.removeNode(this.getLockedRow(row)); + Ext.ux.grid.LockingGridView.superclass.removeRow.call(this, row); + }, + + removeRows : function(firstRow, lastRow){ + var lockedBody = this.lockedBody.dom, + rowIndex = firstRow; + for(; rowIndex <= lastRow; rowIndex++){ + Ext.removeNode(lockedBody.childNodes[firstRow]); + } + Ext.ux.grid.LockingGridView.superclass.removeRows.call(this, firstRow, lastRow); + }, + + syncScroll : function(e){ + this.lockedScroller.dom.scrollTop = this.scroller.dom.scrollTop; + Ext.ux.grid.LockingGridView.superclass.syncScroll.call(this, e); + }, + + updateSortIcon : function(col, dir){ + var sortClasses = this.sortClasses, + lockedHeaders = this.lockedHd.select('td').removeClass(sortClasses), + headers = this.mainHd.select('td').removeClass(sortClasses), + lockedLen = this.cm.getLockedCount(), + cls = sortClasses[dir == 'DESC' ? 1 : 0]; + + if(col < lockedLen){ + lockedHeaders.item(col).addClass(cls); + }else{ + headers.item(col - lockedLen).addClass(cls); + } + }, + + updateAllColumnWidths : function(){ + var tw = this.getTotalWidth(), + clen = this.cm.getColumnCount(), + lw = this.getLockedWidth(), + llen = this.cm.getLockedCount(), + ws = [], len, i; + this.updateLockedWidth(); + for(i = 0; i < clen; i++){ + ws[i] = this.getColumnWidth(i); + var hd = this.getHeaderCell(i); + hd.style.width = ws[i]; + } + var lns = this.getLockedRows(), ns = this.getRows(), row, trow, j; + for(i = 0, len = ns.length; i < len; i++){ + row = lns[i]; + row.style.width = lw; + if(row.firstChild){ + row.firstChild.style.width = lw; + trow = row.firstChild.rows[0]; + for (j = 0; j < llen; j++) { + trow.childNodes[j].style.width = ws[j]; + } + } + row = ns[i]; + row.style.width = tw; + if(row.firstChild){ + row.firstChild.style.width = tw; + trow = row.firstChild.rows[0]; + for (j = llen; j < clen; j++) { + trow.childNodes[j - llen].style.width = ws[j]; + } + } + } + this.onAllColumnWidthsUpdated(ws, tw); + this.syncHeaderHeight(); + }, + + updateColumnWidth : function(col, width){ + var w = this.getColumnWidth(col), + llen = this.cm.getLockedCount(), + ns, rw, c, row; + this.updateLockedWidth(); + if(col < llen){ + ns = this.getLockedRows(); + rw = this.getLockedWidth(); + c = col; + }else{ + ns = this.getRows(); + rw = this.getTotalWidth(); + c = col - llen; + } + var hd = this.getHeaderCell(col); + hd.style.width = w; + for(var i = 0, len = ns.length; i < len; i++){ + row = ns[i]; + row.style.width = rw; + if(row.firstChild){ + row.firstChild.style.width = rw; + row.firstChild.rows[0].childNodes[c].style.width = w; + } + } + this.onColumnWidthUpdated(col, w, this.getTotalWidth()); + this.syncHeaderHeight(); + }, + + updateColumnHidden : function(col, hidden){ + var llen = this.cm.getLockedCount(), + ns, rw, c, row, + display = hidden ? 'none' : ''; + this.updateLockedWidth(); + if(col < llen){ + ns = this.getLockedRows(); + rw = this.getLockedWidth(); + c = col; + }else{ + ns = this.getRows(); + rw = this.getTotalWidth(); + c = col - llen; + } + var hd = this.getHeaderCell(col); + hd.style.display = display; + for(var i = 0, len = ns.length; i < len; i++){ + row = ns[i]; + row.style.width = rw; + if(row.firstChild){ + row.firstChild.style.width = rw; + row.firstChild.rows[0].childNodes[c].style.display = display; + } + } + this.onColumnHiddenUpdated(col, hidden, this.getTotalWidth()); + delete this.lastViewWidth; + this.layout(); + }, + + doRender : function(cs, rs, ds, startRow, colCount, stripe){ + var ts = this.templates, ct = ts.cell, rt = ts.row, last = colCount-1, + tstyle = 'width:'+this.getTotalWidth()+';', + lstyle = 'width:'+this.getLockedWidth()+';', + buf = [], lbuf = [], cb, lcb, c, p = {}, rp = {}, r; + for(var j = 0, len = rs.length; j < len; j++){ + r = rs[j]; cb = []; lcb = []; + var rowIndex = (j+startRow); + for(var i = 0; i < colCount; i++){ + c = cs[i]; + p.id = c.id; + p.css = (i === 0 ? 'x-grid3-cell-first ' : (i == last ? 'x-grid3-cell-last ' : '')) + + (this.cm.config[i].cellCls ? ' ' + this.cm.config[i].cellCls : ''); + p.attr = p.cellAttr = ''; + p.value = c.renderer(r.data[c.name], p, r, rowIndex, i, ds); + p.style = c.style; + if(Ext.isEmpty(p.value)){ + p.value = ' '; + } + if(this.markDirty && r.dirty && Ext.isDefined(r.modified[c.name])){ + p.css += ' x-grid3-dirty-cell'; + } + if(c.locked){ + lcb[lcb.length] = ct.apply(p); + }else{ + cb[cb.length] = ct.apply(p); + } + } + var alt = []; + if(stripe && ((rowIndex+1) % 2 === 0)){ + alt[0] = 'x-grid3-row-alt'; + } + if(r.dirty){ + alt[1] = ' x-grid3-dirty-row'; + } + rp.cols = colCount; + if(this.getRowClass){ + alt[2] = this.getRowClass(r, rowIndex, rp, ds); + } + rp.alt = alt.join(' '); + rp.cells = cb.join(''); + rp.tstyle = tstyle; + buf[buf.length] = rt.apply(rp); + rp.cells = lcb.join(''); + rp.tstyle = lstyle; + lbuf[lbuf.length] = rt.apply(rp); + } + return [buf.join(''), lbuf.join('')]; + }, + processRows : function(startRow, skipStripe){ + if(!this.ds || this.ds.getCount() < 1){ + return; + } + var rows = this.getRows(), + lrows = this.getLockedRows(), + row, lrow; + skipStripe = skipStripe || !this.grid.stripeRows; + startRow = startRow || 0; + for(var i = 0, len = rows.length; i < len; ++i){ + row = rows[i]; + lrow = lrows[i]; + row.rowIndex = i; + lrow.rowIndex = i; + if(!skipStripe){ + row.className = row.className.replace(this.rowClsRe, ' '); + lrow.className = lrow.className.replace(this.rowClsRe, ' '); + if ((i + 1) % 2 === 0){ + row.className += ' x-grid3-row-alt'; + lrow.className += ' x-grid3-row-alt'; + } + } + this.syncRowHeights(row, lrow); + } + if(startRow === 0){ + Ext.fly(rows[0]).addClass(this.firstRowCls); + Ext.fly(lrows[0]).addClass(this.firstRowCls); + } + Ext.fly(rows[rows.length - 1]).addClass(this.lastRowCls); + Ext.fly(lrows[lrows.length - 1]).addClass(this.lastRowCls); + }, + + syncRowHeights: function(row1, row2){ + if(this.syncHeights){ + var el1 = Ext.get(row1), + el2 = Ext.get(row2), + h1 = el1.getHeight(), + h2 = el2.getHeight(); + + if(h1 > h2){ + el2.setHeight(h1); + }else if(h2 > h1){ + el1.setHeight(h2); + } + } + }, + + afterRender : function(){ + if(!this.ds || !this.cm){ + return; + } + var bd = this.renderRows() || [' ', ' ']; + this.mainBody.dom.innerHTML = bd[0]; + this.lockedBody.dom.innerHTML = bd[1]; + this.processRows(0, true); + if(this.deferEmptyText !== true){ + this.applyEmptyText(); + } + this.grid.fireEvent('viewready', this.grid); + }, + + renderUI : function(){ + var templates = this.templates, + header = this.renderHeaders(), + body = templates.body.apply({rows:' '}); + + return templates.masterTpl.apply({ + body : body, + header: header[0], + ostyle: 'width:' + this.getOffsetWidth() + ';', + bstyle: 'width:' + this.getTotalWidth() + ';', + lockedBody: body, + lockedHeader: header[1], + lstyle: 'width:'+this.getLockedWidth()+';' + }); + }, + + afterRenderUI: function(){ + var g = this.grid; + this.initElements(); + Ext.fly(this.innerHd).on('click', this.handleHdDown, this); + Ext.fly(this.lockedInnerHd).on('click', this.handleHdDown, this); + this.mainHd.on({ + scope: this, + mouseover: this.handleHdOver, + mouseout: this.handleHdOut, + mousemove: this.handleHdMove + }); + this.lockedHd.on({ + scope: this, + mouseover: this.handleHdOver, + mouseout: this.handleHdOut, + mousemove: this.handleHdMove + }); + this.scroller.on('scroll', this.syncScroll, this); + if(g.enableColumnResize !== false){ + this.splitZone = new Ext.grid.GridView.SplitDragZone(g, this.mainHd.dom); + this.splitZone.setOuterHandleElId(Ext.id(this.lockedHd.dom)); + this.splitZone.setOuterHandleElId(Ext.id(this.mainHd.dom)); + } + if(g.enableColumnMove){ + this.columnDrag = new Ext.grid.GridView.ColumnDragZone(g, this.innerHd); + this.columnDrag.setOuterHandleElId(Ext.id(this.lockedInnerHd)); + this.columnDrag.setOuterHandleElId(Ext.id(this.innerHd)); + this.columnDrop = new Ext.grid.HeaderDropZone(g, this.mainHd.dom); + } + if(g.enableHdMenu !== false){ + this.hmenu = new Ext.menu.Menu({id: g.id + '-hctx'}); + this.hmenu.add( + {itemId: 'asc', text: this.sortAscText, cls: 'xg-hmenu-sort-asc'}, + {itemId: 'desc', text: this.sortDescText, cls: 'xg-hmenu-sort-desc'} + ); + if(this.grid.enableColLock !== false){ + this.hmenu.add({ + itemId: 'sortSep', + xtype: 'menuseparator' + }, + {itemId: 'lock', text: this.lockText, cls: 'xg-hmenu-lock'}, + {itemId: 'unlock', text: this.unlockText, cls: 'xg-hmenu-unlock'} + ); + } + if(g.enableColumnHide !== false){ + this.colMenu = new Ext.menu.Menu({id:g.id + '-hcols-menu'}); + this.colMenu.on({ + scope: this, + beforeshow: this.beforeColMenuShow, + itemclick: this.handleHdMenuClick + }); + this.hmenu.add('-', { + itemId:'columns', + hideOnClick: false, + text: this.columnsText, + menu: this.colMenu, + iconCls: 'x-cols-icon' + }); + } + this.hmenu.on('itemclick', this.handleHdMenuClick, this); + } + if(g.trackMouseOver){ + this.mainBody.on({ + scope: this, + mouseover: this.onRowOver, + mouseout: this.onRowOut + }); + this.lockedBody.on({ + scope: this, + mouseover: this.onRowOver, + mouseout: this.onRowOut + }); + } + + if(g.enableDragDrop || g.enableDrag){ + this.dragZone = new Ext.grid.GridDragZone(g, { + ddGroup : g.ddGroup || 'GridDD' + }); + } + this.updateHeaderSortState(); + }, + + layout : function(){ + if(!this.mainBody){ + return; + } + var g = this.grid; + var c = g.getGridEl(); + var csize = c.getSize(true); + var vw = csize.width; + if(!g.hideHeaders && (vw < 20 || csize.height < 20)){ + return; + } + this.syncHeaderHeight(); + if(g.autoHeight){ + this.scroller.dom.style.overflow = 'visible'; + this.lockedScroller.dom.style.overflow = 'visible'; + if(Ext.isWebKit){ + this.scroller.dom.style.position = 'static'; + this.lockedScroller.dom.style.position = 'static'; + } + }else{ + this.el.setSize(csize.width, csize.height); + var hdHeight = this.mainHd.getHeight(); + var vh = csize.height - (hdHeight); + } + this.updateLockedWidth(); + if(this.forceFit){ + if(this.lastViewWidth != vw){ + this.fitColumns(false, false); + this.lastViewWidth = vw; + } + }else { + this.autoExpand(); + this.syncHeaderScroll(); + } + this.onLayout(vw, vh); + }, + + getOffsetWidth : function() { + return (this.cm.getTotalWidth() - this.cm.getTotalLockedWidth() + this.getScrollOffset()) + 'px'; + }, + + renderHeaders : function(){ + var cm = this.cm, + ts = this.templates, + ct = ts.hcell, + cb = [], lcb = [], + p = {}, + len = cm.getColumnCount(), + last = len - 1; + for(var i = 0; i < len; i++){ + p.id = cm.getColumnId(i); + p.value = cm.getColumnHeader(i) || ''; + p.style = this.getColumnStyle(i, true); + p.tooltip = this.getColumnTooltip(i); + p.css = (i === 0 ? 'x-grid3-cell-first ' : (i == last ? 'x-grid3-cell-last ' : '')) + + (cm.config[i].headerCls ? ' ' + cm.config[i].headerCls : ''); + if(cm.config[i].align == 'right'){ + p.istyle = 'padding-right:4px'; + } else { + delete p.istyle; + } + if(cm.isLocked(i)){ + lcb[lcb.length] = ct.apply(p); + }else{ + cb[cb.length] = ct.apply(p); + } + } + return [ts.header.apply({cells: cb.join(''), tstyle:'width:'+this.getTotalWidth()+';'}), + ts.header.apply({cells: lcb.join(''), tstyle:'width:'+this.getLockedWidth()+';'})]; + }, + + updateHeaders : function(){ + var hd = this.renderHeaders(); + this.innerHd.firstChild.innerHTML = hd[0]; + this.innerHd.firstChild.style.width = this.getOffsetWidth(); + this.innerHd.firstChild.firstChild.style.width = this.getTotalWidth(); + this.lockedInnerHd.firstChild.innerHTML = hd[1]; + var lw = this.getLockedWidth(); + this.lockedInnerHd.firstChild.style.width = lw; + this.lockedInnerHd.firstChild.firstChild.style.width = lw; + }, + + getResolvedXY : function(resolved){ + if(!resolved){ + return null; + } + var c = resolved.cell, r = resolved.row; + return c ? Ext.fly(c).getXY() : [this.scroller.getX(), Ext.fly(r).getY()]; + }, + + syncFocusEl : function(row, col, hscroll){ + Ext.ux.grid.LockingGridView.superclass.syncFocusEl.call(this, row, col, col < this.cm.getLockedCount() ? false : hscroll); + }, + + ensureVisible : function(row, col, hscroll){ + return Ext.ux.grid.LockingGridView.superclass.ensureVisible.call(this, row, col, col < this.cm.getLockedCount() ? false : hscroll); + }, + + insertRows : function(dm, firstRow, lastRow, isUpdate){ + var last = dm.getCount() - 1; + if(!isUpdate && firstRow === 0 && lastRow >= last){ + this.refresh(); + }else{ + if(!isUpdate){ + this.fireEvent('beforerowsinserted', this, firstRow, lastRow); + } + var html = this.renderRows(firstRow, lastRow), + before = this.getRow(firstRow); + if(before){ + if(firstRow === 0){ + this.removeRowClass(0, this.firstRowCls); + } + Ext.DomHelper.insertHtml('beforeBegin', before, html[0]); + before = this.getLockedRow(firstRow); + Ext.DomHelper.insertHtml('beforeBegin', before, html[1]); + }else{ + this.removeRowClass(last - 1, this.lastRowCls); + Ext.DomHelper.insertHtml('beforeEnd', this.mainBody.dom, html[0]); + Ext.DomHelper.insertHtml('beforeEnd', this.lockedBody.dom, html[1]); + } + if(!isUpdate){ + this.fireEvent('rowsinserted', this, firstRow, lastRow); + this.processRows(firstRow); + }else if(firstRow === 0 || firstRow >= last){ + this.addRowClass(firstRow, firstRow === 0 ? this.firstRowCls : this.lastRowCls); + } + } + this.syncFocusEl(firstRow); + }, + + getColumnStyle : function(col, isHeader){ + var style = !isHeader ? this.cm.config[col].cellStyle || this.cm.config[col].css || '' : this.cm.config[col].headerStyle || ''; + style += 'width:'+this.getColumnWidth(col)+';'; + if(this.cm.isHidden(col)){ + style += 'display:none;'; + } + var align = this.cm.config[col].align; + if(align){ + style += 'text-align:'+align+';'; + } + return style; + }, + + getLockedWidth : function() { + return this.cm.getTotalLockedWidth() + 'px'; + }, + + getTotalWidth : function() { + return (this.cm.getTotalWidth() - this.cm.getTotalLockedWidth()) + 'px'; + }, + + getColumnData : function(){ + var cs = [], cm = this.cm, colCount = cm.getColumnCount(); + for(var i = 0; i < colCount; i++){ + var name = cm.getDataIndex(i); + cs[i] = { + name : (!Ext.isDefined(name) ? this.ds.fields.get(i).name : name), + renderer : cm.getRenderer(i), + scope : cm.getRendererScope(i), + id : cm.getColumnId(i), + style : this.getColumnStyle(i), + locked : cm.isLocked(i) + }; + } + return cs; + }, + + renderBody : function(){ + var markup = this.renderRows() || [' ', ' ']; + return [this.templates.body.apply({rows: markup[0]}), this.templates.body.apply({rows: markup[1]})]; + }, + + refreshRow: function(record){ + var store = this.ds, + colCount = this.cm.getColumnCount(), + columns = this.getColumnData(), + last = colCount - 1, + cls = ['x-grid3-row'], + rowParams = { + tstyle: String.format("width: {0};", this.getTotalWidth()) + }, + lockedRowParams = { + tstyle: String.format("width: {0};", this.getLockedWidth()) + }, + colBuffer = [], + lockedColBuffer = [], + cellTpl = this.templates.cell, + rowIndex, + row, + lockedRow, + column, + meta, + css, + i; + + if (Ext.isNumber(record)) { + rowIndex = record; + record = store.getAt(rowIndex); + } else { + rowIndex = store.indexOf(record); + } + + if (!record || rowIndex < 0) { + return; + } + + for (i = 0; i < colCount; i++) { + column = columns[i]; + + if (i == 0) { + css = 'x-grid3-cell-first'; + } else { + css = (i == last) ? 'x-grid3-cell-last ' : ''; + } + + meta = { + id: column.id, + style: column.style, + css: css, + attr: "", + cellAttr: "" + }; + + meta.value = column.renderer.call(column.scope, record.data[column.name], meta, record, rowIndex, i, store); + + if (Ext.isEmpty(meta.value)) { + meta.value = ' '; + } + + if (this.markDirty && record.dirty && typeof record.modified[column.name] != 'undefined') { + meta.css += ' x-grid3-dirty-cell'; + } + + if (column.locked) { + lockedColBuffer[i] = cellTpl.apply(meta); + } else { + colBuffer[i] = cellTpl.apply(meta); + } + } + + row = this.getRow(rowIndex); + row.className = ''; + lockedRow = this.getLockedRow(rowIndex); + lockedRow.className = ''; + + if (this.grid.stripeRows && ((rowIndex + 1) % 2 === 0)) { + cls.push('x-grid3-row-alt'); + } + + if (this.getRowClass) { + rowParams.cols = colCount; + cls.push(this.getRowClass(record, rowIndex, rowParams, store)); + } + + // Unlocked rows + this.fly(row).addClass(cls).setStyle(rowParams.tstyle); + rowParams.cells = colBuffer.join(""); + row.innerHTML = this.templates.rowInner.apply(rowParams); + + // Locked rows + this.fly(lockedRow).addClass(cls).setStyle(lockedRowParams.tstyle); + lockedRowParams.cells = lockedColBuffer.join(""); + lockedRow.innerHTML = this.templates.rowInner.apply(lockedRowParams); + lockedRow.rowIndex = rowIndex; + this.syncRowHeights(row, lockedRow); + this.fireEvent('rowupdated', this, rowIndex, record); + }, + + refresh : function(headersToo){ + this.fireEvent('beforerefresh', this); + this.grid.stopEditing(true); + var result = this.renderBody(); + this.mainBody.update(result[0]).setWidth(this.getTotalWidth()); + this.lockedBody.update(result[1]).setWidth(this.getLockedWidth()); + if(headersToo === true){ + this.updateHeaders(); + this.updateHeaderSortState(); + } + this.processRows(0, true); + this.layout(); + this.applyEmptyText(); + this.fireEvent('refresh', this); + }, + + onDenyColumnLock : function(){ + + }, + + initData : function(ds, cm){ + if(this.cm){ + this.cm.un('columnlockchange', this.onColumnLock, this); + } + Ext.ux.grid.LockingGridView.superclass.initData.call(this, ds, cm); + if(this.cm){ + this.cm.on('columnlockchange', this.onColumnLock, this); + } + }, + + onColumnLock : function(){ + this.refresh(true); + }, + + handleHdMenuClick : function(item){ + var index = this.hdCtxIndex, + cm = this.cm, + id = item.getItemId(), + llen = cm.getLockedCount(); + switch(id){ + case 'lock': + if(cm.getColumnCount(true) <= llen + 1){ + this.onDenyColumnLock(); + return undefined; + } + cm.setLocked(index, true, llen != index); + if(llen != index){ + cm.moveColumn(index, llen); + this.grid.fireEvent('columnmove', index, llen); + } + break; + case 'unlock': + if(llen - 1 != index){ + cm.setLocked(index, false, true); + cm.moveColumn(index, llen - 1); + this.grid.fireEvent('columnmove', index, llen - 1); + }else{ + cm.setLocked(index, false); + } + break; + default: + return Ext.ux.grid.LockingGridView.superclass.handleHdMenuClick.call(this, item); + } + return true; + }, + + handleHdDown : function(e, t){ + Ext.ux.grid.LockingGridView.superclass.handleHdDown.call(this, e, t); + if(this.grid.enableColLock !== false){ + if(Ext.fly(t).hasClass('x-grid3-hd-btn')){ + var hd = this.findHeaderCell(t), + index = this.getCellIndex(hd), + ms = this.hmenu.items, cm = this.cm; + ms.get('lock').setDisabled(cm.isLocked(index)); + ms.get('unlock').setDisabled(!cm.isLocked(index)); + } + } + }, + + syncHeaderHeight: function(){ + var hrow = Ext.fly(this.innerHd).child('tr', true), + lhrow = Ext.fly(this.lockedInnerHd).child('tr', true); + + hrow.style.height = 'auto'; + lhrow.style.height = 'auto'; + var hd = hrow.offsetHeight, + lhd = lhrow.offsetHeight, + height = Math.max(lhd, hd) + 'px'; + + hrow.style.height = height; + lhrow.style.height = height; + + }, + + updateLockedWidth: function(){ + var lw = this.cm.getTotalLockedWidth(), + tw = this.cm.getTotalWidth() - lw, + csize = this.grid.getGridEl().getSize(true), + lp = Ext.isBorderBox ? 0 : this.lockedBorderWidth, + rp = Ext.isBorderBox ? 0 : this.rowBorderWidth, + vw = Math.max(csize.width - lw - lp - rp, 0) + 'px', + so = this.getScrollOffset(); + if(!this.grid.autoHeight){ + var vh = Math.max(csize.height - this.mainHd.getHeight(), 0) + 'px'; + this.lockedScroller.dom.style.height = vh; + this.scroller.dom.style.height = vh; + } + this.lockedWrap.dom.style.width = (lw + rp) + 'px'; + this.scroller.dom.style.width = vw; + this.mainWrap.dom.style.left = (lw + lp + rp) + 'px'; + if(this.innerHd){ + this.lockedInnerHd.firstChild.style.width = lw + 'px'; + this.lockedInnerHd.firstChild.firstChild.style.width = lw + 'px'; + this.innerHd.style.width = vw; + this.innerHd.firstChild.style.width = (tw + rp + so) + 'px'; + this.innerHd.firstChild.firstChild.style.width = tw + 'px'; + } + if(this.mainBody){ + this.lockedBody.dom.style.width = (lw + rp) + 'px'; + this.mainBody.dom.style.width = (tw + rp) + 'px'; + } + } +}); + +Ext.ux.grid.LockingColumnModel = Ext.extend(Ext.grid.ColumnModel, { + /** + * Returns true if the given column index is currently locked + * @param {Number} colIndex The column index + * @return {Boolean} True if the column is locked + */ + isLocked : function(colIndex){ + return this.config[colIndex].locked === true; + }, + + /** + * Locks or unlocks a given column + * @param {Number} colIndex The column index + * @param {Boolean} value True to lock, false to unlock + * @param {Boolean} suppressEvent Pass false to cause the columnlockchange event not to fire + */ + setLocked : function(colIndex, value, suppressEvent){ + if (this.isLocked(colIndex) == value) { + return; + } + this.config[colIndex].locked = value; + if (!suppressEvent) { + this.fireEvent('columnlockchange', this, colIndex, value); + } + }, + + /** + * Returns the total width of all locked columns + * @return {Number} The width of all locked columns + */ + getTotalLockedWidth : function(){ + var totalWidth = 0; + for (var i = 0, len = this.config.length; i < len; i++) { + if (this.isLocked(i) && !this.isHidden(i)) { + totalWidth += this.getColumnWidth(i); + } + } + + return totalWidth; + }, + + /** + * Returns the total number of locked columns + * @return {Number} The number of locked columns + */ + getLockedCount : function() { + var len = this.config.length; + + for (var i = 0; i < len; i++) { + if (!this.isLocked(i)) { + return i; + } + } + + //if we get to this point all of the columns are locked so we return the total + return len; + }, + + /** + * Moves a column from one position to another + * @param {Number} oldIndex The current column index + * @param {Number} newIndex The destination column index + */ + moveColumn : function(oldIndex, newIndex){ + var oldLocked = this.isLocked(oldIndex), + newLocked = this.isLocked(newIndex); + + if (oldIndex < newIndex && oldLocked && !newLocked) { + this.setLocked(oldIndex, false, true); + } else if (oldIndex > newIndex && !oldLocked && newLocked) { + this.setLocked(oldIndex, true, true); + } + + Ext.ux.grid.LockingColumnModel.superclass.moveColumn.apply(this, arguments); + } +}); diff --git a/client/src/js/SM/AppData.js b/client/src/js/SM/AppData.js new file mode 100644 index 000000000..a261ffa71 --- /dev/null +++ b/client/src/js/SM/AppData.js @@ -0,0 +1,246 @@ +Ext.ns('SM.AppData') + +SM.AppData.DownloadButton = Ext.extend(Ext.Button, { + initComponent: function () { + const config = { + text: 'Download Application Data ', + iconCls: 'sm-export-icon', + handler: this._handler + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this); + + }, + _handler: async function () { + try { + await SM.AppData.doDownload() + } + catch (e) { + SM.Error.handleError(e) + } + finally { + Ext.getBody().unmask(); + } + } +}) + +SM.AppData.ReplaceButton = Ext.extend(Ext.Button, { + initComponent: function () { + const config = { + text: 'Replace Application Data... ', + iconCls: 'sm-import-icon', + handler: this._handler + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this); + + }, + _handler: async function () { + try { + SM.AppData.doReplace() + } + catch (e) { + SM.Error.handleError(e) + } + finally { + Ext.getBody().unmask(); + } + } +}) + +SM.AppData.ManagePanel = Ext.extend(Ext.Panel, { + initComponent: function () { + this.downloadBtn = new SM.AppData.DownloadButton({ + padding: 10 + }) + this.replaceBtn = new SM.AppData.ReplaceButton({ + padding: 10 + }) + const config = { + items: [ + { + xtype: 'fieldset', + width: 200, + title: 'Export', + items: [this.downloadBtn] + }, + { + xtype: 'fieldset', + width: 200, + title: 'Import', + items: [this.replaceBtn] + } + ] + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this); + } +}) + +SM.AppData.ReplacePanel = Ext.extend(Ext.Panel, { + initComponent: function () { + this.selectFileBtn = new Ext.ux.form.FileUploadField({ + buttonOnly: true, + accept: '.json,.zip', + webkitdirectory: false, + multiple: false, + style: 'width: 95px;', + buttonText: `Select appdata file...`, + buttonCfg: { + icon: "img/disc_drive.png" + }, + listeners: { + fileselected: this.onFileSelected + } + }) + this.textarea = new Ext.form.TextArea({ + buffer: '', + anchor: '100%, -10', + border: false, + readOnly: true + }) + + const config = { + layout: 'anchor', + border: false, + items: [this.textarea], + tbar: [ + this.selectFileBtn, + ] + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + }, + updateStatusText: function (text, noNL = false, replace = false) { + const ta = this.textarea + if (replace) ta.buffer = '' + if (noNL) { + ta.buffer += text; + } else { + ta.buffer += text + "\n" + } + ta.setRawValue(ta.buffer) + ta.getEl().dom.scrollTop = 99999 // scroll to bottom + } +}) + +SM.AppData.doDownload = async function () { + try { + await window.oidcProvider.updateToken(10) + const fetchInit = { + url: `${STIGMAN.Env.apiBase}/op/appdata?elevate=true`, + method: 'GET', + headers: { + 'Authorization': `Bearer ${window.oidcProvider.token}` + } + } + const href = await SM.ServiceWorker.getDownloadUrl(fetchInit) + if (href) { + window.location = href + return + } + } + catch (e) { + SM.Error.handleError(e) + } +} + +SM.AppData.doReplace = function () { + const rp = new SM.AppData.ReplacePanel({ + onFileSelected, + btnHandler + }) + + new Ext.Window({ + title: 'Replace Application Data', + cls: 'sm-dialog-window sm-round-panel', + modal: true, + width: 500, + height: 400, + layout: 'fit', + plain: true, + bodyStyle: 'padding:5px;', + buttonAlign: 'center', + items: rp, + onEsc: Ext.emptyFn + }).show(document.body) + rp.updateStatusText('IMPORTANT: Content from the imported file will replace ALL existing application data!', true, true) + + function btnHandler (btn) { + if (btn.fileObj) upload(btn.fileObj) + } + + async function upload (fileObj) { + try { + rp.ownerCt.getTool('close')?.hide() + + rp.updateStatusText('Awaiting API response...', false, true) + let formData = new FormData() + formData.append('importFile', fileObj); + + await window.oidcProvider.updateToken(10) + const response = await fetch(`${STIGMAN.Env.apiBase}/op/appdata?elevate=true`, { + method: 'POST', + headers: new Headers({ + 'Authorization': `Bearer ${window.oidcProvider.token}` + }), + body: formData + }) + + const responseStream = response.body + .pipeThrough(new TextDecoderStream()) + + for await (const line of responseStream) { + rp.updateStatusText(line) + await new Promise(resolve => setTimeout(resolve, 10)) + } + rp.updateStatusText('\n**** REFRESH the web app to use the new data ****') + } + catch (e) { + SM.Error.handleError(e) + } + + } + + async function onFileSelected (uploadField) { + try { + let input = uploadField.fileInput.dom + const files = [...input.files] + await upload(files[0]) + } + catch (e) { + uploadField.reset() + SM.Error.handleError(e) + } + } +} + +SM.AppData.showAppDataTab = function (params) { + let { treePath } = params + const tab = Ext.getCmp('main-tab-panel').getItem('appdata-admin-tab') + if (tab) { + tab.show() + return + } + + const appDataPanel = new SM.AppData.ManagePanel({ + border: false, + margins: { top: SM.Margin.adjacent, right: SM.Margin.edge, bottom: SM.Margin.bottom, left: SM.Margin.edge }, + cls: 'sm-round-panel', + height: 200, + labelWidth: 1, + hideLabel: true, + padding: 10 + }) + + const thisTab = Ext.getCmp('main-tab-panel').add({ + id: 'appdata-admin-tab', + sm_treePath: treePath, + iconCls: 'sm-database-save-icon', + title: 'Export/Import Data', + closable: true, + layout: 'fit', + items: [appDataPanel] + }) + thisTab.show() +} diff --git a/client/src/js/SM/AppInfo.js b/client/src/js/SM/AppInfo.js new file mode 100644 index 000000000..65025e0f9 --- /dev/null +++ b/client/src/js/SM/AppInfo.js @@ -0,0 +1,3369 @@ +Ext.ns("SM.AppInfo") +Ext.ns("SM.AppInfo.Collections") +Ext.ns("SM.AppInfo.MySql") +Ext.ns("SM.AppInfo.Requests") +Ext.ns("SM.AppInfo.Users") +Ext.ns("SM.AppInfo.Nodejs") +Ext.ns("SM.AppInfo.ShareFile") + +SM.AppInfo.numberFormat = new Intl.NumberFormat().format + +SM.AppInfo.numberRenderer = function (value) { + return value && value !== 0 ? SM.AppInfo.numberFormat(value) : `${value}` +} + +SM.AppInfo.usernameLookup = {} + +SM.AppInfo.uptimeString = function uptimeString(uptime) { + const days = Math.floor(uptime / 86400) + uptime %= 86400 + const hours = Math.floor(uptime / 3600) + uptime %= 3600 + const minutes = Math.floor(uptime / 60) + const seconds = Math.floor(uptime % 60) + return `${days}d ${hours}h ${minutes}m ${seconds}s` +} + +SM.AppInfo.transformPreviousSchemas = function (input) { + if (input.schema === 'stig-manager-appinfo-v1.0') { + return input + } + if (!input.stigmanVersion) { + return false + } + + // renames properties "assetStigByCollection" and "restrictedGrantCountsByUser" + function transformCountsByCollection(i) { + const o = {} + const padLength = Object.keys(i).at(-1).length + for (const id in i) { + const { + assetStigByCollection, + restrictedGrantCountsByUser, + assetsTotal, + assetsDisabled, + ruleCnt, + reviewCntTotal, + reviewCntDisabled, + labelCounts, + ...keep } = i[id] + + // rename restrictedGrantCountsByUser properties to match aclCounts schema + for (const userId in restrictedGrantCountsByUser) { + restrictedGrantCountsByUser[userId].ruleCounts = { + rw: restrictedGrantCountsByUser[userId].stigAssetCount, + r: 0, + none: 0 + } + delete restrictedGrantCountsByUser[userId].stigAssetCount + } + + // rename grantCounts properties + const grantCounts = { + restricted: keep.grantCounts.accessLevel1, + full: keep.grantCounts.accessLevel2, + manage: keep.grantCounts.accessLevel3, + owner: keep.grantCounts.accessLevel4 + } + delete keep.grantCounts + + // rename labelCounts properties + labelCounts.collectionLabels = labelCounts.collectionLabelCount + delete labelCounts.collectionLabelCount + labelCounts.labeledAssets = labelCounts.labeledAssetCount + delete labelCounts.labeledAssetCount + labelCounts.assetLabels = labelCounts.assetLabelCount + delete labelCounts.assetLabelCount + + o[id] = { + name: id.padStart(padLength, '0'), + assets: assetsTotal - assetsDisabled, + assetsDisabled, + rules: ruleCnt, + reviews: reviewCntTotal - reviewCntDisabled, + reviewsDisabled: reviewCntDisabled, + ...keep, + assetStigRanges: transformAssetStigByCollection(assetStigByCollection), + aclCounts: { + users: restrictedGrantCountsByUser || {} + }, + grantCounts, + labelCounts, + settings: { + fields: { + detail: { + enabled: null, + required: null + }, + comment: { + enabled: null, + required: null + } + }, + status: { + canAccept: null, + resetCriteria: null, + minAcceptGrant: null + } + + } + } + } + return o + } + + // renames property "roles" and removes the string "other" + function transformUserInfo(i) { + const o = {} + const padLength = Object.keys(i).at(-1).length + for (const id in i) { + const { roles, ...keep } = i[id] + o[id] = { + username: id.padStart(padLength, '0'), + ...keep, + privileges: roles?.filter(v => v !== 'other') || [], + roles: { + restricted: null, + full: null, + manage: null, + owner: null + } + } + } + return o + } + + // remove counts of the "other" string + function transformUserPrivilegeCounts(i) { + for (const category in i) { + delete i[category].other + } + return i + } + + // add count of privilege "none" to each category + // must be called after transforming userInfo + function addNoPrivilegeCount(i) { + const dataTime = Math.floor(new Date(i.dateGenerated) / 1000) + const thirtyDaysAgo = dataTime - (30 * 24 * 60 * 60) + const ninetyDaysAgo = dataTime - (90 * 24 * 60 * 60) + + i.userPrivilegeCounts.overall.none = 0 + i.userPrivilegeCounts.activeInLast90Days.none = 0 + i.userPrivilegeCounts.activeInLast30Days.none = 0 + + for (const userId in i.userInfo) { + const user = i.userInfo[userId] + if (user.privileges.length === 0) { + i.userPrivilegeCounts.overall.none++ + // Update counts for the last 30 and 90 days based on lastAccess + if (user.lastAccess >= ninetyDaysAgo) { + i.userPrivilegeCounts.activeInLast90Days.none++ + } + if (user.lastAccess >= thirtyDaysAgo) { + i.userPrivilegeCounts.activeInLast30Days.none++ + } + } + } + } + + function transformAssetStigByCollection(i) { + i.range00 = i.assetCnt - (i.range01to05 + i.range06to10 + i.range11to15 + i.range16plus) + delete i.assetCnt + return i + } + + const { operationIdStats, ...requestsKeep } = input.operationalStats + for (const opId in operationIdStats) { + operationIdStats[opId].errors = {} + } + + input.userInfo = transformUserInfo(input.userInfo) + addNoPrivilegeCount(input) + transformUserPrivilegeCounts(input.userPrivilegeCounts) + + const norm = { + date: input.dateGenerated, + schema: 'stig-manager-appinfo-v1.0', + version: input.stigmanVersion, + collections: transformCountsByCollection(input.countsByCollection), + requests: { + ...requestsKeep, + operationIds: operationIdStats + }, + users: { + userInfo: input.userInfo, + userPrivilegeCounts: input.userPrivilegeCounts + }, + mysql: { + version: input.mySqlVersion, + tables: input.dbInfo.tables, + variables: input.mySqlVariablesRaw, + status: input.mySqlStatusRaw + }, + nodejs: { + version: 'v0.0.0', + uptime: parseNodeUptimeString(input.nodeUptime), + os: {}, + environment: {}, + memory: input.nodeMemoryUsageInMb, + cpus: [] + } + } + return norm + + function parseNodeUptimeString(uptimeString) { + const values = uptimeString.match(/\d+/g) + return (parseInt(values[0]) * 86400) + + (parseInt(values[1]) * 3600) + + (parseInt(values[2]) * 60) + + parseInt(values[3]) + } +} + +SM.AppInfo.objectToRowsArray = function (obj, keyPropertyName) { + const rows = [] + for (const prop of obj) { + rows.push({[keyPropertyName]: prop, ...obj[prop]}) + } + return rows +} + +SM.AppInfo.KeyValueGrid = Ext.extend(Ext.grid.GridPanel, { + initComponent: function () { + const valueColumnId = Ext.id() + const fields = [ + 'key', + 'value' + ] + + const store = new Ext.data.JsonStore({ + fields, + root: '', + idProperty: 'key', + sortInfo: { + field: 'key', + direction: 'ASC' + } + }) + + const keyColumn = { + ...{ + header: 'key', + width: 100, + dataIndex: 'key', + sortable: true, + filter: { type: 'string' } + }, + ...this.keyColumnConfig + } + + const valueColumn = { + ...{ + header: 'value', + id: valueColumnId, + dataIndex: 'value', + sortable: true, + align: 'right', + renderer: v => { + const rendered = SM.AppInfo.numberRenderer(v) + return rendered === 'NaN' ? v : rendered + } + }, + ...this.valueColumnConfig + } + + const columns = [ + keyColumn, + valueColumn + ] + + const sm = new Ext.grid.RowSelectionModel({ + singleSelect: true + }) + + const view = new SM.ColumnFilters.GridView({ + emptyText: this.emptyText || 'No records to display', + deferEmptyText: false, + forceFit: this.forceFit ?? false, + markDirty: false, + listeners: { + filterschanged: function (view) { + store.filter(view.getFilterFns()) + } + } + }) + + const bbar = new Ext.Toolbar({ + items: [ + { + xtype: 'exportbutton', + hasMenu: false, + grid: this, + gridBasename: this.exportName || this.title || 'keys', + iconCls: 'sm-export-icon', + text: 'CSV' + }, + { + xtype: 'tbfill' + }, + { + xtype: 'tbseparator' + }, + new SM.RowCountTextItem({ + store, + noun: this.rowCountNoun ?? 'key', + iconCls: 'sm-circle-icon' + }) + ] + }) + + function loadData(o) { + const rows = [] + for (const key in o) { + rows.push({ key, value: o[key] }) + } + this.store.loadData(rows) + } + + const config = { + cls: this.cls ?? 'sm-round-panel', + autoExpandColumn: valueColumnId, + autoExpandMax: 500, + store, + view, + sm, + columns, + bbar, + loadData + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.JsonTreePanel = Ext.extend(Ext.Panel, { + initComponent: function () { + let tree + function loadData(data) { + tree = JsonView.createTree(data) + tree.isExpanded = true + if (this.body) { + this.body.dom.textContent = '' + JsonView.render(tree, this.body.dom) + } + } + function renderTree() { + if (tree) { + JsonView.render(tree, this.body.dom) + } + } + + const config = { + bodyStyle: 'overflow-y:auto;', + loadData + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + this.on('render', renderTree) + } +}) + +SM.AppInfo.Collections.OverviewGrid = Ext.extend(Ext.grid.GridPanel, { + initComponent: function () { + const fields = [ + { + name: 'collectionId', + type: 'int' + }, + 'name', + 'state', + 'assets', + 'assetsDisabled', + { + name: 'assetsTotal', + convert: (v, r) => r.assets + r.assetsDisabled + }, + 'uniqueStigs', + 'stigAssignments', + 'rules', + 'reviews', + 'reviewsDisabled', + { + name: 'reviewsTotal', + convert: (v, r) => r.reviews + r.reviewsDisabled + }, + 'aclCounts', + { + name: 'aclCountUsers', + convert: (v, r) => Object.keys(r.aclCounts.users).length || 0 + } + ] + + const store = new Ext.data.JsonStore({ + fields, + root: '', + idProperty: 'collectionId', + sortInfo: { + field: 'name', + direction: 'ASC' + } + }) + + const columns = [ + { + header: "Collection", + width: 180, + dataIndex: 'name', + sortable: true, + filter: { type: 'string' } + }, + { + header: "Id", + hidden: true, + dataIndex: 'collectionId', + sortable: true, + }, + { + header: "State", + dataIndex: 'state', + sortable: true, + filter: { type: 'values' } + }, + { + header: "Assets", + dataIndex: 'assets', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Assets Disabled", + dataIndex: 'assetsDisabled', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Assets Total", + hidden: true, + dataIndex: 'assetsTotal', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "STIGs", + dataIndex: 'uniqueStigs', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Assignments", + dataIndex: 'stigAssignments', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Rules", + dataIndex: 'rules', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Reviews", + dataIndex: 'reviews', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Reviews Disabled", + dataIndex: 'reviewsDisabled', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Reviews Total", + dataIndex: 'reviewsTotal', + hidden: true, + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "User ACLs", + dataIndex: 'aclCountUsers', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + } + ] + + const sm = new Ext.grid.RowSelectionModel({ + singleSelect: true, + listeners: { + rowselect: this.onRowSelect ?? Ext.emptyFn + } + }) + + const view = new SM.ColumnFilters.GridView({ + emptyText: this.emptyText || 'No records to display', + forceFit: true, + listeners: { + filterschanged: function (view) { + store.filter(view.getFilterFns()) + } + }, + getRowClass: record => record.data.state === 'disabled' ? 'sm-row-disabled' : '' + }) + + const bbar = new Ext.Toolbar({ + items: [ + { + xtype: 'exportbutton', + hasMenu: false, + grid: this, + gridBasename: this.exportName || this.title || 'collections', + iconCls: 'sm-export-icon', + text: 'CSV' + }, + { + xtype: 'tbfill' + }, + { + xtype: 'tbseparator' + }, + new SM.RowCountTextItem({ + store, + noun: 'collection', + iconCls: 'sm-collection-icon' + }) + ] + }) + + const config = { + cls: this.cls ?? 'sm-round-panel', + store, + view, + sm, + columns, + bbar + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.Collections.FullGridLocked = Ext.extend(Ext.grid.GridPanel, { + initComponent: function () { + const fields = [ + { + name: 'collectionId', + type: 'int' + }, + 'name', + 'state', + 'assets', + 'assetsDisabled', + { + name: 'assetsTotal', + convert: (v, r) => r.assets + r.assetsDisabled + }, + 'uniqueStigs', + 'stigAssignments', + 'rules', + 'reviews', + 'reviewsDisabled', + { + name: 'reviewsTotal', + convert: (v, r) => r.reviews + r.reviewsDisabled + }, + 'aclCounts', + { + name: 'aclCountUsers', + convert: (v, r) => Object.keys(r.aclCounts.users).length || 0 + }, + { + name: 'range00', + mapping: 'assetStigRanges.range00' + }, + { + name: 'range01to05', + mapping: 'assetStigRanges.range01to05' + }, + { + name: 'range06to10', + mapping: 'assetStigRanges.range06to10' + }, + { + name: 'range11to15', + mapping: 'assetStigRanges.range11to15' + }, + { + name: 'range16plus', + mapping: 'assetStigRanges.range16plus' + }, + { + name: 'restricted', + mapping: 'grantCounts.restricted' + }, + { + name: 'full', + mapping: 'grantCounts.full' + }, + { + name: 'manage', + mapping: 'grantCounts.manage' + }, + { + name: 'owner', + mapping: 'grantCounts.owner' + }, + { + name: 'collectionLabels', + mapping: 'labelCounts.collectionLabels' + }, + { + name: 'labeledAssets', + mapping: 'labelCounts.labeledAssets' + }, + { + name: 'assetLabels', + mapping: 'labelCounts.assetLabels' + }, + { + name: 'detailEnabled', + mapping: 'settings.fields.detail.enabled' + }, + { + name: 'detailRequired', + mapping: 'settings.fields.detail.required' + }, + { + name: 'commentEnabled', + mapping: 'settings.fields.comment.enabled' + }, + { + name: 'commentRequired', + mapping: 'settings.fields.comment.required' + }, + { + name: 'canAccept', + mapping: 'settings.status.canAccept' + }, + { + name: 'resetCriteria', + mapping: 'settings.status.resetCriteria' + }, + { + name: 'minAcceptGrant', + mapping: 'settings.status.minAcceptGrant' + } + ] + + const store = new Ext.data.JsonStore({ + fields, + root: '', + idProperty: 'collectionId', + sortInfo: { + field: 'name', + direction: 'ASC' + } + }) + + const columns = [ + { + header: "Name", + locked: true, + width: 180, + dataIndex: 'name', + sortable: true, + filter: { type: 'string' } + }, + { + header: "Id", + hidden: true, + dataIndex: 'collectionId', + sortable: true, + }, + { + header: "State", + dataIndex: 'state', + sortable: true, + filter: { type: 'values' } + }, + { + header: "Assets", + dataIndex: 'assets', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Assets Disabled", + dataIndex: 'assetsDisabled', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Assets Total", + hidden: true, + dataIndex: 'assetsTotal', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "STIGs", + dataIndex: 'uniqueStigs', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Assignments", + dataIndex: 'stigAssignments', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Rules", + dataIndex: 'rules', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Reviews", + dataIndex: 'reviews', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Reviews Disabled", + dataIndex: 'reviewsDisabled', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Reviews Total", + dataIndex: 'reviewsTotal', + hidden: true, + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "User ACLs", + dataIndex: 'aclCountUsers', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Range 0", + dataIndex: 'range00', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Range 1-5", + dataIndex: 'range01to05', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Range 6-10", + dataIndex: 'range06to10', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Range 11-15", + dataIndex: 'range11to15', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Range 16+", + dataIndex: 'range16plus', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Restricted", + dataIndex: 'restricted', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Full", + dataIndex: 'full', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Manage", + dataIndex: 'manage', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Owner", + dataIndex: 'owner', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Labels", + dataIndex: 'collectionLabels', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Labeled", + dataIndex: 'labeledAssets', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Tags", + dataIndex: 'assetLabels', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Detail Enabled", + dataIndex: 'detailEnabled', + sortable: true + }, + { + header: "Detail Required", + dataIndex: 'detailRequired', + sortable: true + }, + { + header: "Comment Enabled", + dataIndex: 'commentEnabled', + sortable: true + }, + { + header: "Comment Required", + dataIndex: 'commentRequired', + sortable: true + }, + { + header: "Can Accept", + dataIndex: 'canAccept', + sortable: true + }, + { + header: "Reset Criteria", + dataIndex: 'resetCriteria', + sortable: true + }, + { + header: "Accept Grant", + dataIndex: 'minAcceptGrant', + sortable: true + } + ] + + const sm = new Ext.grid.RowSelectionModel({ + singleSelect: true, + listeners: { + rowselect: this.onRowSelect ?? Ext.emptyFn + } + }) + + const view = new SM.ColumnFilters.GridViewLocking({ + emptyText: this.emptyText || 'No records to display', + listeners: { + filterschanged: function (view) { + store.filter(view.getFilterFns()) + } + }, + getRowClass: record => record.data.state === 'disabled' ? 'sm-row-disabled' : '' + }) + + const bbar = new Ext.Toolbar({ + items: [ + { + xtype: 'exportbutton', + hasMenu: false, + grid: this, + gridBasename: this.exportName || this.title || 'collections', + iconCls: 'sm-export-icon', + text: 'CSV' + }, + { + xtype: 'tbfill' + }, + { + xtype: 'tbseparator' + }, + new SM.RowCountTextItem({ + store, + noun: 'collection', + iconCls: 'sm-collection-icon' + }) + ] + }) + + const config = { + enableColLock: false, + cls: this.cls ?? 'sm-round-panel', + store, + view, + sm, + colModel: new Ext.ux.grid.LockingColumnModel(columns), + bbar + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.Collections.AclGrid = Ext.extend(Ext.grid.GridPanel, { + initComponent: function () { + const fields = [ + { + name: 'userId', + type: 'int' + }, + 'username', + 'uniqueAssets', + 'uniqueAssetsDisabled', + 'uniqueStigs', + 'uniqueStigsDisabled', + { + name: 'ruleCountRw', + mapping: 'ruleCounts.rw' + }, + { + name: 'ruleCountR', + mapping: 'ruleCounts.r' + }, + { + name: 'ruleCountNone', + mapping: 'ruleCounts.none' + }, + 'role', + 'access' + ] + + const store = new Ext.data.JsonStore({ + fields, + root: '', + idProperty: 'userId', + sortInfo: { + field: 'username', + direction: 'ASC' + } + }) + + const columns = [ + { + header: "Id", + hidden: true, + dataIndex: 'userId', + sortable: true, + }, + { + header: "Grantee", + dataIndex: 'username', + sortable: true, + filter: { type: 'string' } + }, + { + header: "Role", + dataIndex: 'role', + sortable: true, + filter: { type: 'string' } + }, + { + header: "Rules RW", + dataIndex: 'ruleCountRw', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Rules R", + dataIndex: 'ruleCountR', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Rules None", + dataIndex: 'ruleCountNone', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Assets", + dataIndex: 'uniqueAssets', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Assets Disabled", + dataIndex: 'uniqueAssetsDisabled', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "STIGs", + dataIndex: 'uniqueStigs', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "STIGs Disabled", + dataIndex: 'uniqueStigsDisabled', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + ] + + const sm = new Ext.grid.RowSelectionModel({ + singleSelect: true + }) + + const view = new SM.ColumnFilters.GridView({ + emptyText: this.emptyText || 'No records to display', + deferEmptyText: false, + forceFit: true, + markDirty: false, + listeners: { + filterschanged: function (view) { + store.filter(view.getFilterFns()) + } + } + }) + + const bbar = new Ext.Toolbar({ + items: [ + { + xtype: 'exportbutton', + hasMenu: false, + grid: this, + gridBasename: this.exportName || this.title || 'acls', + iconCls: 'sm-export-icon', + text: 'CSV' + }, + { + xtype: 'tbfill' + }, + { + xtype: 'tbseparator' + }, + new SM.RowCountTextItem({ + store, + noun: 'ACL', + iconCls: 'sm-collection-icon' + }) + ] + }) + + const config = { + cls: this.cls ?? 'sm-round-panel', + store, + view, + sm, + columns, + bbar + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.Collections.AssetStigGrid = Ext.extend(Ext.grid.GridPanel, { + initComponent: function () { + const fields = [ + { + name: 'collectionId', + type: 'int' + }, + 'name', + 'state', + 'range00', + 'range01to05', + 'range06to10', + 'range11to15', + 'range16plus' + ] + + const store = new Ext.data.JsonStore({ + fields, + root: '', + idProperty: 'collectionId', + sortInfo: { + field: 'name', + direction: 'ASC' + } + }) + + const columns = [ + { + header: "Id", + hidden: true, + dataIndex: 'collectionId', + sortable: true, + }, + { + header: "Collection", + dataIndex: 'name', + sortable: true, + filter: { type: 'string' } + }, + { + header: "State", + hidden: true, + dataIndex: 'state', + sortable: true, + filter: { type: 'values' } + }, + { + header: "0", + dataIndex: 'range00', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "1-5", + dataIndex: 'range01to05', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "6-10", + dataIndex: 'range06to10', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "11-15", + dataIndex: 'range11to15', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "16+", + dataIndex: 'range16plus', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + } + ] + + const sm = new Ext.grid.RowSelectionModel({ + singleSelect: true, + listeners: { + rowselect: this.onRowSelect ?? Ext.emptyFn + } + }) + + const view = new SM.ColumnFilters.GridView({ + emptyText: this.emptyText || 'No records to display', + forceFit: true, + listeners: { + filterschanged: function (view) { + store.filter(view.getFilterFns()) + } + }, + getRowClass: record => record.data.state === 'disabled' ? 'sm-row-disabled' : '' + }) + + const bbar = new Ext.Toolbar({ + items: [ + { + xtype: 'exportbutton', + hasMenu: false, + grid: this, + gridBasename: this.exportName || this.title || 'collections', + iconCls: 'sm-export-icon', + text: 'CSV' + }, + { + xtype: 'tbfill' + }, + { + xtype: 'tbseparator' + }, + new SM.RowCountTextItem({ + store, + noun: 'collection', + iconCls: 'sm-collection-icon' + }) + ] + }) + + const config = { + cls: this.cls ?? 'sm-round-panel', + store, + view, + sm, + columns, + bbar + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.Collections.GrantsGrid = Ext.extend(Ext.grid.GridPanel, { + initComponent: function () { + const fields = [ + { + name: 'collectionId', + type: 'int' + }, + 'name', + 'state', + 'restricted', + 'full', + 'manage', + 'owner' + ] + + const store = new Ext.data.JsonStore({ + fields, + root: '', + idProperty: 'collectionId', + sortInfo: { + field: 'name', + direction: 'ASC' + } + }) + + const columns = [ + { + header: "Id", + hidden: true, + dataIndex: 'collectionId', + sortable: true, + }, + { + header: "Collection", + dataIndex: 'name', + sortable: true, + filter: { type: 'string' } + }, + { + header: "State", + hidden: true, + dataIndex: 'state', + sortable: true, + filter: { type: 'values' } + }, + { + header: "Restricted", + width: 40, + dataIndex: 'restricted', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Full", + width: 40, + dataIndex: 'full', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Manage", + width: 40, + dataIndex: 'manage', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Owner", + width: 40, + dataIndex: 'owner', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + } + ] + + const sm = new Ext.grid.RowSelectionModel({ + singleSelect: true, + listeners: { + rowselect: this.onRowSelect ?? Ext.emptyFn + } + }) + + const view = new SM.ColumnFilters.GridView({ + emptyText: this.emptyText || 'No records to display', + deferEmptyText: false, + forceFit: true, + markDirty: false, + listeners: { + filterschanged: function (view) { + store.filter(view.getFilterFns()) + } + }, + getRowClass: record => record.data.state === 'disabled' ? 'sm-row-disabled' : '' + }) + + const bbar = new Ext.Toolbar({ + items: [ + { + xtype: 'exportbutton', + hasMenu: false, + grid: this, + gridBasename: this.exportName || this.title || 'collections', + iconCls: 'sm-export-icon', + text: 'CSV' + }, + { + xtype: 'tbfill' + }, + { + xtype: 'tbseparator' + }, + new SM.RowCountTextItem({ + store, + noun: 'collection', + iconCls: 'sm-collection-icon' + }) + ] + }) + + const config = { + cls: this.cls ?? 'sm-round-panel', + store, + view, + sm, + columns, + bbar + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.Collections.LabelsGrid = Ext.extend(Ext.grid.GridPanel, { + initComponent: function () { + const fields = [ + { + name: 'collectionId', + type: 'int' + }, + 'name', + 'state', + 'collectionLabels', + 'labeledAssets', + 'assetLabels' + ] + + const store = new Ext.data.JsonStore({ + fields, + root: '', + idProperty: 'collectionId', + sortInfo: { + field: 'name', + direction: 'ASC' + } + }) + + const columns = [ + { + header: "Id", + hidden: true, + dataIndex: 'collectionId', + sortable: true, + }, + { + header: "Collection", + dataIndex: 'name', + sortable: true, + filter: { type: 'string' } + }, + { + header: "State", + hidden: true, + dataIndex: 'state', + sortable: true, + filter: { type: 'values' } + }, + { + header: "Labels", + dataIndex: 'collectionLabels', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Labeled", + dataIndex: 'labeledAssets', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Tags", + dataIndex: 'assetLabels', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + } + ] + + const sm = new Ext.grid.RowSelectionModel({ + singleSelect: true, + listeners: { + rowselect: this.onRowSelect ?? Ext.emptyFn + } + }) + + const view = new SM.ColumnFilters.GridView({ + emptyText: this.emptyText || 'No records to display', + forceFit: true, + listeners: { + filterschanged: function (view) { + store.filter(view.getFilterFns()) + } + }, + getRowClass: record => record.data.state === 'disabled' ? 'sm-row-disabled' : '' + }) + + const bbar = new Ext.Toolbar({ + items: [ + { + xtype: 'exportbutton', + hasMenu: false, + grid: this, + gridBasename: this.exportName || this.title || 'collections', + iconCls: 'sm-export-icon', + text: 'CSV' + }, + { + xtype: 'tbfill' + }, + { + xtype: 'tbseparator' + }, + new SM.RowCountTextItem({ + store, + noun: 'collection', + iconCls: 'sm-collection-icon' + }) + ] + }) + + const config = { + cls: this.cls ?? 'sm-round-panel', + store, + view, + sm, + columns, + bbar + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.Collections.SettingsGrid = Ext.extend(Ext.grid.GridPanel, { + initComponent: function () { + const fields = [ + { + name: 'collectionId', + type: 'int' + }, + 'name', + 'state', + { + name: 'detailEnabled', + mapping: 'fields.detail.enabled' + }, + { + name: 'detailRequired', + mapping: 'fields.detail.required' + }, + { + name: 'commentEnabled', + mapping: 'fields.comment.enabled' + }, + { + name: 'commentRequired', + mapping: 'fields.comment.required' + }, + { + name: 'canAccept', + mapping: 'status.canAccept' + }, + { + name: 'resetCriteria', + mapping: 'status.resetCriteria' + }, + { + name: 'minAcceptGrant', + mapping: 'status.minAcceptGrant' + } + ] + + const store = new Ext.data.JsonStore({ + fields, + root: '', + idProperty: 'collectionId', + sortInfo: { + field: 'name', + direction: 'ASC' + } + }) + + const columns = [ + { + header: "Id", + hidden: true, + dataIndex: 'collectionId', + sortable: true, + }, + { + header: "Collection", + dataIndex: 'name', + sortable: true, + filter: { type: 'string' } + }, + { + header: "State", + hidden: true, + dataIndex: 'state', + sortable: true, + filter: { type: 'values' } + }, + { + header: "Detail Enabled", + dataIndex: 'detailEnabled', + sortable: true + }, + { + header: "Detail Required", + dataIndex: 'detailRequired', + sortable: true + }, + { + header: "Comment Enabled", + dataIndex: 'commentEnabled', + sortable: true + }, + { + header: "Comment Required", + dataIndex: 'commentRequired', + sortable: true + }, + { + header: "Can Accept", + dataIndex: 'canAccept', + sortable: true + }, + { + header: "Reset Criteria", + dataIndex: 'resetCriteria', + sortable: true + }, + { + header: "Accept Grant", + dataIndex: 'minAcceptGrant', + sortable: true + } + ] + + const sm = new Ext.grid.RowSelectionModel({ + singleSelect: true, + listeners: { + rowselect: this.onRowSelect ?? Ext.emptyFn + } + }) + + const view = new SM.ColumnFilters.GridView({ + emptyText: this.emptyText || 'No records to display', + forceFit: true, + listeners: { + filterschanged: function (view) { + store.filter(view.getFilterFns()) + } + }, + getRowClass: record => record.data.state === 'disabled' ? 'sm-row-disabled' : '' + }) + + const bbar = new Ext.Toolbar({ + items: [ + { + xtype: 'exportbutton', + hasMenu: false, + grid: this, + gridBasename: this.exportName || this.title || 'collections', + iconCls: 'sm-export-icon', + text: 'CSV' + }, + { + xtype: 'tbfill' + }, + { + xtype: 'tbseparator' + }, + new SM.RowCountTextItem({ + store, + noun: 'collection', + iconCls: 'sm-collection-icon' + }) + ] + }) + + const config = { + cls: this.cls ?? 'sm-round-panel', + store, + view, + sm, + columns, + bbar + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.Collections.Container = Ext.extend(Ext.Container, { + initComponent: function () { + function loadData(data) { + // expects just the collections property of the full object + const overview = [] + const assetStig = [] + const grants = [] + const labels = [] + const settingRows = [] + for (const collectionId in data) { + const { settings, assetStigRanges, grantCounts, labelCounts, name, ...rest } = data[collectionId] + overview.push({ collectionId, name, ...rest }) + assetStig.push({ collectionId, name, ...assetStigRanges }) + grants.push({ collectionId, name, ...grantCounts }) + labels.push({ collectionId, name, ...labelCounts }) + settingRows.push({ collectionId, name, ...settings }) + } + overviewGrid.store.loadData(overview) + assetStigGrid.store.loadData(assetStig) + grantsGrid.store.loadData(grants) + labelsGrid.store.loadData(labels) + settingsGrid.store.loadData(settingRows) + aclGrid.store.removeAll() + + const overviewLocked = [] + for (const collectionId in data) { + overviewLocked.push({ collectionId, ...data[collectionId] }) + } + fullGridLocked.store.loadData(overviewLocked) + } + + function loadAce(sm, index, record) { + const data = record.data.aclCounts?.users + const rows = [] + for (const userId in data) { + rows.push({ userId, username: SM.AppInfo.usernameLookup[userId], ...data[userId] }) + } + aclGrid.store.loadData(rows) + } + + function syncGridsOnRowSelect(sm, rowIndex, e) { + const sourceRecord = sm.grid.store.getAt(rowIndex) + console.log(sourceRecord) + for (const peeredGrid of peeredGrids) { + if (sm.grid.title !== peeredGrid.title) { + const destRecord = peeredGrid.store.getById(sourceRecord.id) + const destIndex = peeredGrid.store.indexOf(destRecord) + peeredGrid.selModel.suspendEvents() + peeredGrid.selModel.selectRow(destIndex) + peeredGrid.selModel.resumeEvents() + peeredGrid.view.focusRow(destIndex) + } + } + // load restricted users grid, record in overviewGrid contains restrictedUsers field + loadAce(null, null, overviewGrid.store.getById(sourceRecord.id)) + } + + const overviewGrid = new SM.AppInfo.Collections.OverviewGrid({ + title: 'Overview', + border: false, + region: 'center', + onRowSelect: syncGridsOnRowSelect, + hideMode: 'offsets' + }) + const fullGridLocked = new SM.AppInfo.Collections.FullGridLocked({ + title: 'All Fields', + border: false, + id: 'appinfo-locked', + autoDestroy: false, + onRowSelect: syncGridsOnRowSelect, + hideMode: 'offsets' + }) + + const aclGrid = new SM.AppInfo.Collections.AclGrid({ + title: 'Access Control Lists', + border: false, + collapsible: true, + region: 'south', + split: true, + height: 240 + }) + const grantsGrid = new SM.AppInfo.Collections.GrantsGrid({ + title: 'Grants', + border: false, + onRowSelect: syncGridsOnRowSelect, + hideMode: 'offsets' + }) + const labelsGrid = new SM.AppInfo.Collections.LabelsGrid({ + title: 'Labels', + border: false, + onRowSelect: syncGridsOnRowSelect, + hideMode: 'offsets' + }) + const assetStigGrid = new SM.AppInfo.Collections.AssetStigGrid({ + title: 'STIG Assignment Ranges', + border: false, + onRowSelect: syncGridsOnRowSelect, + hideMode: 'offsets' + }) + const settingsGrid = new SM.AppInfo.Collections.SettingsGrid({ + title: 'Settings', + border: false, + onRowSelect: syncGridsOnRowSelect, + hideMode: 'offsets' + }) + const peeredGrids = [overviewGrid, grantsGrid, labelsGrid, assetStigGrid, settingsGrid, fullGridLocked] + const centerTp = new Ext.TabPanel({ + region: 'center', + border: false, + activeTab: 0, + deferredRender: false, + items: peeredGrids, + }) + const config = { + layout: 'border', + items: [ + centerTp, + aclGrid + ], + loadData + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.MySql.TablesGrid = Ext.extend(Ext.grid.GridPanel, { + initComponent: function () { + const fields = [ + 'tableName', + 'rowCount', + 'tableRows', + 'tableCollation', + 'avgRowLength', + 'dataLength', + 'indexLength', + 'autoIncrement', + 'createTime', + 'updateTime' + ] + + const store = new Ext.data.JsonStore({ + fields, + root: '', + idProperty: 'tableName', + sortInfo: { + field: 'tableName', + direction: 'ASC' + } + }) + + const columns = [ + { + header: "Table", + width: 160, + dataIndex: 'tableName', + sortable: true, + filter: { type: 'string' } + }, + { + header: "RowCount", + dataIndex: 'rowCount', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "TableRows", + dataIndex: 'tableRows', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Collation", + hidden: true, + dataIndex: 'tableCollation', + sortable: true, + align: 'right', + }, + { + header: "RowLengthAvg", + dataIndex: 'avgRowLength', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "DataLength", + dataIndex: 'dataLength', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "IndexLength", + dataIndex: 'indexLength', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "AutoIncrement", + dataIndex: 'autoIncrement', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Created", + dataIndex: 'createTime', + sortable: true, + align: 'right', + }, + { + header: "Updated", + dataIndex: 'updateTime', + sortable: true, + align: 'right', + } + ] + + const sm = new Ext.grid.RowSelectionModel({ + singleSelect: true + }) + + const view = new SM.ColumnFilters.GridView({ + emptyText: this.emptyText || 'No records to display', + forceFit: true, + listeners: { + filterschanged: function (view) { + store.filter(view.getFilterFns()) + } + } + }) + + const bbar = new Ext.Toolbar({ + items: [ + { + xtype: 'exportbutton', + hasMenu: false, + grid: this, + gridBasename: this.exportName || this.title || 'tables', + iconCls: 'sm-export-icon', + text: 'CSV' + }, + { + xtype: 'tbfill' + }, + { + xtype: 'tbseparator' + }, + new SM.RowCountTextItem({ + store, + noun: 'table', + iconCls: 'sm-database-icon' + }) + ] + }) + + const config = { + store, + view, + sm, + columns, + bbar + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.MySql.Container = Ext.extend(Ext.Container, { + initComponent: function () { + function loadData(data) { + // expects only mysql property from full appinfo object + const tables = [] + for (const key in data.tables) { + tables.push({ tableName: key, ...data.tables[key] }) + } + tablesGrid.store.loadData(tables) + variablesGrid.loadData(data.variables) + statusGrid.loadData(data.status) + const lengths = getTotalLengths(data.tables) + const sep = '' + tablesGrid.setTitle(`Tables ${sep} Data ≈ ${formatBytes(lengths.data)} ${sep} Indexes ≈ ${formatBytes(lengths.index)} ${sep} Version ${data.version} ${sep} Up ${SM.AppInfo.uptimeString(data.status.Uptime)} `) + } + + function getTotalLengths(tables) { + const lengths = { + data: 0, + index: 0 + } + for (const table in tables) { + lengths.data += tables[table].dataLength + lengths.index += tables[table].indexLength + } + return lengths + } + + function formatBytes(bytes, decimals = 2) { + if (!+bytes) return '0 Bytes' + + const k = 1024 + const dm = decimals < 0 ? 0 : decimals + const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] + + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}` + } + + + const tablesGrid = new SM.AppInfo.MySql.TablesGrid({ + title: ' ', + border: false, + cls: 'sm-round-panel', + region: 'center' + }) + + const variablesGrid = new SM.AppInfo.KeyValueGrid({ + title: 'Variables', + border: false, + flex: 1, + margins: { top: 0, right: 5, bottom: 0, left: 0 }, + keyColumnConfig: { header: 'Variable', width: 200 }, + valueColumnConfig: { header: 'Value' }, + exportName: 'variables', + rowCountNoun: 'variable' + }) + + const statusGrid = new SM.AppInfo.KeyValueGrid({ + title: 'Status', + border: false, + flex: 1, + margins: { top: 0, right: 0, bottom: 0, left: 5 }, + keyColumnConfig: { header: 'Variable', width: 200 }, + valueColumnConfig: { header: 'Value' }, + exportName: 'status', + rowCountNoun: 'variable' + }) + + const childContainer = new Ext.Container({ + region: 'south', + split: true, + height: 300, + layout: 'hbox', + bodyStyle: 'background-color: transparent;', + layoutConfig: { + align: 'stretch', + }, + items: [ + variablesGrid, + statusGrid + ] + }) + + const config = { + layout: 'border', + items: [ + tablesGrid, + childContainer + ], + loadData + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.Requests.OperationsGrid = Ext.extend(Ext.grid.GridPanel, { + initComponent: function () { + const fields = [ + 'operationId', + 'totalRequests', + 'totalDuration', + 'minDuration', + 'maxDuration', + 'maxDurationUpdates', + { + name: 'averageDuration', + convert: (v, r) => Math.round(r.totalDuration / r.totalRequests) + }, + 'elevatedRequests', + 'retried', + 'averageRetries', + 'totalReqLength', + 'minReqLength', + 'maxReqLength', + 'totalResLength', + 'minResLength', + 'maxResLength', + 'clients', + 'users', + 'projections', + 'errors', + { + name: 'errorCount', + convert: (v, r) => Object.values(r.errors).reduce((a, v) => a+v, 0) + } + ] + + const store = new Ext.data.JsonStore({ + fields, + root: '', + idProperty: 'operationId', + sortInfo: { + field: 'operationId', + direction: 'ASC' + } + }) + + const columns = [ + { + header: "Operation", + width: 160, + dataIndex: 'operationId', + sortable: true, + filter: { type: 'string' } + }, + { + header: "Requests", + dataIndex: 'totalRequests', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Errors", + dataIndex: 'errorCount', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Duration", + dataIndex: 'totalDuration', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "DurAvg", + hidden: true, + dataIndex: 'averageDuration', + sortable: true, + align: 'right', + }, + { + header: "DurMin", + dataIndex: 'minDuration', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "DurMax", + dataIndex: 'maxDuration', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "DurMaxUpdates", + hidden: true, + dataIndex: 'maxDurationUpdates', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Elevated", + dataIndex: 'elevatedRequests', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "Retried", + dataIndex: 'retried', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "RetriesAvg", + dataIndex: 'averageRetries', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "ResLen", + dataIndex: 'totalResLength', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "ResLenMin", + dataIndex: 'minResLength', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "ResLenMax", + dataIndex: 'maxResLength', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "ReqLen", + dataIndex: 'totalReqLength', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "ReqLenMin", + dataIndex: 'minReqLength', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: "ReqLenMin", + dataIndex: 'maxReqLength', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + } + ] + + const sm = new Ext.grid.RowSelectionModel({ + singleSelect: true, + listeners: { + rowselect: this.onRowSelect ?? Ext.emptyFn + }, + grid: this + }) + + const view = new SM.ColumnFilters.GridView({ + emptyText: this.emptyText || 'No records to display', + deferEmptyText: false, + forceFit: true, + markDirty: false, + listeners: { + filterschanged: function (view) { + store.filter(view.getFilterFns()) + } + } + }) + + const bbar = new Ext.Toolbar({ + items: [ + { + xtype: 'exportbutton', + hasMenu: false, + grid: this, + gridBasename: this.exportName || this.title || 'operations', + iconCls: 'sm-export-icon', + text: 'CSV' + }, + { + xtype: 'tbfill' + }, + { + xtype: 'tbseparator' + }, + new SM.RowCountTextItem({ + store, + noun: 'operation', + iconCls: 'sm-circle-icon' + }) + ] + }) + + const config = { + cls: this.cls ?? 'sm-round-panel', + store, + view, + sm, + columns, + bbar + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.Requests.ProjectionsGrid = Ext.extend(Ext.grid.GridPanel, { + initComponent: function () { + const fields = [ + 'projection', + 'totalRequests', + 'minDuration', + 'maxDuration', + 'totalDuration', + 'averageDuration', + 'retried', + 'averageRetries' + ] + + const store = new Ext.data.JsonStore({ + fields, + root: '', + idProperty: 'projection', + sortInfo: { + field: 'projection', + direction: 'ASC' + } + }) + + const columns = [ + { + header: 'Projection', + width: 160, + dataIndex: 'projection', + sortable: true, + filter: { type: 'string' } + }, + { + header: 'Requests', + dataIndex: 'totalRequests', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: 'Duration', + dataIndex: 'totalDuration', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: 'DurMin', + dataIndex: 'minDuration', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: 'DurMax', + dataIndex: 'maxDuration', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: 'DurationAvg', + dataIndex: 'averageDuration', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: 'Retried', + dataIndex: 'retried', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: 'RetriesAvg', + dataIndex: 'averageRetries', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + } + ] + + const sm = new Ext.grid.RowSelectionModel({ + singleSelect: true + }) + + const view = new SM.ColumnFilters.GridView({ + emptyText: this.emptyText || 'No records to display', + deferEmptyText: false, + forceFit: true, + markDirty: false, + listeners: { + filterschanged: function (view) { + store.filter(view.getFilterFns()) + } + } + }) + + const bbar = new Ext.Toolbar({ + items: [ + { + xtype: 'exportbutton', + hasMenu: false, + grid: this, + gridBasename: this.exportName || this.title || 'projections', + iconCls: 'sm-export-icon', + text: 'CSV' + }, + { + xtype: 'tbfill' + }, + { + xtype: 'tbseparator' + }, + new SM.RowCountTextItem({ + store, + noun: this.rowCountNoun ?? 'projection', + iconCls: 'sm-circle-icon' + }) + ] + }) + + const config = { + cls: this.cls ?? 'sm-round-panel', + store, + view, + sm, + columns, + bbar + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.Requests.Container = Ext.extend(Ext.Container, { + initComponent: function () { + const operationsGrid = new SM.AppInfo.Requests.OperationsGrid({ + title: 'Operations', + border: false, + region: 'center', + onRowSelect + }) + const usersGrid = new SM.AppInfo.KeyValueGrid({ + title: 'User requests', + border: false, + margins: { top: 0, right: 5, bottom: 0, left: 0 }, + keyColumnConfig: { header: 'Username' }, + valueColumnConfig: { header: 'Requests' }, + width: 200, + rowCountNoun: 'user' + }) + const clientsGrid = new SM.AppInfo.KeyValueGrid({ + title: 'Client requests', + border: false, + margins: { top: 0, right: 5, bottom: 0, left: 5 }, + keyColumnConfig: { header: 'Client' }, + valueColumnConfig: { header: 'Requests' }, + width: 200, + rowCountNoun: 'client' + }) + const errorsGrid = new SM.AppInfo.KeyValueGrid({ + title: 'Errors', + border: false, + margins: { top: 0, right: 5, bottom: 0, left: 5 }, + keyColumnConfig: { header: 'Code' }, + valueColumnConfig: { header: 'Requests' }, + width: 200, + rowCountNoun: 'error' + }) + const projectionsGrid = new SM.AppInfo.Requests.ProjectionsGrid({ + title: 'Projections', + border: false, + flex: 1, + margins: { top: 0, right: 0, bottom: 0, left: 5 } + }) + + function onRowSelect(sm, index, record) { + const users = [] + const clients = [] + const errors= [] + const projections = [] + const data = record.data + for (const userId in data.users) { + users.push({ key: SM.AppInfo.usernameLookup[userId] || 'unkown', value: data.users[userId] }) + } + for (const client in data.clients) { + clients.push({ key: client, value: data.clients[client] }) + } + for (const code in data.errors) { + errors.push({ key: code, value: data.errors[code] }) + } + for (const projection of Object.keys(data.projections)) { + projections.push({ projection, ...data.projections[projection] }) + } + usersGrid.store.loadData(users) + clientsGrid.store.loadData(clients) + errorsGrid.store.loadData(errors) + projectionsGrid.store.loadData(projections) + } + + const childContainer = new Ext.Container({ + region: 'south', + split: true, + height: 200, + bodyStyle: 'background-color: transparent;', + layout: 'hbox', + layoutConfig: { + align: 'stretch', + }, + items: [ + usersGrid, + clientsGrid, + errorsGrid, + projectionsGrid + ] + }) + + function loadData(data) { + const nr = SM.AppInfo.numberRenderer + const operationIds = [] + for (const key in data.operationIds) { + operationIds.push({ operationId: key, ...data.operationIds[key] }) + } + operationsGrid.store.loadData(operationIds) + const sep = `` + operationsGrid.setTitle(`API Operations ${sep} ${nr(data.totalRequests)} total requests, ${nr(data.totalApiRequests)} to API, duration ${nr(data.totalRequestDuration)}ms`) + usersGrid.store.removeAll() + clientsGrid.store.removeAll() + errorsGrid.store.removeAll() + projectionsGrid.store.removeAll() + } + + const config = { + layout: 'border', + items: [ + operationsGrid, + childContainer + ], + loadData + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.Users.InfoGrid = Ext.extend(Ext.grid.GridPanel, { + initComponent: function () { + const fields = [ + { + name: 'userId', + type: 'int' + }, + 'username', + 'created', + 'lastAccess', + 'privileges', + { + name: 'restricted', + mapping: 'roles.restricted', + useNull: true, + type: 'int' + }, + { + name: 'full', + mapping: 'roles.full', + useNull: true, + type: 'int' + }, + { + name: 'manage', + mapping: 'roles.manage', + useNull: true, + type: 'int' + }, + { + name: 'owner', + mapping: 'roles.owner', + useNull: true, + type: 'int' + } + ] + + const store = new Ext.data.JsonStore({ + fields, + root: '', + idProperty: 'userId', + sortInfo: { + field: 'username', + direction: 'ASC' + } + }) + + const columns = [ + { + header: 'Username', + dataIndex: 'username', + sortable: true, + filter: { type: 'string' } + }, + { + header: 'Id', + dataIndex: 'userId', + hidden: true, + sortable: true, + }, + { + header: 'Last Access', + dataIndex: 'lastAccess', + sortable: true, + align: 'right', + renderer: v => v ? new Date(v * 1000).toISOString() : '-' + }, + { + header: 'Owner', + dataIndex: 'owner', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: 'Manage', + dataIndex: 'manage', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: 'Full', + dataIndex: 'full', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: 'Restricted', + dataIndex: 'restricted', + sortable: true, + align: 'right', + renderer: SM.AppInfo.numberRenderer + }, + { + header: 'Privileges', + dataIndex: 'privileges', + sortable: true, + align: 'right', + renderer: v => JSON.stringify(v) + }, + { + header: 'Created', + dataIndex: 'created', + sortable: true, + align: 'right' + } + + ] + + const sm = new Ext.grid.RowSelectionModel({ + singleSelect: true + }) + + const view = new SM.ColumnFilters.GridView({ + emptyText: this.emptyText || 'No records to display', + deferEmptyText: false, + forceFit: true, + markDirty: false, + listeners: { + filterschanged: function (view) { + store.filter(view.getFilterFns()) + } + } + }) + + const bbar = new Ext.Toolbar({ + items: [ + { + xtype: 'exportbutton', + hasMenu: false, + grid: this, + gridBasename: this.exportName || this.title || 'users', + iconCls: 'sm-export-icon', + text: 'CSV' + }, + { + xtype: 'tbfill' + }, + { + xtype: 'tbseparator' + }, + new SM.RowCountTextItem({ + store, + noun: this.rowCountNoun ?? 'user', + iconCls: 'sm-users-icon' + }) + ] + }) + + const config = { + cls: this.cls ?? 'sm-round-panel', + store, + view, + sm, + columns, + bbar + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.Users.Container = Ext.extend(Ext.Container, { + initComponent: function () { + // expects just the value of appinfo.users + function loadData(data) { + const rows = [] + for (const key in data.userInfo) { + rows.push({ userId: key, ...data.userInfo[key] }) + } + infoGrid.store.loadData(rows) + + // setup the username lookup object + SM.AppInfo.usernameLookup = {} + for (const row of rows) { + SM.AppInfo.usernameLookup[row.userId] = row.username + } + + for (const key in data.userPrivilegeCounts) { + privilegePropertyGridMap[key].loadData(data.userPrivilegeCounts[key]) + } + } + + const privilegeGridOptions = { + border: false, + flex: 1, + keyColumnConfig: { header: 'Privilege' }, + valueColumnConfig: { header: 'User count' }, + forceFit: true, + exportName: 'overall', + rowCountNoun: 'privilege' + } + + const overallGrid = new SM.AppInfo.KeyValueGrid({ + title: 'Privileges - Overall', + margins: { top: 0, right: 5, bottom: 0, left: 0 }, + ...privilegeGridOptions + }) + const last30Grid = new SM.AppInfo.KeyValueGrid({ + title: 'Privileges - Active last 30d', + margins: { top: 0, right: 5, bottom: 0, left: 5 }, + ...privilegeGridOptions + }) + const last90Grid = new SM.AppInfo.KeyValueGrid({ + title: 'Privileges - Active last 90d', + margins: { top: 0, right: 0, bottom: 0, left: 5 }, + ...privilegeGridOptions + }) + + const privilegePropertyGridMap = { + overall: overallGrid, + activeInLast30Days: last30Grid, + activeInLast90Days: last90Grid + } + + const infoGrid = new SM.AppInfo.Users.InfoGrid({ + title: 'User details', + border: false, + region: 'center' + }) + + const privilegeContainer = new Ext.Container({ + region: 'south', + split: true, + height: 160, + bodyStyle: 'background-color: transparent;', + layout: 'hbox', + layoutConfig: { + align: 'stretch', + }, + border: false, + items: [ + overallGrid, + last30Grid, + last90Grid + ] + }) + + const config = { + layout: 'border', + items: [infoGrid, privilegeContainer], + loadData, + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.Nodejs.CpusGrid = Ext.extend(Ext.grid.GridPanel, { + initComponent: function () { + // expects the nodejs.cpus array as data + function loadData(data) { + let index = 0 + const rows = data?.map(item => ({ + cpu: index++, + ...item + })) || [] + store.loadData(rows) + } + const fields = [ + { + name: 'cpu', + type: 'int' + }, + 'model', + 'speed' + ] + + const store = new Ext.data.JsonStore({ + fields, + root: '', + idProperty: 'cpu', + sortInfo: { + field: 'cpu', + direction: 'ASC' + } + }) + + const columns = [ + { + header: 'CPU', + dataIndex: 'cpu', + width: 15, + sortable: true, + }, + { + header: 'Model', + dataIndex: 'model', + width: 60, + sortable: true, + filter: { type: 'string' } + }, + { + header: 'Speed (MHz)', + dataIndex: 'speed', + width: 25, + align: 'right', + sortable: true + } + ] + + const sm = new Ext.grid.RowSelectionModel({ + singleSelect: true + }) + + const view = new SM.ColumnFilters.GridView({ + emptyText: this.emptyText || 'No records to display', + deferEmptyText: false, + forceFit: true, + markDirty: false, + listeners: { + filterschanged: function (view) { + store.filter(view.getFilterFns()) + } + } + }) + + const bbar = new Ext.Toolbar({ + items: [ + { + xtype: 'exportbutton', + hasMenu: false, + grid: this, + gridBasename: this.exportName || this.title || 'cpus', + iconCls: 'sm-export-icon', + text: 'CSV' + }, + { + xtype: 'tbfill' + }, + { + xtype: 'tbseparator' + }, + new SM.RowCountTextItem({ + store, + noun: this.rowCountNoun ?? 'cpu', + iconCls: 'sm-cpu-icon' + }) + ] + }) + + const config = { + cls: this.cls ?? 'sm-round-panel', + store, + view, + sm, + columns, + bbar, + loadData + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.Nodejs.Container = Ext.extend(Ext.Container, { + initComponent: function () { + // expects just the value of appinfo.nodejs + function loadData(data) { + const sep = '' + envGrid.setTitle(`Environment ${sep} Version ${data.version} ${sep} up ${SM.AppInfo.uptimeString(data.uptime)}`) + memoryGrid.loadData(data.memory) + osGrid.loadData(data.os) + cpusGrid.loadData(data.cpus) + envGrid.loadData(data.environment) + } + + const envGrid = new SM.AppInfo.KeyValueGrid({ + title: 'Environment', + border: false, + region: 'center', + keyColumnConfig: { header: 'Variable', width: 240 }, + valueColumnConfig: { header: 'Value', align: 'left', width: 370 }, + forceFit: true, + exportName: 'environment', + rowCountNoun: 'item' + }) + const cpusGrid = new SM.AppInfo.Nodejs.CpusGrid({ + title: 'CPU', + border: false, + flex: 1, + margins: { top: 0, right: 5, bottom: 0, left: 0 } + }) + const memoryGrid = new SM.AppInfo.KeyValueGrid({ + title: 'Memory', + border: false, + flex: 1, + margins: { top: 0, right: 5, bottom: 0, left: 5 }, + keyColumnConfig: { header: 'Key' }, + valueColumnConfig: { header: 'Value' }, + exportName: 'memory' + }) + const osGrid = new SM.AppInfo.KeyValueGrid({ + title: 'OS', + border: false, + flex: 1, + margins: { top: 0, right: 0, bottom: 0, left: 5 }, + keyColumnConfig: { header: 'Key' }, + valueColumnConfig: { header: 'Value', align: 'left' }, + exportName: 'os' + }) + + const panel = new Ext.Panel({ + region: 'south', + split: true, + height: 300, + bodyStyle: 'background-color: transparent;', + layout: 'hbox', + layoutConfig: { + align: 'stretch' + }, + border: false, + items: [ + cpusGrid, + memoryGrid, + osGrid, + ] + }) + + const config = { + layout: 'border', + items: [envGrid, panel], + loadData, + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.TabPanel = Ext.extend(Ext.TabPanel, { + initComponent: function () { + const collectionsContainer = new SM.AppInfo.Collections.Container({ + border: false, + title: 'Collections', + iconCls: 'sm-collection-icon' + }) + + const usersContainer = new SM.AppInfo.Users.Container({ + title: 'Users', + iconCls: 'sm-users-icon' + }) + + const requestsContainer = new SM.AppInfo.Requests.Container({ + title: 'Requests', + iconCls: 'sm-browser-icon' + }) + + const mysqlContainer = new SM.AppInfo.MySql.Container({ + title: 'MySQL', + iconCls: 'sm-database-save-icon' + }) + + const nodejsContainer = new SM.AppInfo.Nodejs.Container({ + title: 'Node.js', + iconCls: 'sm-nodejs-icon', + }) + + const jsonPanel = new SM.AppInfo.JsonTreePanel({ + title: 'JSON Tree', + iconCls: 'sm-json-icon', + layout: 'fit' + }) + + const items = [ + requestsContainer, + collectionsContainer, + usersContainer, + mysqlContainer, + nodejsContainer, + jsonPanel, + ] + + function loadData(data) { + // users MUST be loaded first so the username lookup object is built + usersContainer.loadData(data.users) + collectionsContainer.loadData(data.collections) + requestsContainer.loadData(data.requests) + mysqlContainer.loadData(data.mysql) + nodejsContainer.loadData(data.nodejs) + jsonPanel.loadData(data) + + } + + const config = { + deferredRender: true, + loadData, + items + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.ShareFile.OptionsFieldSet = Ext.extend(Ext.form.FieldSet, { + initComponent: function () { + const collectionNames = new Ext.form.Checkbox({ + prop: 'collectionName', + boxLabel: 'Replace each Collection name with its ID' + }) + const usernames = new Ext.form.Checkbox({ + prop: 'username', + boxLabel: 'Replace each User name with its ID' + }) + const clientIds = new Ext.form.Checkbox({ + prop: 'clientId', + boxLabel: 'Replace each Request clientId with a generated value' + }) + const envvars = new Ext.form.Checkbox({ + prop: 'envvar', + boxLabel: 'Exclude Node.js environment variables' + }) + + const items = [ + collectionNames, + usernames, + clientIds, + envvars + ] + + function getValues() { + const values = {} + for (const item of items) { + values[item.prop] = item.getValue() + } + return values + } + const config = { + title: this.title || 'Options', + defaults: { + hideLabel: true, + checked: true + }, + autoHeight: true, + items, + getValues + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.ShareFile.Panel = Ext.extend(Ext.Panel, { + initComponent: function () { + const saveFn = this.onSaveShared || Ext.emptyFn + const _this = this + const fieldset = new SM.AppInfo.ShareFile.OptionsFieldSet() + const button = new Ext.Button({ + style: 'float: right; margin-top: 6px;', + cls: 'x-toolbar', + text: 'Save for sharing', + iconCls: 'sm-share-icon', + handler: () => { + const fieldsetValues = fieldset.getValues() + if (_this.menu) _this.menu.hide() + saveFn(fieldsetValues) + } + }) + const config = { + border: false, + autoWidth: true, + items: [ + fieldset, + button + ] + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.SourceMessage = { + header: 'Help the STIG Manager OSS project by sharing', + text: 'The Save for Sharing option can create a file without identifiers or compliance data. Mail to RMF_Tools@us.navy.mil' +} + +SM.AppInfo.SourcePanel = Ext.extend(Ext.Panel, { + initComponent: function () { + const sourceDisplayField = new Ext.form.DisplayField({ + fieldLabel: 'Source', + width: 330 + }) + const dateDisplayField = new Ext.form.DisplayField({ + fieldLabel: 'Date', + width: 200 + }) + const versionDisplayField = new Ext.form.DisplayField({ + fieldLabel: 'Version', + width: 200 + }) + + const fieldContainer = new Ext.Container({ + layout: 'form', + items: [ + sourceDisplayField, + dateDisplayField, + versionDisplayField + ] + }) + + function loadData({ data, source }) { + sourceDisplayField.setValue(source) + dateDisplayField.setValue(data.dateGenerated ?? data.date) + versionDisplayField.setValue(data.stigmanVersion ?? data.version) + } + + const selectFileBtn = new Ext.ux.form.FileUploadField({ + buttonOnly: true, + accept: '.json', + webkitdirectory: false, + multiple: false, + style: 'width: 95px;', + buttonText: `Load from file...`, + buttonCfg: { + icon: "img/upload.svg" + }, + listeners: { + fileselected: this.onFileSelected || Ext.emptyFn + } + }) + + const saveSharedPanel = new SM.AppInfo.ShareFile.Panel({ + onSaveShared: this.onSaveShared + }) + + const saveSharedMenu = new Ext.menu.Menu({ + plain: true, + style: 'padding: 10px;', + items: saveSharedPanel + }) + saveSharedPanel.menu = saveSharedMenu + + const tbar = new Ext.Toolbar({ + items: [ + selectFileBtn, + '-', + { + text: 'Save to file', + iconCls: 'sm-export-icon', + handler: this.onSaveFull || Ext.emptyFn + }, + '-', + { + text: 'Save for sharing', + iconCls: 'sm-share-icon', + menu: saveSharedMenu + }, + '-', + { + text: 'Fetch from API', + iconCls: 'icon-refresh', + handler: this.onFetchFromApi || Ext.emptyFn + }, + + ] + }) + + const config = { + layout: 'hbox', + padding: '10px 10px 10px 10px', + items: [ + fieldContainer, + { + xtype: 'container', + tpl: new Ext.XTemplate( + `
`, + `
{header}
`, + `
{text}
`, + `
` + ), + data: SM.AppInfo.SourceMessage + } + ], + tbar, + loadData + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.AppInfo.fetchFromApi = async function () { + return Ext.Ajax.requestPromise({ + responseType: 'json', + url: `${STIGMAN.Env.apiBase}/op/appinfo`, + params: { + elevate: curUser.privileges.canAdmin + }, + method: 'GET' + }) +} + +SM.AppInfo.generateSharable = function (data, options) { + const kloned = SM.Klona(data) + const { collections, requests, users, nodejs } = kloned + if (options.collectionName) { + const padLength = Object.keys(collections).at(-1).length + for (const id in collections) { + collections[id].name = id.padStart(padLength, '0') + } + } + if (options.username) { + const padLength = Object.keys(users.userInfo).at(-1).length + for (const id in users.userInfo) { + users.userInfo[id].username = id.padStart(padLength, '0') + } + } + if (options.clientId) { + obfuscateClients(requests.operationIds) + } + if (options.envvar) { + delete nodejs.environment + } + return kloned + + function obfuscateClients(operationIds) { + const obfuscationMap = { + [STIGMAN.Env.oauth.clientId]: 'webapp' + } + let obfuscatedCounter = 1 + + function getObfuscatedKey(client) { + if (client === 'unknown' || client === 'webapp') { + return client + } + if (!obfuscationMap[client]) { + obfuscationMap[client] = `client${obfuscatedCounter++}` + } + return obfuscationMap[client] + } + + for (const id in operationIds) { + if (operationIds[id].clients) { + const clients = operationIds[id].clients + const newClients = {} + for (const client in clients) { + const obfuscatedName = getObfuscatedKey(client) + newClients[obfuscatedName] = clients[client] + } + operationIds[id].clients = newClients + } + } + } + +} + +SM.AppInfo.showAppInfoTab = async function (options) { + const { treePath } = options + const tab = Ext.getCmp('main-tab-panel').getItem(`appinfo-tab`) + if (tab) { + Ext.getCmp('main-tab-panel').setActiveTab(tab.id) + return + } + + let data = '' + + async function onFileSelected(uploadField) { + try { + thisTab.getEl().mask('Loading from file...') + let input = uploadField.fileInput.dom + const text = await input.files[0].text() + data = SM.AppInfo.transformPreviousSchemas(SM.safeJSONParse(text)) + if (data) { + sourcePanel.loadData({ data, source: input.files[0].name }) + tabPanel.loadData(data) + } + else { + Ext.Msg.alert('Unrecognized data', 'The file contents could not be understood as Application information.') + } + } + catch (e) { + SM.Error.handleError(e) + } + finally { + uploadField.reset() + thisTab.getEl()?.unmask() + } + } + + async function onFetchFromApi() { + try { + thisTab.getEl().mask('Fetching from API...') + data = await SM.AppInfo.fetchFromApi() + sourcePanel.loadData({ data, source: 'API' }) + tabPanel.loadData(data) + } + finally { + thisTab.getEl()?.unmask() + } + } + + function onSaveFull() { + if (data) { + const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }) + downloadBlob(blob, SM.Global.filenameEscaped(`stig-manager-appinfo_${SM.Global.filenameComponentFromDate()}.json`)) + } + } + + function onSaveShared(options) { + console.log(options) + const kloned = SM.AppInfo.generateSharable(data, options) + console.log(kloned) + const blob = new Blob([JSON.stringify(kloned)], { type: 'application/json' }) + downloadBlob(blob, SM.Global.filenameEscaped(`stig-manager-appinfo-shareable_${SM.Global.filenameComponentFromDate()}.json`)) + } + + function downloadBlob(blob, filename) { + let a = document.createElement('a') + a.style.display = "none" + let url = window.URL.createObjectURL(blob) + a.href = url + a.download = filename + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url) + } + + const sourcePanel = new SM.AppInfo.SourcePanel({ + cls: 'sm-round-panel', + margins: { top: SM.Margin.top, right: SM.Margin.edge, bottom: SM.Margin.adjacent, left: SM.Margin.edge }, + title: 'Source', + region: 'north', + border: false, + height: 145, + onFileSelected, + onFetchFromApi, + onSaveFull, + onSaveShared + }) + + + const tabPanel = new SM.AppInfo.TabPanel({ + cls: 'sm-round-panel', + margins: { top: SM.Margin.adjacent, right: SM.Margin.edge, bottom: SM.Margin.bottom, left: SM.Margin.edge }, + region: 'center', + border: false, + activeTab: 0, + listeners: { + tabchange: function () { + console.log('tabPanel event') + } + }, + flex: 1 + + }) + + const thisTab = Ext.getCmp('main-tab-panel').add({ + id: 'appinfo-tab', + sm_treePath: treePath, + iconCls: 'sm-info-circle-icon', + bodyStyle: "background-color:transparent;", + title: 'Application Info', + closable: true, + layout: 'vbox', + layoutConfig: { + align: 'stretch' + }, + border: false, + items: [sourcePanel, tabPanel] + }) + thisTab.show() + + await onFetchFromApi() +} \ No newline at end of file diff --git a/client/src/js/SM/ColumnFilters.js b/client/src/js/SM/ColumnFilters.js index 48bbc57e1..61805d9dc 100644 --- a/client/src/js/SM/ColumnFilters.js +++ b/client/src/js/SM/ColumnFilters.js @@ -1,20 +1,21 @@ Ext.ns('SM.ColumnFilters') -SM.ColumnFilters.extend = function extend (extended = Ext.grid.GridView) { +SM.ColumnFilters.extend = function extend (extended, ex) { return Ext.extend(extended, { constructor: function (config) { // Ext.apply(this, config); + this.extends = ex this.addEvents( 'filterschanged', 'columnfiltered', 'columnunfiltered' ) - SM.ColumnFilters.GridView.superclass.constructor.call(this, config); + SM.ColumnFilters[ex].superclass.constructor.call(this, config); }, handleHdDown: function (e, target) { // Modifies superclass method to support lastHide - if (target.className == 'x-grid3-hd-checker') { + if (target.classList[0] !== 'x-grid3-hd-inner') { return } e.stopEvent() @@ -22,7 +23,7 @@ SM.ColumnFilters.extend = function extend (extended = Ext.grid.GridView) { var colModel = this.cm, header = this.findHeaderCell(target), index = this.getCellIndex(header), - sortable = colModel.isSortable(index), + sortable = colModel?.isSortable(index), menu = this.hmenu, menuItems = menu.items, menuCls = this.headerMenuOpenCls, @@ -153,7 +154,7 @@ SM.ColumnFilters.extend = function extend (extended = Ext.grid.GridView) { const _this = this const dynamicColumns = [] - SM.ColumnFilters.GridView.superclass.afterRenderUI.call(this) + SM.ColumnFilters[this.extends].superclass.afterRenderUI.call(this) const hmenu = this.hmenu hmenu.filterItems = { @@ -314,8 +315,9 @@ SM.ColumnFilters.extend = function extend (extended = Ext.grid.GridView) { }) } -SM.ColumnFilters.GridView = SM.ColumnFilters.extend(Ext.grid.GridView) -SM.ColumnFilters.GridViewBuffered = SM.ColumnFilters.extend(Ext.ux.grid.BufferView) +SM.ColumnFilters.GridView = SM.ColumnFilters.extend(Ext.grid.GridView, 'GridView') +SM.ColumnFilters.GridViewBuffered = SM.ColumnFilters.extend(Ext.ux.grid.BufferView, 'GridViewBuffered') +SM.ColumnFilters.GridViewLocking = SM.ColumnFilters.extend(Ext.ux.grid.LockingGridView, 'GridViewLocking') SM.ColumnFilters.StringMatchTextField = Ext.extend(Ext.form.TextField, { initComponent: function () { diff --git a/client/src/js/SM/Global.js b/client/src/js/SM/Global.js index 315add57a..079b37772 100644 --- a/client/src/js/SM/Global.js +++ b/client/src/js/SM/Global.js @@ -567,3 +567,35 @@ SM.Global.filenameEscaped = function (value) { .substring(0, 255) } + SM.Klona = function klona(val) { + // MIT License + // Copyright (c) Luke Edwards (lukeed.com) + // https://github.com/lukeed/klona + + let k, out, tmp + + if (Array.isArray(val)) { + out = Array(k=val.length) + while (k--) out[k] = (tmp=val[k]) && typeof tmp === 'object' ? klona(tmp) : tmp + return out + } + + if (Object.prototype.toString.call(val) === '[object Object]') { + out = {} // null + for (k in val) { + if (k === '__proto__') { + Object.defineProperty(out, k, { + value: klona(val[k]), + configurable: true, + enumerable: true, + writable: true, + }) + } else { + out[k] = (tmp=val[k]) && typeof tmp === 'object' ? klona(tmp) : tmp + } + } + return out + } + + return val + } diff --git a/client/src/js/SM/NavTree.js b/client/src/js/SM/NavTree.js index 5bf342c96..f39e089be 100644 --- a/client/src/js/SM/NavTree.js +++ b/client/src/js/SM/NavTree.js @@ -229,11 +229,44 @@ SM.NavTree.TreePanel = Ext.extend(Ext.tree.TreePanel, { }, loadTree: async function (node, cb) { try { - let match, collectionGrant // Root node if (node == 'stigman-root') { let content = [] if (curUser.privileges.canAdmin) { + const children = [ + { + id: 'collection-admin', + text: 'Collections', + leaf: true, + iconCls: 'sm-collection-icon' + }, + { + id: 'user-admin', + text: 'User Grants', + leaf: true, + iconCls: 'sm-users-icon' + }, + { + id: 'stig-admin', + text: 'STIG Benchmarks', + leaf: true, + iconCls: 'sm-stig-icon' + }, + { + id: 'appinfo-admin', + text: 'Application Info', + leaf: true, + iconCls: 'sm-info-circle-icon' + } + ] + if (STIGMAN.Env.experimental.appData === 'true') { + children.push({ + id: 'appdata-admin', + text: 'Export/Import Data experimental', + leaf: true, + iconCls: 'sm-database-save-icon' + }) + } content.push( { id: `admin-root`, @@ -241,32 +274,7 @@ SM.NavTree.TreePanel = Ext.extend(Ext.tree.TreePanel, { text: 'Application Management', iconCls: 'sm-setting-icon', expanded: false, - children: [ - { - id: 'collection-admin', - text: 'Collections', - leaf: true, - iconCls: 'sm-collection-icon' - }, - { - id: 'user-admin', - text: 'User Grants', - leaf: true, - iconCls: 'sm-users-icon' - }, - { - id: 'stig-admin', - text: 'STIG Benchmarks', - leaf: true, - iconCls: 'sm-stig-icon' - }, - { - id: 'appdata-admin', - text: 'Application Info', - leaf: true, - iconCls: 'sm-database-save-icon' - } - ] + children } ) } @@ -473,8 +481,11 @@ SM.NavTree.TreePanel = Ext.extend(Ext.tree.TreePanel, { case 'stig-admin': addStigAdmin( { treePath: n.getPath() }) break + case 'appinfo-admin': + SM.AppInfo.showAppInfoTab({treePath: n.getPath()}) + break case 'appdata-admin': - addAppDataAdmin( { treePath: n.getPath() }) + SM.AppData.showAppDataTab({treePath: n.getPath()}) break case 'whats-new': SM.WhatsNew.addTab( { treePath: n.getPath() }) diff --git a/client/src/js/SM/WhatsNew.js b/client/src/js/SM/WhatsNew.js index d28427ec3..81755b987 100644 --- a/client/src/js/SM/WhatsNew.js +++ b/client/src/js/SM/WhatsNew.js @@ -1,6 +1,24 @@ Ext.ns('SM.WhatsNew') SM.WhatsNew.Sources = [ + { + date: '2024-10-09', + header: `New Application Information Report for Application Managers`, + body: ` +

Application Managers can now view detailed information about the application from the Application Management tree node. This feature expands on and replaces the "Anonymized Deployment Details" feature.

+

To provide insights useful to the local deployment, the information is not anonymized by default. However, the data can be saved with all identifiers removed for sharing with the STIG Manager OSS Project Team. The STIGMan team encourages you to contribute your report, which will be used to recreate production-like scenarios that help us target new features and improve overall performance of the application.

+

The report can be submitted to:

+ RMF_Tools@us.navy.mil +

Thank you for your help!

+ +

To access the new report, click on the "Application Information" node in the Application Management tree. Click the "Save for sharing" button to download the report and send to the team:

+ +

+ +

NOTE: The "Experimental" Export/Import Data feature that used to share the "App Info" tab was unable to reliably scale with the current size of production deployments. As it was intended mainly for use with testing and demo data sets, it must now be enabled specifically with a deployment configuration option. See the documentation for more details.

+ + ` + }, { date: '2024-03-17', header: `Bulk Checklist Imports Now Available to All Users`, @@ -11,7 +29,6 @@ SM.WhatsNew.Sources = [

` }, - { date: '2024-03-01', header: `Review Age Info Now Available In All Review Grids`, @@ -37,8 +54,6 @@ SM.WhatsNew.Sources = [ ` }, - - { date: '2024-01-17', header: `New Meta Dashboard`, @@ -55,7 +70,6 @@ SM.WhatsNew.Sources = [

` }, - { date: '2023-10-31', header: `New Interfaces for Managing Asset Labels and STIG Assignments`, @@ -124,7 +138,6 @@ SM.WhatsNew.Sources = [

` }, - { date: '2023-06-20', header: `Set the Default STIG Revision for a Collection`, @@ -140,7 +153,6 @@ SM.WhatsNew.Sources = [

` }, - { date: '2023-05-20', header: `Tally Sprites for Most Display Grids`, @@ -167,7 +179,6 @@ SM.WhatsNew.Sources = [

There are a very small number of exceptions to this behavior, please see the STIG Manager Documentation for more details.

` }, - { date: '2023-01-11', header: `New Collection Dashboard!`, @@ -211,7 +222,6 @@ SM.WhatsNew.Sources = [

Please see the STIG Manager Documentation for more details about this new feature!

` }, - { date: '2023-01-10', header: `New STIG Revision Compare Tool!`, @@ -223,7 +233,6 @@ SM.WhatsNew.Sources = [

` }, - { date: '2022-10-12', header: `New Metrics Report Replaces Status Report`, diff --git a/client/src/js/appDataAdmin.js b/client/src/js/appDataAdmin.js deleted file mode 100644 index 432c1ff4d..000000000 --- a/client/src/js/appDataAdmin.js +++ /dev/null @@ -1,272 +0,0 @@ -async function addAppDataAdmin( params ) { - let detailBodyWrapEl - try { - let { treePath } = params - const tab = Ext.getCmp('main-tab-panel').getItem('appdata-admin-tab') - if (tab) { - tab.show() - return - } - - const detailJson = new Ext.Panel({ - region: 'center', - title: 'Anonymized Deployment Details', - cls: 'sm-round-panel', - margins: { top: SM.Margin.top, right: SM.Margin.edge, bottom: SM.Margin.adjacent, left: SM.Margin.edge }, - autoScroll: true, - buttonAlign: 'left', - bbar: [ - { - text: 'JSON', - iconCls: 'sm-export-icon', - handler: function (btn) { - if (detailResponseText) { - const blob = new Blob([detailResponseText], {type: 'application/json'}) - downloadBlob(blob, SM.Global.filenameEscaped(`stig-manager-details_${SM.Global.filenameComponentFromDate()}.json`)) - } - } - } - ] - }) - - const appDataPanel = new Ext.Panel({ - region: 'north', - title: 'Application Data experimental', - margins: { top: SM.Margin.adjacent, right: SM.Margin.edge, bottom: SM.Margin.bottom, left: SM.Margin.edge }, - cls: 'sm-round-panel', - height: 200, - labelWidth: 1, - hideLabel: true, - // width: 500, - padding: 10, - defaults: { - anchor: '100%', - allowBlank: false - }, - items: [ - { - xtype: 'fieldset', - width: 200, - title: 'Export', - items: [ - { - xtype: 'button', - id: 'appdata-admin-form-export-btn', - text: 'Download Application Data ', - iconCls: 'sm-export-icon', - handler: async function () { - try { - Ext.getBody().mask('Waiting for JSON...'); - let appdata = await getAppdata() - downloadBlob(appdata.blob, appdata.filename) - } - catch (e) { - SM.Error.handleError(e) - } - finally { - Ext.getBody().unmask(); - } - } - } - ] - }, - { - xtype: 'fieldset', - title: 'Import', - width: 200, - items: [ - { - xtype: 'button', - id: 'appdata-admin-form-import-btn', - text: 'Replace Application Data... ', - iconCls: 'sm-import-icon', - handler: handleImport - } - ] - } - ] - }) - - function handleImport (item, event) { - let fp = new Ext.FormPanel({ - standardSubmit: false, - fileUpload: true, - baseCls: 'x-plain', - monitorValid: true, - autoHeight: true, - labelWidth: 1, - hideLabel: true, - defaults: { - anchor: '100%', - allowBlank: false - }, - items: [ - { - xtype:'fieldset', - title: 'Instructions', - autoHeight:true, - items: [ - { - xtype: 'displayfield', - id: 'infoText1', - name: 'infoText', - html: "Please browse for an Application Data file", - }] - }, - { - xtype: 'fileuploadfield', - id: 'form-file', - emptyText: 'Browse for a file...', - name: 'importFile', - accept: '.json,.zip', - buttonText: 'Browse...', - buttonCfg: { - icon: "img/disc_drive.png" - } - }, - { - xtype: 'displayfield', - id: 'infoText2', - name: 'infoText', - html: "IMPORTANT: Content from the imported file will replace ALL existing application data!", - } - ], - buttonAlign: 'center', - buttons: [ - { - text: 'Import', - icon: 'img/page_white_get.png', - tooltip: 'Import the data', - formBind: true, - handler: async function(){ - try { - let input = document.getElementById("form-file-file") - let file = input.files[0] - let formEl = fp.getForm().getEl().dom - let formData = new FormData(formEl) - // appwindow.close(); - initProgress("Importing file", "Waiting for response..."); - updateStatusText ('Waiting for API response...') - await window.oidcProvider.updateToken(10) - let response = await fetch(`${STIGMAN.Env.apiBase}/op/appdata?elevate=true`, { - method: 'POST', - headers: new Headers({ - 'Authorization': `Bearer ${window.oidcProvider.token}` - }), - body: formData - }) - const reader = response.body.getReader() - const td = new TextDecoder("utf-8") - let isdone = false - do { - const {value, done} = await reader.read() - updateStatusText (td.decode(value),true) - isdone = done - } while (!isdone) - updateProgress(0, 'Done') - } - catch (e) { - SM.Error.handleError(e) - } - } - }, - { - text: 'Cancel', - handler: function(){appwindow.close();} - } - ] - }); - - var appwindow = new Ext.Window({ - title: 'Initialize Application Data', - cls: 'sm-dialog-window sm-round-panel', - modal: true, - width: 500, - layout: 'fit', - plain:true, - bodyStyle:'padding:5px;', - buttonAlign:'center', - items: fp - }); - - appwindow.show(document.body); - } - - async function getAppdata () { - try { - let url = `${STIGMAN.Env.apiBase}/op/appdata?elevate=true` - await window.oidcProvider.updateToken(10) - let response = await fetch( - url, - { - method: 'GET', - headers: new Headers({ - 'Authorization': `Bearer ${window.oidcProvider.token}` - }) - } - ) - const contentDispo = response.headers.get("content-disposition") - //https://stackoverflow.com/questions/23054475/javascript-regex-for-extracting-filename-from-content-disposition-header/39800436 - const filename = contentDispo.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']*)['"]?;?/)[1] - const blob = await response.blob() - return ({blob, filename}) - } - catch (e) { - SM.Error.handleError(e) - } - } - - async function getDetail () { - return Ext.Ajax.requestPromise({ - url: `${STIGMAN.Env.apiBase}/op/details`, - params: { - elevate: curUser.privileges.canAdmin - }, - method: 'GET' - }) - } - - function downloadBlob (blob, filename) { - let a = document.createElement('a') - a.style.display = "none" - let url = window.URL.createObjectURL(blob) - a.href = url - a.download = filename - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url) - } - - - const thisTab = Ext.getCmp('main-tab-panel').add({ - id: 'appdata-admin-tab', - sm_treePath: treePath, - iconCls: 'sm-database-save-icon', - title: 'Application Info', - closable: true, - layout: 'border', - border: false, - items: [detailJson, appDataPanel] - }) - thisTab.show() - - detailBodyWrapEl = detailJson.getEl().child('.x-panel-bwrap') - detailBodyWrapEl.mask('Getting data...') - const detailResponseText = (await getDetail()).response.responseText //used by downloadBlob - const detailTree = JsonView.createTree(JSON.parse(detailResponseText)) - // adjust for rendering - // the parent and first child (dbInfo) are expanded - detailTree.key = `GET ${STIGMAN.Env.apiBase}/op/details?elevate=true` - detailTree.isExpanded = true - detailTree.children[0].isExpanded = true - - JsonView.render(detailTree, detailJson.body) - } - catch (e) { - SM.Error.handleError(e) - } - finally { - detailBodyWrapEl.unmask() - } -} \ No newline at end of file diff --git a/client/src/js/collectionAdmin.js b/client/src/js/collectionAdmin.js index 17f5e6212..fbef84210 100644 --- a/client/src/js/collectionAdmin.js +++ b/client/src/js/collectionAdmin.js @@ -75,7 +75,7 @@ function addCollectionAdmin( params ) { const collectionGrid = new Ext.grid.GridPanel({ cls: 'sm-round-panel', - margins: { top: SM.Margin.top, right: SM.Margin.edge, bottom: SM.Margin.bottom, left: SM.Margin.edge }, + // margins: { top: SM.Margin.top, right: SM.Margin.edge, bottom: SM.Margin.bottom, left: SM.Margin.edge }, region: 'center', id: 'collectionGrid', store: store, @@ -276,7 +276,7 @@ function addCollectionAdmin( params ) { iconCls: 'sm-collection-icon', title: 'Collections', closable: true, - layout: 'border', + layout: 'fit', border: false, items: [collectionGrid], listeners: { diff --git a/client/src/js/overrides.js b/client/src/js/overrides.js index f64e6189a..ef5f41ae2 100644 --- a/client/src/js/overrides.js +++ b/client/src/js/overrides.js @@ -955,6 +955,46 @@ Ext.override(Ext.grid.GridView,{ row.innerHTML = this.templates.rowInner.apply(rowParams); this.fireEvent('rowupdated', this, rowIndex, record); + }, + renderHeaders : function() { + let colModel = this.cm, + templates = this.templates, + headerTpl = templates.hcell, + properties = {}, + colCount = colModel.getColumnCount(), + last = colCount - 1, + cells = [], + i, cssCls; + + for (i = 0; i < colCount; i++) { + if (i == 0) { + cssCls = 'x-grid3-cell-first '; + } else { + cssCls = i == last ? 'x-grid3-cell-last ' : ''; + } + + properties = { + id : colModel.getColumnId(i), + value : colModel.getColumnHeader(i) || '', + style : this.getColumnStyle(i, true), + css : cssCls, + tooltip: this.getColumnTooltip(i) + }; + + if (colModel.config[i].align == 'right') { + // changed from framework default of 16px + properties.istyle = 'padding-right: 4px;'; + } else { + delete properties.istyle; + } + + cells[i] = headerTpl.apply(properties); + } + + return templates.header.apply({ + cells : cells.join(""), + tstyle: String.format("width: {0};", this.getTotalWidth()) + }); } }) diff --git a/client/src/js/resources-dist.js b/client/src/js/resources-dist.js index e1fdf94aa..b2f9653cd 100644 --- a/client/src/js/resources-dist.js +++ b/client/src/js/resources-dist.js @@ -7,7 +7,8 @@ const stylesheets = [ 'css/RowEditor.css', 'css/jsonview.bundle.css', 'css/diff2html.min.css', - 'css/dark-mode.css' + 'css/dark-mode.css', + 'ext/ux/css/LockingGridView.css' ] const scripts = [ diff --git a/client/src/js/resources.js b/client/src/js/resources.js index 8e2618bca..169835e98 100644 --- a/client/src/js/resources.js +++ b/client/src/js/resources.js @@ -7,7 +7,8 @@ const stylesheets = [ 'css/RowEditor.css', 'css/jsonview.bundle.css', 'css/diff2html.min.css', - 'css/dark-mode.css' + 'css/dark-mode.css', + 'ext/ux/css/LockingGridView.css' ] const scripts = [ @@ -47,6 +48,7 @@ const scripts = [ 'js/SM/CollectionGrant.js', 'js/SM/CollectionPanel.js', 'js/SM/MetaPanel.js', + 'js/LockingGridView.js', 'js/SM/ColumnFilters.js', 'js/SM/FindingsPanel.js', 'js/SM/Assignments.js', @@ -59,12 +61,13 @@ const scripts = [ 'js/SM/StigRevision.js', 'js/SM/Inventory.js', 'js/SM/AssetSelection.js', + 'js/SM/AppInfo.js', + 'js/SM/AppData.js', 'js/library.js', 'js/userAdmin.js', 'js/collectionAdmin.js', 'js/collectionManager.js', 'js/stigAdmin.js', - 'js/appDataAdmin.js', 'js/completionStatus.js', 'js/findingsSummary.js', 'js/review.js', diff --git a/docs/admin-guide/admin-guide.rst b/docs/admin-guide/admin-guide.rst index c78abae89..46c18a4d5 100644 --- a/docs/admin-guide/admin-guide.rst +++ b/docs/admin-guide/admin-guide.rst @@ -95,17 +95,55 @@ Use the buttons at the top to add new STIGS, delete entire STIGs or specific rev + ------------------------- -.. _app-data: +.. _app-info: Application Info Panel ------------------------------------ -This panel allows App Managers to download a representation of all data STIGMan manages, minus the actual DISA Reference STIGs themselves. -This same data can also be imported, but be aware that if data is moved to a different STIGMan instance, the destination instance must have all STIGs that were assigned to any Assets from the originating instance. + + +This panel provides App Managers with a report on the current state, performance, and utilization of the STIGMan application. + +The toolbar allows users to load and save report data files, as well as fetch a new report from the API. The "Save for sharing" button will download a .json file of the current report data with the option to replace specific deployment data such as Collection and User names with generated identifiers. + +The report displays the data source, date, and STIG Manager version at the top. Report data is displayed in the following tabs: + + - **Requests**: Information regarding the requests made to each API endpoint, organized by operationId. This data includes the count of requests, max duration, average duration, response length, error counts, and other useful metrics. Endpoints with ``projection`` parameters will populate the "Projections" panel with a subset of these metrics. This report also indicates users and clients that made the requests, as well as counts of any error code responses. + - **Collections**: High level metrics about the size and state of all Collections, including "disabled" Collections and Assets, total Reviews, grants, etc. This report offers additional tabs reporting Grants, Labels, STIG Assignments, and Settings by Collection. The "Access Control Lists" panel lists users and applicable access control rules for users with limited access to the Collection, such as those with Restricted-type grants. + - **Users**: A report of all users of the system, their privileges, grants, and last active date. This report also includes panels summarizing overall user counts by privilege, and by last activity date (last 30/90 days). + - **MySQL**: Information about the managed data, configuration, and status of the MySQL database. + - **NodeJs**: Information about the configuration of the STIGMan application, as well as status of the NodeJs server, including the version, uptime, and memory usage. + - **JSON Tree** : A tree view of the data that is available in the report. Equivalent to the contents of the .json file that can be downloaded with the "Save" button. + + +.. note:: + Help the STIG Manager team improve the application by sharing this report if you encounter issues or have suggestions for improvement. You can email the report to the team at RMF_Tools@us.navy.mil + + +.. thumbnail:: /assets/images/admin-app-info.png + :width: 50% + :show_caption: True + :title: Application Info Report + + +| + + +------------------------- + +.. _app-data: + +Export/Import Data Panel +------------------------------------ + +This panel allows App Managers to download a representation of most of the data STIGMan manages, minus the actual DISA Reference STIGs themselves. This same data can also be imported, but be aware that ALL existing data in that STIGMan instance will be lost. If this data is imported into a different STIGMan instance, the destination instance must have all STIGs that were assigned to any Assets from the originating instance. + +This feature must be enabled for the deployment by setting the ``STIGMAN_EXPERIMENTAL_APPDATA`` environment variable to ``true``. .. warning:: - This feature is considered Experimental! Use at your own risk, and rely on daily database backups to maintain your data! + This feature is considered Experimental! Use at your own risk, and rely on daily database backups to maintain your data! ALL data in the destination instance will be replaced! .. thumbnail:: /assets/images/admin-app-data.png :width: 50% diff --git a/docs/admin-guide/admin-quickstart.rst b/docs/admin-guide/admin-quickstart.rst index 46d0b460d..2a878a6f4 100644 --- a/docs/admin-guide/admin-quickstart.rst +++ b/docs/admin-guide/admin-quickstart.rst @@ -173,16 +173,6 @@ The only changes that can be made to Users in the STIG Manager interface is thei ------------------------------- -Application Data -=========================== - -This Tab has buttons that allow you to Import and Export all User and Collection data from STIG Manager. These options are considered experimental and should not be relied upon to move or preserve Production data or other data you cannot afford to lose. On import, the imported data completely replaces all STIG Manager data currently on the system. Compatibility with future versions of STIG Manager is not guaranteed. They are currently used only for Development purposes. All that said, we are considering developing a method for handling the importation of STIG Manager Collection objects and their associated Assets, STIGs, Reviews, History, and Users. - - - - - - .. _automated-imports: Configure a Source of Automated Evaluations diff --git a/docs/assets/images/admin-app-data.png b/docs/assets/images/admin-app-data.png index fa6cb9e7c..e00004d27 100644 Binary files a/docs/assets/images/admin-app-data.png and b/docs/assets/images/admin-app-data.png differ diff --git a/docs/assets/images/admin-app-info.png b/docs/assets/images/admin-app-info.png new file mode 100644 index 000000000..36bf26312 Binary files /dev/null and b/docs/assets/images/admin-app-info.png differ diff --git a/docs/installation-and-setup/envvars.csv b/docs/installation-and-setup/envvars.csv index ae8421063..7964bc6aa 100644 --- a/docs/installation-and-setup/envvars.csv +++ b/docs/installation-and-setup/envvars.csv @@ -55,6 +55,8 @@ | The location of the documentation files, relative to the API source directory. Note that if running source from a clone of the GitHub repository, the docs are located at `../../docs/_build/html` relative to the API directory. ","API, documentation" "STIGMAN_DOCS_DISABLED","| **Default** ``false`` | Whether to *not* serve the project Documentation. NOTE: If you choose to serve the Client from the API container but not the Documentation, the links do the Docs on the home page will not work. ","Documentation " +"STIGMAN_EXPERIMENTAL_APPDATA","| **Default** ``false`` +| Set to ``true`` to enable the experimental AppData import/export API endpoints and User Interface. ","API, Client" "STIGMAN_LOG_LEVEL","| **Default** ``3`` | Controls the granularity of the generated log output, from 1 to 4. Each level is inclusive of the ones before it. Level 1 will log only errors, level 2 includes warnings, level 3 includes status and transaction logs, and level 4 includes debug-level logs","API" "STIGMAN_LOG_MODE","| **Default** ``combined`` diff --git a/test/api/dark.css b/test/api/dark.css new file mode 100644 index 000000000..2d0489ef8 --- /dev/null +++ b/test/api/dark.css @@ -0,0 +1,4110 @@ +/*! mochawesome-report-generator 6.2.0 | https://github.com/adamgruber/mochawesome-report-generator */ +:root { + --screen-sm-min: 768px; + --screen-md-min: 992px; + --screen-lg-min: 1200px; + --grid-gutter-width: 30px; + --container-sm: calc(720px + var(--grid-gutter-width)); + --container-md: calc(940px + var(--grid-gutter-width)); + --container-lg: calc(1140px + var(--grid-gutter-width)); + --navbar-height: 122px; + --navbar-height-short: 56px; + --summary-height-stacked: 82px; + --statusbar-height-stacked: 54px; + --footer-height: 60px; + --default-transition-duration: 0.2s; + --default-transition-easing: ease; + --gray-base: #000; + --gray-darker-faded: color(var(--gray-darker) alpha(95%)); + --gray-darker: color(var(--gray-base) tint(13.5%)); + --gray-dark: color(var(--gray-base) tint(20%)); + --gray: color(var(--gray-base) tint(33.5%)); + --gray-light: color(var(--gray-base) tint(46.7%)); + --gray-medium: color(var(--gray-base) tint(73.5%)); + --gray-lighter: color(var(--gray-base) tint(93.5%)); + --gray-lighter-faded: color(var(--gray-lighter) alpha(95%)); + --gray-border: color(var(--gray-base) tint(80%)); + --grey50: #eceff1; + --grey100: #f5f5f5; + --grey300: #e0e0e0; + --grey500: #9e9e9e; + --grey700: #616161; + --green100: #c8e6c9; + --green200: #a5d6a7; + --green300: #81c784; + --green500: #4caf50; + --green700: #388e3c; + --red100: #ffcdd2; + --red300: #e57373; + --red500: #f44336; + --red700: #d32f2f; + --ltblue100: #b3e5fc; + --ltblue300: #4fc3f7; + --ltblue500: #03a9f4; + --ltblue700: #0288d1; + --black87: rgba(0,0,0,0.87); + --black54: rgba(0,0,0,0.54); + --black38: rgba(0,0,0,0.38); + --bluegrey500: #607d8b; + --bluegrey800: #37474f; + --bluegrey900: #263238; + --light-icon-active: #fff; + --light-icon-inactive: hsla(0,0%,100%,0.5); + --dark-icon-active: var(--black54); + --dark-icon-inactive: var(--black38); + --amber300: #ffd54f; + --amber400: #ffca28; + --amber500: #ffc107; + --yellow700: #fbc02d; + --yellow800: #f9a825; + --brand-primary: color(#428bca shade(6.5%)); + --brand-success: #4caf50; + --brand-info: #5bc0de; + --brand-warning: #f0ad4e; + --brand-danger: #d9534f; + --text-color: var(--black87); + --body-bg: #f2f2f2; + --link-color: var(--brand-primary); + --link-hover-color: color(var(--link-color) shade(15%)); + --list-group-border: #ddd; + --font-family-sans-serif: "robotoregular","Helvetica Neue",Helvetica,Arial,sans-serif; + --font-family-base: var(--font-family-sans-serif); + --font-family-mono: "Menlo","Monaco","Consolas","Courier New",monospace; + --font-size-base: 14px; + --line-height-base: 1.429; + --line-height-computed: 20px; + --headings-font-family: inherit; + --headings-font-weight: 400; + --headings-line-height: 1.1; + --headings-color: inherit; + --headings-small-color: var(--gray-light); + --font-size-h1: 36px; + --font-size-h2: 30px; + --font-size-h3: 24px; + --font-size-h4: 18px; + --font-size-h5: var(--font-size-base); + --font-size-h6: 12px; + --font-family-light: "robotolight"; + --font-family-regular: "robotoregular"; + --font-family-medium: "robotomedium"; + --link-transition: color 0.2s ease-out +} + +.dropdown--trans-color---3ixtY { + transition: color .2s ease-out; + transition: var(--link-transition) +} + +.dropdown--component---21Q9c { + position: relative +} + +.dropdown--toggle---3gdzr { + white-space: nowrap +} + +.dropdown--toggle-icon---1j9Ga:not(.dropdown--icon-only---3vq2I) { + margin-left: .5rem +} + +.dropdown--list---8GPrA { + padding: 0; + margin: 0; + list-style: none; + text-align: left +} + +.dropdown--list-main---3QZnQ { + position: absolute; + top: 100%; + z-index: 1000; + visibility: hidden; + min-width: 160px; + overflow: auto +} + +.dropdown--align-left---3-3Hu { + left: 0 +} + +.dropdown--align-right---2ZQx0 { + right: 0 +} + +.dropdown--list-item-link---JRrOY,.dropdown--list-item-text---2COKZ { + display: block; + position: relative; + white-space: nowrap; + text-decoration: none +} + +.dropdown--list-item-text---2COKZ { + cursor: default +} + +@-webkit-keyframes dropdown--in---FpwEb { + 0% { + opacity: 0 + } + + to { + opacity: 1 + } +} + +@keyframes dropdown--in---FpwEb { + 0% { + opacity: 0 + } + + to { + opacity: 1 + } +} + +@-webkit-keyframes dropdown--out---2HVe1 { + 0% { + opacity: 1; + visibility: visible + } + + to { + opacity: 0 + } +} + +@keyframes dropdown--out---2HVe1 { + 0% { + opacity: 1; + visibility: visible + } + + to { + opacity: 0 + } +} + +.dropdown--close---2LnDu { + -webkit-animation: dropdown--out---2HVe1 .2s ease; + animation: dropdown--out---2HVe1 .2s ease; + -webkit-animation: dropdown--out---2HVe1 var(--default-transition-duration) var(--default-transition-easing); + animation: dropdown--out---2HVe1 var(--default-transition-duration) var(--default-transition-easing); + visibility: hidden +} + +.dropdown--open---3bwiy { + -webkit-animation: dropdown--in---FpwEb .2s ease; + animation: dropdown--in---FpwEb .2s ease; + -webkit-animation: dropdown--in---FpwEb var(--default-transition-duration) var(--default-transition-easing); + animation: dropdown--in---FpwEb var(--default-transition-duration) var(--default-transition-easing); + visibility: visible +} + +:root { + --screen-sm-min: 768px; + --screen-md-min: 992px; + --screen-lg-min: 1200px; + --grid-gutter-width: 30px; + --container-sm: calc(720px + var(--grid-gutter-width)); + --container-md: calc(940px + var(--grid-gutter-width)); + --container-lg: calc(1140px + var(--grid-gutter-width)); + --navbar-height: 122px; + --navbar-height-short: 56px; + --summary-height-stacked: 82px; + --statusbar-height-stacked: 54px; + --footer-height: 60px; + --default-transition-duration: 0.2s; + --default-transition-easing: ease; + --gray-base: #000; + --gray-darker-faded: color(var(--gray-darker) alpha(95%)); + --gray-darker: color(var(--gray-base) tint(13.5%)); + --gray-dark: color(var(--gray-base) tint(20%)); + --gray: color(var(--gray-base) tint(33.5%)); + --gray-light: color(var(--gray-base) tint(46.7%)); + --gray-medium: color(var(--gray-base) tint(73.5%)); + --gray-lighter: color(var(--gray-base) tint(93.5%)); + --gray-lighter-faded: color(var(--gray-lighter) alpha(95%)); + --gray-border: color(var(--gray-base) tint(80%)); + --grey50: #eceff1; + --grey100: #f5f5f5; + --grey300: #e0e0e0; + --grey500: #9e9e9e; + --grey700: #616161; + --green100: #c8e6c9; + --green200: #a5d6a7; + --green300: #81c784; + --green500: #4caf50; + --green700: #388e3c; + --red100: #ffcdd2; + --red300: #e57373; + --red500: #f44336; + --red700: #d32f2f; + --ltblue100: #b3e5fc; + --ltblue300: #4fc3f7; + --ltblue500: #03a9f4; + --ltblue700: #0288d1; + --black87: rgba(0,0,0,0.87); + --black54: rgba(0,0,0,0.54); + --black38: rgba(0,0,0,0.38); + --bluegrey500: #607d8b; + --bluegrey800: #37474f; + --bluegrey900: #263238; + --light-icon-active: #fff; + --light-icon-inactive: hsla(0,0%,100%,0.5); + --dark-icon-active: var(--black54); + --dark-icon-inactive: var(--black38); + --amber300: #ffd54f; + --amber400: #ffca28; + --amber500: #ffc107; + --yellow700: #fbc02d; + --yellow800: #f9a825; + --brand-primary: color(#428bca shade(6.5%)); + --brand-success: #4caf50; + --brand-info: #5bc0de; + --brand-warning: #f0ad4e; + --brand-danger: #d9534f; + --text-color: var(--black87); + --body-bg: #f2f2f2; + --link-color: var(--brand-primary); + --link-hover-color: color(var(--link-color) shade(15%)); + --list-group-border: #ddd; + --font-family-sans-serif: "robotoregular","Helvetica Neue",Helvetica,Arial,sans-serif; + --font-family-base: var(--font-family-sans-serif); + --font-family-mono: "Menlo","Monaco","Consolas","Courier New",monospace; + --font-size-base: 14px; + --line-height-base: 1.429; + --line-height-computed: 20px; + --headings-font-family: inherit; + --headings-font-weight: 400; + --headings-line-height: 1.1; + --headings-color: inherit; + --headings-small-color: var(--gray-light); + --font-size-h1: 36px; + --font-size-h2: 30px; + --font-size-h3: 24px; + --font-size-h4: 18px; + --font-size-h5: var(--font-size-base); + --font-size-h6: 12px; + --font-family-light: "robotolight"; + --font-family-regular: "robotoregular"; + --font-family-medium: "robotomedium"; + --link-transition: color 0.2s ease-out +} + +.dropdown-selector--trans-color---3nePW { + transition: color .2s ease-out; + transition: var(--link-transition) +} + +.dropdown-selector--dropdown---AT5ee { + right: -8px +} + +.dropdown-selector--menu---nW4gv { + box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12); + font-family: robotolight; + font-family: var(--font-family-light); + min-width: 70px; + width: 70px; + background: #fff; + top: 0 +} + +.dropdown-selector--toggle---WEnEe { + display: inline-block; + font-family: robotoregular; + font-family: var(--font-family-regular); + font-size: 14px; + color: rgba(0,0,0,.54); + color: var(--black54); + vertical-align: top; + line-height: 24px; + padding: 0 22px 0 0; + cursor: pointer; + border: none; + background: none; + outline: none; + width: 70px +} + +.dropdown-selector--toggle---WEnEe:focus { + box-shadow: 0 0 2px 0 #03a9f4; + box-shadow: 0 0 2px 0 var(--ltblue500) +} + +.dropdown-selector--toggle-icon---10VKo { + position: absolute; + top: 4px; + right: 4px +} + +.dropdown-selector--item-link---2W1T7,.dropdown-selector--toggle-icon---10VKo { + color: rgba(0,0,0,.38); + color: var(--black38) +} + +.dropdown-selector--item-link---2W1T7 { + border: none; + cursor: pointer; + padding: 4px 10px; + text-align: left; + width: 100% +} + +.dropdown-selector--item-link---2W1T7:hover { + background-color: #f5f5f5; + background-color: var(--grey100) +} + +.dropdown-selector--item-link---2W1T7:focus { + box-shadow: inset 0 0 2px 0 #03a9f4; + box-shadow: inset 0 0 2px 0 var(--ltblue500); + outline: none +} + +.dropdown-selector--item-selected---1q-NK .dropdown-selector--item-link---2W1T7 { + color: #4caf50; + color: var(--green500) +} + +:root { + --screen-sm-min: 768px; + --screen-md-min: 992px; + --screen-lg-min: 1200px; + --grid-gutter-width: 30px; + --container-sm: calc(720px + var(--grid-gutter-width)); + --container-md: calc(940px + var(--grid-gutter-width)); + --container-lg: calc(1140px + var(--grid-gutter-width)); + --navbar-height: 122px; + --navbar-height-short: 56px; + --summary-height-stacked: 82px; + --statusbar-height-stacked: 54px; + --footer-height: 60px; + --default-transition-duration: 0.2s; + --default-transition-easing: ease; + --gray-base: #000; + --gray-darker-faded: color(var(--gray-darker) alpha(95%)); + --gray-darker: color(var(--gray-base) tint(13.5%)); + --gray-dark: color(var(--gray-base) tint(20%)); + --gray: color(var(--gray-base) tint(33.5%)); + --gray-light: color(var(--gray-base) tint(46.7%)); + --gray-medium: color(var(--gray-base) tint(73.5%)); + --gray-lighter: color(var(--gray-base) tint(93.5%)); + --gray-lighter-faded: color(var(--gray-lighter) alpha(95%)); + --gray-border: color(var(--gray-base) tint(80%)); + --grey50: #eceff1; + --grey100: #f5f5f5; + --grey300: #e0e0e0; + --grey500: #9e9e9e; + --grey700: #616161; + --green100: #c8e6c9; + --green200: #a5d6a7; + --green300: #81c784; + --green500: #4caf50; + --green700: #388e3c; + --red100: #ffcdd2; + --red300: #e57373; + --red500: #f44336; + --red700: #d32f2f; + --ltblue100: #b3e5fc; + --ltblue300: #4fc3f7; + --ltblue500: #03a9f4; + --ltblue700: #0288d1; + --black87: rgba(0,0,0,0.87); + --black54: rgba(0,0,0,0.54); + --black38: rgba(0,0,0,0.38); + --bluegrey500: #607d8b; + --bluegrey800: #37474f; + --bluegrey900: #263238; + --light-icon-active: #fff; + --light-icon-inactive: hsla(0,0%,100%,0.5); + --dark-icon-active: var(--black54); + --dark-icon-inactive: var(--black38); + --amber300: #ffd54f; + --amber400: #ffca28; + --amber500: #ffc107; + --yellow700: #fbc02d; + --yellow800: #f9a825; + --brand-primary: color(#428bca shade(6.5%)); + --brand-success: #4caf50; + --brand-info: #5bc0de; + --brand-warning: #f0ad4e; + --brand-danger: #d9534f; + --text-color: var(--black87); + --body-bg: #f2f2f2; + --link-color: var(--brand-primary); + --link-hover-color: color(var(--link-color) shade(15%)); + --list-group-border: #ddd; + --font-family-sans-serif: "robotoregular","Helvetica Neue",Helvetica,Arial,sans-serif; + --font-family-base: var(--font-family-sans-serif); + --font-family-mono: "Menlo","Monaco","Consolas","Courier New",monospace; + --font-size-base: 14px; + --line-height-base: 1.429; + --line-height-computed: 20px; + --headings-font-family: inherit; + --headings-font-weight: 400; + --headings-line-height: 1.1; + --headings-color: inherit; + --headings-small-color: var(--gray-light); + --font-size-h1: 36px; + --font-size-h2: 30px; + --font-size-h3: 24px; + --font-size-h4: 18px; + --font-size-h5: var(--font-size-base); + --font-size-h6: 12px; + --font-family-light: "robotolight"; + --font-family-regular: "robotoregular"; + --font-family-medium: "robotomedium"; + --link-transition: color 0.2s ease-out +} + +.footer--trans-color---205XF { + transition: color .2s ease-out; + transition: var(--link-transition) +} + +.footer--component---1WcTR { + position: absolute; + bottom: 0; + width: 100%; + height: 60px; + height: var(--footer-height); + color: rgba(0,0,0,.38); + color: var(--black38); + text-align: center +} + +.footer--component---1WcTR p { + font-size: 12px; + margin: 10px 0 +} + +.footer--component---1WcTR a { + color: rgba(0,0,0,.54); + color: var(--black54); + transition: color .2s ease-out; + transition: var(--link-transition) +} + +.footer--component---1WcTR a:hover { + color: rgba(0,0,0,.87); + color: var(--black87) +} + +:root { + --screen-sm-min: 768px; + --screen-md-min: 992px; + --screen-lg-min: 1200px; + --grid-gutter-width: 30px; + --container-sm: calc(720px + var(--grid-gutter-width)); + --container-md: calc(940px + var(--grid-gutter-width)); + --container-lg: calc(1140px + var(--grid-gutter-width)); + --navbar-height: 122px; + --navbar-height-short: 56px; + --summary-height-stacked: 82px; + --statusbar-height-stacked: 54px; + --footer-height: 60px; + --default-transition-duration: 0.2s; + --default-transition-easing: ease; + --gray-base: #000; + --gray-darker-faded: color(var(--gray-darker) alpha(95%)); + --gray-darker: color(var(--gray-base) tint(13.5%)); + --gray-dark: color(var(--gray-base) tint(20%)); + --gray: color(var(--gray-base) tint(33.5%)); + --gray-light: color(var(--gray-base) tint(46.7%)); + --gray-medium: color(var(--gray-base) tint(73.5%)); + --gray-lighter: color(var(--gray-base) tint(93.5%)); + --gray-lighter-faded: color(var(--gray-lighter) alpha(95%)); + --gray-border: color(var(--gray-base) tint(80%)); + --grey50: #eceff1; + --grey100: #f5f5f5; + --grey300: #e0e0e0; + --grey500: #9e9e9e; + --grey700: #616161; + --green100: #c8e6c9; + --green200: #a5d6a7; + --green300: #81c784; + --green500: #4caf50; + --green700: #388e3c; + --red100: #ffcdd2; + --red300: #e57373; + --red500: #f44336; + --red700: #d32f2f; + --ltblue100: #b3e5fc; + --ltblue300: #4fc3f7; + --ltblue500: #03a9f4; + --ltblue700: #0288d1; + --black87: rgba(0,0,0,0.87); + --black54: rgba(0,0,0,0.54); + --black38: rgba(0,0,0,0.38); + --bluegrey500: #607d8b; + --bluegrey800: #37474f; + --bluegrey900: #263238; + --light-icon-active: #fff; + --light-icon-inactive: hsla(0,0%,100%,0.5); + --dark-icon-active: var(--black54); + --dark-icon-inactive: var(--black38); + --amber300: #ffd54f; + --amber400: #ffca28; + --amber500: #ffc107; + --yellow700: #fbc02d; + --yellow800: #f9a825; + --brand-primary: color(#428bca shade(6.5%)); + --brand-success: #4caf50; + --brand-info: #5bc0de; + --brand-warning: #f0ad4e; + --brand-danger: #d9534f; + --text-color: var(--black87); + --body-bg: #f2f2f2; + --link-color: var(--brand-primary); + --link-hover-color: color(var(--link-color) shade(15%)); + --list-group-border: #ddd; + --font-family-sans-serif: "robotoregular","Helvetica Neue",Helvetica,Arial,sans-serif; + --font-family-base: var(--font-family-sans-serif); + --font-family-mono: "Menlo","Monaco","Consolas","Courier New",monospace; + --font-size-base: 14px; + --line-height-base: 1.429; + --line-height-computed: 20px; + --headings-font-family: inherit; + --headings-font-weight: 400; + --headings-line-height: 1.1; + --headings-color: inherit; + --headings-small-color: var(--gray-light); + --font-size-h1: 36px; + --font-size-h2: 30px; + --font-size-h3: 24px; + --font-size-h4: 18px; + --font-size-h5: var(--font-size-base); + --font-size-h6: 12px; + --font-family-light: "robotolight"; + --font-family-regular: "robotoregular"; + --font-family-medium: "robotomedium"; + --link-transition: color 0.2s ease-out +} + +.loader--trans-color---97r08 { + transition: color .2s ease-out; + transition: var(--link-transition) +} + +.loader--component---2grcA { + position: fixed; + top: 0; + height: 100%; + width: 100%; + background-color: color(#f2f2f2 alpha(60%)); + background-color: color(var(--body-bg) alpha(60%)); + padding-top: 122px; + padding-top: var(--navbar-height) +} + +.loader--wrap---3Fhrc { + display: -webkit-flex; + display: flex; + -webkit-align-items: center; + align-items: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-flex-direction: column; + flex-direction: column; + min-height: 200px +} + +.loader--text---3Yu3g { + color: color(#000 tint(46.7%)); + color: var(--gray-light); + text-align: center; + margin: 1rem 0 0 +} + +.loader--spinner---2q6MO { + border-radius: 50%; + width: 42px; + height: 42px; + border: .25rem solid color(#000 tint(73.5%)); + border-top-color: color(#000 tint(33.5%)); + border: .25rem solid var(--gray-medium); + border-top-color: var(--gray); + -webkit-animation: loader--spin---K6Loh 1s linear infinite; + animation: loader--spin---K6Loh 1s linear infinite +} + +@-webkit-keyframes loader--spin---K6Loh { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg) + } + + to { + -webkit-transform: rotate(1turn); + transform: rotate(1turn) + } +} + +@keyframes loader--spin---K6Loh { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg) + } + + to { + -webkit-transform: rotate(1turn); + transform: rotate(1turn) + } +} + +@media (min-width: 768px) { + .loader--component---2grcA { + padding-top:56px; + padding-top: var(--navbar-height-short) + } +} + +:root { + --screen-sm-min: 768px; + --screen-md-min: 992px; + --screen-lg-min: 1200px; + --grid-gutter-width: 30px; + --container-sm: calc(720px + var(--grid-gutter-width)); + --container-md: calc(940px + var(--grid-gutter-width)); + --container-lg: calc(1140px + var(--grid-gutter-width)); + --navbar-height: 122px; + --navbar-height-short: 56px; + --summary-height-stacked: 82px; + --statusbar-height-stacked: 54px; + --footer-height: 60px; + --default-transition-duration: 0.2s; + --default-transition-easing: ease; + --gray-base: #000; + --gray-darker-faded: color(var(--gray-darker) alpha(95%)); + --gray-darker: color(var(--gray-base) tint(13.5%)); + --gray-dark: color(var(--gray-base) tint(20%)); + --gray: color(var(--gray-base) tint(33.5%)); + --gray-light: color(var(--gray-base) tint(46.7%)); + --gray-medium: color(var(--gray-base) tint(73.5%)); + --gray-lighter: color(var(--gray-base) tint(93.5%)); + --gray-lighter-faded: color(var(--gray-lighter) alpha(95%)); + --gray-border: color(var(--gray-base) tint(80%)); + --grey50: #eceff1; + --grey100: #f5f5f5; + --grey300: #e0e0e0; + --grey500: #9e9e9e; + --grey700: #616161; + --green100: #c8e6c9; + --green200: #a5d6a7; + --green300: #81c784; + --green500: #4caf50; + --green700: #388e3c; + --red100: #ffcdd2; + --red300: #e57373; + --red500: #f44336; + --red700: #d32f2f; + --ltblue100: #b3e5fc; + --ltblue300: #4fc3f7; + --ltblue500: #03a9f4; + --ltblue700: #0288d1; + --black87: rgba(0,0,0,0.87); + --black54: rgba(0,0,0,0.54); + --black38: rgba(0,0,0,0.38); + --bluegrey500: #607d8b; + --bluegrey800: #37474f; + --bluegrey900: #263238; + --light-icon-active: #fff; + --light-icon-inactive: hsla(0,0%,100%,0.5); + --dark-icon-active: var(--black54); + --dark-icon-inactive: var(--black38); + --amber300: #ffd54f; + --amber400: #ffca28; + --amber500: #ffc107; + --yellow700: #fbc02d; + --yellow800: #f9a825; + --brand-primary: color(#428bca shade(6.5%)); + --brand-success: #4caf50; + --brand-info: #5bc0de; + --brand-warning: #f0ad4e; + --brand-danger: #d9534f; + --text-color: var(--black87); + --body-bg: #f2f2f2; + --link-color: var(--brand-primary); + --link-hover-color: color(var(--link-color) shade(15%)); + --list-group-border: #ddd; + --font-family-sans-serif: "robotoregular","Helvetica Neue",Helvetica,Arial,sans-serif; + --font-family-base: var(--font-family-sans-serif); + --font-family-mono: "Menlo","Monaco","Consolas","Courier New",monospace; + --font-size-base: 14px; + --line-height-base: 1.429; + --line-height-computed: 20px; + --headings-font-family: inherit; + --headings-font-weight: 400; + --headings-line-height: 1.1; + --headings-color: inherit; + --headings-small-color: var(--gray-light); + --font-size-h1: 36px; + --font-size-h2: 30px; + --font-size-h3: 24px; + --font-size-h4: 18px; + --font-size-h5: var(--font-size-base); + --font-size-h6: 12px; + --font-family-light: "robotolight"; + --font-family-regular: "robotoregular"; + --font-family-medium: "robotomedium"; + --link-transition: color 0.2s ease-out +} + +.nav-menu--trans-color---1l-R- { + transition: color .2s ease-out; + transition: var(--link-transition) +} + +.nav-menu--wrap---39S_b { + position: fixed; + z-index: 2010; + top: 0; + right: 0; + bottom: 0; + left: 0; + overflow: hidden; + visibility: hidden +} + +.nav-menu--overlay---k2Lwz { + display: none; + background: rgba(0,0,0,.5) +} + +.nav-menu--close-btn---2m7W7 { + border: none; + background: transparent; + padding: 0 +} + +.nav-menu--close-btn---2m7W7:focus { + box-shadow: 0 0 2px 0 #03a9f4; + box-shadow: 0 0 2px 0 var(--ltblue500); + outline: none +} + +.nav-menu--close-btn---2m7W7 { + cursor: pointer; + transition: color .2s ease-out; + transition: var(--link-transition); + position: absolute; + top: 16px; + right: 16px; + color: rgba(0,0,0,.54); + color: var(--black54) +} + +.nav-menu--close-btn---2m7W7:active,.nav-menu--close-btn---2m7W7:hover { + color: rgba(0,0,0,.87); + color: var(--black87) +} + +.nav-menu--menu---lFcsl { + position: absolute; + transition: all .15s cubic-bezier(.25,1,.8,1); + -webkit-transform: translate(-100%); + transform: translate(-100%); + width: 100%; + z-index: 1; + top: 0; + bottom: 0; + left: 0; + overflow: auto; + background: #202020; +} + +.nav-menu--close-button---2_OHr { + border: none; + background: transparent; + padding: 0 +} + +.nav-menu--close-button---2_OHr:focus { + box-shadow: 0 0 2px 0 #03a9f4; + box-shadow: 0 0 2px 0 var(--ltblue500); + outline: none +} + +.nav-menu--close-button---2_OHr { + cursor: pointer; + transition: color .2s ease-out; + transition: var(--link-transition); + position: absolute; + top: 14px; + right: 14px; + font-size: 21px; + width: 26px; + height: 26px; + color: color(#000 tint(33.5%)); + color: var(--gray) +} + +.nav-menu--close-button---2_OHr:hover { + color: color(#000 tint(20%)); + color: var(--gray-dark) +} + +.nav-menu--date---3SYOi,.nav-menu--section-head---3LXPD { + color: rgba(0,0,0,.54); + color: var(--black54) +} + +.nav-menu--section-head---3LXPD { + text-transform: uppercase +} + +.nav-menu--control---1JEYH { + display: -webkit-flex; + display: flex; + position: relative; + margin: 8px 0; + -webkit-align-items: center; + align-items: center +} + +.nav-menu--control-label---3f2XU { + display: inline-block; + -webkit-flex-grow: 1; + flex-grow: 1; + font-family: var(--font-family--regular); + font-size: 13px; + vertical-align: top; + line-height: 24px +} + +.nav-menu--control-label---3f2XU.nav-menu--with-icon---qF4hj { + margin-left: 12px +} + +.nav-menu--control-group---32kKg { + margin-bottom: 10px +} + +.nav-menu--toggle-icon-passed---132lH { + color: #4caf50; + color: var(--green500) +} + +.nav-menu--toggle-icon-failed---x-XUB { + color: #f44336; + color: var(--red500) +} + +.nav-menu--toggle-icon-pending---3ZJAs { + color: #03a9f4; + color: var(--ltblue500) +} + +.nav-menu--toggle-icon-skipped---FyedH { + color: #9e9e9e; + color: var(--grey500) +} + +.nav-menu--wrap---39S_b.nav-menu--open---3BW1O { + visibility: visible +} + +.nav-menu--wrap---39S_b.nav-menu--open---3BW1O .nav-menu--overlay---k2Lwz { + opacity: 1 +} + +.nav-menu--wrap---39S_b.nav-menu--open---3BW1O .nav-menu--menu---lFcsl { + -webkit-transform: translate(0); + transform: translate(0) +} + +.nav-menu--section---2z7Dj { + padding: 0 16px; + border-bottom: 1px solid #e0e0e0; + border-bottom: 1px solid var(--grey300) +} + +.nav-menu--list---2QMG9 { + list-style: none; + padding-left: 0 +} + +.nav-menu--main---jkqJW { + margin: 8px 0 +} + +.nav-menu--no-tests---2sRAg>.nav-menu--item---gXWu6:not(.nav-menu--has-tests---1ND4g)>div>.nav-menu--sub---EnSIu { + padding-left: 0 +} + +.nav-menu--no-tests---2sRAg>.nav-menu--item---gXWu6:not(.nav-menu--has-tests---1ND4g):not(:only-child) { + padding-left: 22px +} + +.nav-menu--sub---EnSIu { + padding-left: 24px; + margin: 0 0 2px +} + +.nav-menu--link---tywPF { + display: -webkit-flex; + display: flex; + position: relative; + -webkit-align-items: center; + align-items: center; + padding: 3px 0; + color: color(#000 tint(33.5%)); + color: var(--gray) +} + +.nav-menu--link---tywPF:hover { + color: color(color(#428bca shade(6.5%)) shade(15%)); + color: var(--link-hover-color); + text-decoration: none +} + +.nav-menu--link---tywPF:active,.nav-menu--link---tywPF:focus { + box-shadow: 0 0 2px 0 #03a9f4; + box-shadow: 0 0 2px 0 var(--ltblue500); + outline: none; + text-decoration: none +} + +.nav-menu--link---tywPF span { + transition: color .2s ease-out; + transition: var(--link-transition); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap +} + +.nav-menu--link-icon---1Q2NP { + margin-right: 2px +} + +.nav-menu--link-icon---1Q2NP.nav-menu--pass---1PUeh { + color: #4caf50; + color: var(--green500) +} + +.nav-menu--link-icon---1Q2NP.nav-menu--fail---3gQQa { + color: #f44336; + color: var(--red500) +} + +.nav-menu--link-icon---1Q2NP.nav-menu--pending---9zAw0 { + color: #03a9f4; + color: var(--ltblue500) +} + +.nav-menu--link-icon---1Q2NP.nav-menu--skipped---31GPM { + color: #9e9e9e; + color: var(--grey500) +} + +.nav-menu--disabled---2MoA_ { + opacity: .3; + pointer-events: none +} + +@media (min-width: 768px) { + .nav-menu--menu---lFcsl { + width:320px; + left: auto + } + + .nav-menu--overlay---k2Lwz { + display: block; + position: fixed; + transition: all .2s ease-out; + top: 0; + right: 0; + bottom: 0; + left: 0; + cursor: pointer; + opacity: 0 + } +} + +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ +html { + line-height: 1.15; + -webkit-text-size-adjust: 100% +} + +body { + margin: 0 +} + +main { + display: block +} + +h1 { + font-size: 2em; + margin: .67em 0 +} + +hr { + box-sizing: content-box; + height: 0; + overflow: visible +} + +pre { + font-family: monospace,monospace; + font-size: 1em +} + +a { + background-color: transparent +} + +abbr[title] { + border-bottom: none; + text-decoration: underline; + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted +} + +b,strong { + font-weight: bolder +} + +code,kbd,samp { + font-family: monospace,monospace; + font-size: 1em +} + +small { + font-size: 80% +} + +sub,sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline +} + +sub { + bottom: -.25em +} + +sup { + top: -.5em +} + +img { + border-style: none +} + +button,input,optgroup,select,textarea { + font-family: inherit; + font-size: 100%; + line-height: 1.15; + margin: 0 +} + +button,input { + overflow: visible +} + +button,select { + text-transform: none +} + +[type=button],[type=reset],[type=submit],button { + -webkit-appearance: button +} + +[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner { + border-style: none; + padding: 0 +} + +[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring { + outline: 1px dotted ButtonText +} + +fieldset { + padding: .35em .75em .625em +} + +legend { + box-sizing: border-box; + color: inherit; + display: table; + max-width: 100%; + padding: 0; + white-space: normal +} + +progress { + vertical-align: baseline +} + +textarea { + overflow: auto +} + +[type=checkbox],[type=radio] { + box-sizing: border-box; + padding: 0 +} + +[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button { + height: auto +} + +[type=search] { + -webkit-appearance: textfield; + outline-offset: -2px +} + +[type=search]::-webkit-search-decoration { + -webkit-appearance: none +} + +::-webkit-file-upload-button { + -webkit-appearance: button; + font: inherit +} + +details { + display: block +} + +summary { + display: list-item +} + +[hidden],template { + display: none +} + +:root { + --screen-sm-min: 768px; + --screen-md-min: 992px; + --screen-lg-min: 1200px; + --grid-gutter-width: 30px; + --container-sm: calc(720px + var(--grid-gutter-width)); + --container-md: calc(940px + var(--grid-gutter-width)); + --container-lg: calc(1140px + var(--grid-gutter-width)); + --navbar-height: 122px; + --navbar-height-short: 56px; + --summary-height-stacked: 82px; + --statusbar-height-stacked: 54px; + --footer-height: 60px; + --default-transition-duration: 0.2s; + --default-transition-easing: ease; + --gray-base: #000; + --gray-darker-faded: color(var(--gray-darker) alpha(95%)); + --gray-darker: color(var(--gray-base) tint(13.5%)); + --gray-dark: color(var(--gray-base) tint(20%)); + --gray: color(var(--gray-base) tint(33.5%)); + --gray-light: color(var(--gray-base) tint(46.7%)); + --gray-medium: color(var(--gray-base) tint(73.5%)); + --gray-lighter: color(var(--gray-base) tint(93.5%)); + --gray-lighter-faded: color(var(--gray-lighter) alpha(95%)); + --gray-border: color(var(--gray-base) tint(80%)); + --grey50: #eceff1; + --grey100: #f5f5f5; + --grey300: #e0e0e0; + --grey500: #9e9e9e; + --grey700: #616161; + --green100: #c8e6c9; + --green200: #a5d6a7; + --green300: #81c784; + --green500: #4caf50; + --green700: #388e3c; + --red100: #ffcdd2; + --red300: #e57373; + --red500: #f44336; + --red700: #d32f2f; + --ltblue100: #b3e5fc; + --ltblue300: #4fc3f7; + --ltblue500: #03a9f4; + --ltblue700: #0288d1; + --black87: rgba(0,0,0,0.87); + --black54: rgba(0,0,0,0.54); + --black38: rgba(0,0,0,0.38); + --bluegrey500: #607d8b; + --bluegrey800: #37474f; + --bluegrey900: #263238; + --light-icon-active: #fff; + --light-icon-inactive: hsla(0,0%,100%,0.5); + --dark-icon-active: var(--black54); + --dark-icon-inactive: var(--black38); + --amber300: #ffd54f; + --amber400: #ffca28; + --amber500: #ffc107; + --yellow700: #fbc02d; + --yellow800: #f9a825; + --brand-primary: color(#428bca shade(6.5%)); + --brand-success: #4caf50; + --brand-info: #5bc0de; + --brand-warning: #f0ad4e; + --brand-danger: #d9534f; + --text-color: var(--black87); + --body-bg: #f2f2f2; + --link-color: var(--brand-primary); + --link-hover-color: color(var(--link-color) shade(15%)); + --list-group-border: #ddd; + --font-family-sans-serif: "robotoregular","Helvetica Neue",Helvetica,Arial,sans-serif; + --font-family-base: var(--font-family-sans-serif); + --font-family-mono: "Menlo","Monaco","Consolas","Courier New",monospace; + --font-size-base: 14px; + --line-height-base: 1.429; + --line-height-computed: 20px; + --headings-font-family: inherit; + --headings-font-weight: 400; + --headings-line-height: 1.1; + --headings-color: inherit; + --headings-small-color: var(--gray-light); + --font-size-h1: 36px; + --font-size-h2: 30px; + --font-size-h3: 24px; + --font-size-h4: 18px; + --font-size-h5: var(--font-size-base); + --font-size-h6: 12px; + --font-family-light: "robotolight"; + --font-family-regular: "robotoregular"; + --font-family-medium: "robotomedium"; + --link-transition: color 0.2s ease-out +} + +.trans-color { + transition: color .2s ease-out; + transition: var(--link-transition) +} + +.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6 { + font-family: inherit; + font-family: var(--headings-font-family); + font-weight: 400; + font-weight: var(--headings-font-weight); + line-height: 1.1; + line-height: var(--headings-line-height); + color: inherit; + color: var(--headings-color) +} + +.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small { + font-weight: 400; + line-height: 1; + color: color(#000 tint(46.7%)); + color: var(--headings-small-color) +} + +.h1,.h2,.h3,h1,h2,h3 { + margin-top: 20px; + margin-top: var(--line-height-computed); + margin-bottom: 10px; + margin-bottom: calc(var(--line-height-computed)/2) +} + +.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small { + font-size: 65% +} + +.h4,.h5,.h6,h4,h5,h6 { + margin-top: 10px; + margin-top: calc(var(--line-height-computed)/2); + margin-bottom: 10px; + margin-bottom: calc(var(--line-height-computed)/2) +} + +.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small { + font-size: 75% +} + +.h1,h1 { + font-size: 36px; + font-size: var(--font-size-h1) +} + +.h2,h2 { + font-size: 30px; + font-size: var(--font-size-h2) +} + +.h3,h3 { + font-size: 24px; + font-size: var(--font-size-h3) +} + +.h4,h4 { + font-size: 18px; + font-size: var(--font-size-h4) +} + +.h5,h5 { + font-size: 14px; + font-size: var(--font-size-h5) +} + +.h6,h6 { + font-size: 12px; + font-size: var(--font-size-h6) +} + +p { + margin: 0 0 10px; + margin: 0 0 calc(var(--line-height-computed)/2) +} + +.text-left { + text-align: left +} + +.text-right { + text-align: right +} + +.text-center { + text-align: center +} + +.text-justify { + text-align: justify +} + +.text-nowrap { + white-space: nowrap +} + +.text-lowercase { + text-transform: lowercase +} + +.text-uppercase { + text-transform: uppercase +} + +.text-capitalize { + text-transform: capitalize +} + +ol,ul { + margin-top: 0; + margin-bottom: 10px; + margin-bottom: calc(var(--line-height-computed)/2); + ol,ul { + margin-bottom: 0 + } +} + +.list-inline,.list-unstyled { + padding-left: 0; + list-style: none +} + +.list-inline { + margin-left: -5px +} + +.list-inline>li { + display: inline-block; + padding-left: 5px; + padding-right: 5px +} + +code { + font-family: Menlo,Monaco,Consolas,Courier New,monospace; + font-family: var(--font-family-mono) +} + +.hljs { + display: block; + overflow-x: auto; + padding: .5em; + color: #b8b8b8; + background: #fafafa +} + +.hljs-comment,.hljs-quote { + color: #a0a1a7; + font-style: italic +} + +.hljs-doctag,.hljs-formula,.hljs-keyword { + color: #a626a4 +} + +.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst { + color: #e45649 +} + +.hljs-literal { + color: #0184bb +} + +.hljs-addition,.hljs-attribute,.hljs-meta-string,.hljs-regexp,.hljs-string { + color: #50a14f +} + +.hljs-built_in,.hljs-class .hljs-title { + color: #c18401 +} + +.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable { + color: #986801 +} + +.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title { + color: #4078f2 +} + +.hljs-emphasis { + font-style: italic +} + +.hljs-strong { + font-weight: 700 +} + +.hljs-link { + text-decoration: underline +} + +.ct-label { + fill: rgba(0,0,0,.4); + color: rgba(0,0,0,.4); + font-size: .75rem; + line-height: 1 +} + +.ct-chart-bar .ct-label,.ct-chart-line .ct-label { + display: block; + display: -webkit-flex; + display: flex +} + +.ct-chart-donut .ct-label,.ct-chart-pie .ct-label { + dominant-baseline: central +} + +.ct-label.ct-horizontal.ct-start { + -webkit-align-items: flex-end; + align-items: flex-end +} + +.ct-label.ct-horizontal.ct-end,.ct-label.ct-horizontal.ct-start { + -webkit-justify-content: flex-start; + justify-content: flex-start; + text-align: left; + text-anchor: start +} + +.ct-label.ct-horizontal.ct-end { + -webkit-align-items: flex-start; + align-items: flex-start +} + +.ct-label.ct-vertical.ct-start { + -webkit-align-items: flex-end; + align-items: flex-end; + -webkit-justify-content: flex-end; + justify-content: flex-end; + text-align: right; + text-anchor: end +} + +.ct-label.ct-vertical.ct-end { + -webkit-align-items: flex-end; + align-items: flex-end; + -webkit-justify-content: flex-start; + justify-content: flex-start; + text-align: left; + text-anchor: start +} + +.ct-chart-bar .ct-label.ct-horizontal.ct-start { + -webkit-align-items: flex-end; + align-items: flex-end; + -webkit-justify-content: center; + justify-content: center; + text-align: center; + text-anchor: start +} + +.ct-chart-bar .ct-label.ct-horizontal.ct-end { + -webkit-align-items: flex-start; + align-items: flex-start; + -webkit-justify-content: center; + justify-content: center; + text-align: center; + text-anchor: start +} + +.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-start { + -webkit-align-items: flex-end; + align-items: flex-end; + -webkit-justify-content: flex-start; + justify-content: flex-start; + text-align: left; + text-anchor: start +} + +.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-end { + -webkit-align-items: flex-start; + align-items: flex-start; + -webkit-justify-content: flex-start; + justify-content: flex-start; + text-align: left; + text-anchor: start +} + +.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-start { + -webkit-align-items: center; + align-items: center; + -webkit-justify-content: flex-end; + justify-content: flex-end; + text-align: right; + text-anchor: end +} + +.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-end { + -webkit-align-items: center; + align-items: center; + -webkit-justify-content: flex-start; + justify-content: flex-start; + text-align: left; + text-anchor: end +} + +.ct-grid { + stroke: rgba(0,0,0,.2); + stroke-width: 1px; + stroke-dasharray: 2px +} + +.ct-grid-background { + fill: none +} + +.ct-point { + stroke-width: 10px; + stroke-linecap: round +} + +.ct-line { + fill: none; + stroke-width: 4px +} + +.ct-area { + stroke: none; + fill-opacity: .1 +} + +.ct-bar { + fill: none; + stroke-width: 10px +} + +.ct-slice-donut { + fill: none; + stroke-width: 60px +} + +.ct-series-a .ct-bar,.ct-series-a .ct-line,.ct-series-a .ct-point,.ct-series-a .ct-slice-donut { + stroke: #d70206 +} + +.ct-series-a .ct-area,.ct-series-a .ct-slice-donut-solid,.ct-series-a .ct-slice-pie { + fill: #d70206 +} + +.ct-series-b .ct-bar,.ct-series-b .ct-line,.ct-series-b .ct-point,.ct-series-b .ct-slice-donut { + stroke: #f05b4f +} + +.ct-series-b .ct-area,.ct-series-b .ct-slice-donut-solid,.ct-series-b .ct-slice-pie { + fill: #f05b4f +} + +.ct-series-c .ct-bar,.ct-series-c .ct-line,.ct-series-c .ct-point,.ct-series-c .ct-slice-donut { + stroke: #f4c63d +} + +.ct-series-c .ct-area,.ct-series-c .ct-slice-donut-solid,.ct-series-c .ct-slice-pie { + fill: #f4c63d +} + +.ct-series-d .ct-bar,.ct-series-d .ct-line,.ct-series-d .ct-point,.ct-series-d .ct-slice-donut { + stroke: #d17905 +} + +.ct-series-d .ct-area,.ct-series-d .ct-slice-donut-solid,.ct-series-d .ct-slice-pie { + fill: #d17905 +} + +.ct-series-e .ct-bar,.ct-series-e .ct-line,.ct-series-e .ct-point,.ct-series-e .ct-slice-donut { + stroke: #453d3f +} + +.ct-series-e .ct-area,.ct-series-e .ct-slice-donut-solid,.ct-series-e .ct-slice-pie { + fill: #453d3f +} + +.ct-series-f .ct-bar,.ct-series-f .ct-line,.ct-series-f .ct-point,.ct-series-f .ct-slice-donut { + stroke: #59922b +} + +.ct-series-f .ct-area,.ct-series-f .ct-slice-donut-solid,.ct-series-f .ct-slice-pie { + fill: #59922b +} + +.ct-series-g .ct-bar,.ct-series-g .ct-line,.ct-series-g .ct-point,.ct-series-g .ct-slice-donut { + stroke: #0544d3 +} + +.ct-series-g .ct-area,.ct-series-g .ct-slice-donut-solid,.ct-series-g .ct-slice-pie { + fill: #0544d3 +} + +.ct-series-h .ct-bar,.ct-series-h .ct-line,.ct-series-h .ct-point,.ct-series-h .ct-slice-donut { + stroke: #6b0392 +} + +.ct-series-h .ct-area,.ct-series-h .ct-slice-donut-solid,.ct-series-h .ct-slice-pie { + fill: #6b0392 +} + +.ct-series-i .ct-bar,.ct-series-i .ct-line,.ct-series-i .ct-point,.ct-series-i .ct-slice-donut { + stroke: #f05b4f +} + +.ct-series-i .ct-area,.ct-series-i .ct-slice-donut-solid,.ct-series-i .ct-slice-pie { + fill: #f05b4f +} + +.ct-series-j .ct-bar,.ct-series-j .ct-line,.ct-series-j .ct-point,.ct-series-j .ct-slice-donut { + stroke: #dda458 +} + +.ct-series-j .ct-area,.ct-series-j .ct-slice-donut-solid,.ct-series-j .ct-slice-pie { + fill: #dda458 +} + +.ct-series-k .ct-bar,.ct-series-k .ct-line,.ct-series-k .ct-point,.ct-series-k .ct-slice-donut { + stroke: #eacf7d +} + +.ct-series-k .ct-area,.ct-series-k .ct-slice-donut-solid,.ct-series-k .ct-slice-pie { + fill: #eacf7d +} + +.ct-series-l .ct-bar,.ct-series-l .ct-line,.ct-series-l .ct-point,.ct-series-l .ct-slice-donut { + stroke: #86797d +} + +.ct-series-l .ct-area,.ct-series-l .ct-slice-donut-solid,.ct-series-l .ct-slice-pie { + fill: #86797d +} + +.ct-series-m .ct-bar,.ct-series-m .ct-line,.ct-series-m .ct-point,.ct-series-m .ct-slice-donut { + stroke: #b2c326 +} + +.ct-series-m .ct-area,.ct-series-m .ct-slice-donut-solid,.ct-series-m .ct-slice-pie { + fill: #b2c326 +} + +.ct-series-n .ct-bar,.ct-series-n .ct-line,.ct-series-n .ct-point,.ct-series-n .ct-slice-donut { + stroke: #6188e2 +} + +.ct-series-n .ct-area,.ct-series-n .ct-slice-donut-solid,.ct-series-n .ct-slice-pie { + fill: #6188e2 +} + +.ct-series-o .ct-bar,.ct-series-o .ct-line,.ct-series-o .ct-point,.ct-series-o .ct-slice-donut { + stroke: #a748ca +} + +.ct-series-o .ct-area,.ct-series-o .ct-slice-donut-solid,.ct-series-o .ct-slice-pie { + fill: #a748ca +} + +.ct-square { + display: block; + position: relative; + width: 100% +} + +.ct-square:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 100% +} + +.ct-square:after { + content: ""; + display: table; + clear: both +} + +.ct-square>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-minor-second { + display: block; + position: relative; + width: 100% +} + +.ct-minor-second:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 93.75% +} + +.ct-minor-second:after { + content: ""; + display: table; + clear: both +} + +.ct-minor-second>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-major-second { + display: block; + position: relative; + width: 100% +} + +.ct-major-second:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 88.8888888889% +} + +.ct-major-second:after { + content: ""; + display: table; + clear: both +} + +.ct-major-second>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-minor-third { + display: block; + position: relative; + width: 100% +} + +.ct-minor-third:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 83.3333333333% +} + +.ct-minor-third:after { + content: ""; + display: table; + clear: both +} + +.ct-minor-third>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-major-third { + display: block; + position: relative; + width: 100% +} + +.ct-major-third:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 80% +} + +.ct-major-third:after { + content: ""; + display: table; + clear: both +} + +.ct-major-third>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-perfect-fourth { + display: block; + position: relative; + width: 100% +} + +.ct-perfect-fourth:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 75% +} + +.ct-perfect-fourth:after { + content: ""; + display: table; + clear: both +} + +.ct-perfect-fourth>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-perfect-fifth { + display: block; + position: relative; + width: 100% +} + +.ct-perfect-fifth:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 66.6666666667% +} + +.ct-perfect-fifth:after { + content: ""; + display: table; + clear: both +} + +.ct-perfect-fifth>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-minor-sixth { + display: block; + position: relative; + width: 100% +} + +.ct-minor-sixth:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 62.5% +} + +.ct-minor-sixth:after { + content: ""; + display: table; + clear: both +} + +.ct-minor-sixth>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-golden-section { + display: block; + position: relative; + width: 100% +} + +.ct-golden-section:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 61.804697157% +} + +.ct-golden-section:after { + content: ""; + display: table; + clear: both +} + +.ct-golden-section>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-major-sixth { + display: block; + position: relative; + width: 100% +} + +.ct-major-sixth:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 60% +} + +.ct-major-sixth:after { + content: ""; + display: table; + clear: both +} + +.ct-major-sixth>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-minor-seventh { + display: block; + position: relative; + width: 100% +} + +.ct-minor-seventh:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 56.25% +} + +.ct-minor-seventh:after { + content: ""; + display: table; + clear: both +} + +.ct-minor-seventh>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-major-seventh { + display: block; + position: relative; + width: 100% +} + +.ct-major-seventh:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 53.3333333333% +} + +.ct-major-seventh:after { + content: ""; + display: table; + clear: both +} + +.ct-major-seventh>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-octave { + display: block; + position: relative; + width: 100% +} + +.ct-octave:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 50% +} + +.ct-octave:after { + content: ""; + display: table; + clear: both +} + +.ct-octave>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-major-tenth { + display: block; + position: relative; + width: 100% +} + +.ct-major-tenth:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 40% +} + +.ct-major-tenth:after { + content: ""; + display: table; + clear: both +} + +.ct-major-tenth>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-major-eleventh { + display: block; + position: relative; + width: 100% +} + +.ct-major-eleventh:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 37.5% +} + +.ct-major-eleventh:after { + content: ""; + display: table; + clear: both +} + +.ct-major-eleventh>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-major-twelfth { + display: block; + position: relative; + width: 100% +} + +.ct-major-twelfth:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 33.3333333333% +} + +.ct-major-twelfth:after { + content: ""; + display: table; + clear: both +} + +.ct-major-twelfth>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +.ct-double-octave { + display: block; + position: relative; + width: 100% +} + +.ct-double-octave:before { + display: block; + float: left; + content: ""; + width: 0; + height: 0; + padding-bottom: 25% +} + +.ct-double-octave:after { + content: ""; + display: table; + clear: both +} + +.ct-double-octave>svg { + display: block; + position: absolute; + top: 0; + left: 0 +} + +@font-face { + font-family: robotolight; + src: url(roboto-light-webfont.woff2) format("woff2"),url(roboto-light-webfont.woff) format("woff"); + font-weight: 400; + font-style: normal +} + +@font-face { + font-family: robotomedium; + src: url(roboto-medium-webfont.woff2) format("woff2"),url(roboto-medium-webfont.woff) format("woff"); + font-weight: 400; + font-style: normal +} + +@font-face { + font-family: robotoregular; + src: url(roboto-regular-webfont.woff2) format("woff2"),url(roboto-regular-webfont.woff) format("woff"); + font-weight: 400; + font-style: normal +} + +@font-face { + font-family: Material Icons; + font-style: normal; + font-weight: 400; + src: url(MaterialIcons-Regular.woff2) format("woff2"),url(MaterialIcons-Regular.woff) format("woff") +} + +.material-icons { + display: inline-block; + font-family: Material Icons; + font-weight: 400; + font-style: normal; + font-size: 24px; + line-height: 1; + text-transform: none; + letter-spacing: normal; + word-wrap: normal; + white-space: nowrap; + direction: ltr; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + -webkit-font-feature-settings: "liga"; + font-feature-settings: "liga" +} + +.material-icons.md-18 { + font-size: 18px +} + +.material-icons.md-24 { + font-size: 24px +} + +.material-icons.md-36 { + font-size: 36px +} + +.material-icons.md-48 { + font-size: 48px +} + +.material-icons.md-dark { + color: rgba(0,0,0,.54) +} + +.material-icons.md-dark.md-inactive { + color: rgba(0,0,0,.26) +} + +.material-icons.md-light { + color: #fff +} + +.material-icons.md-light.md-inactive { + color: hsla(0,0%,100%,.3) +} + +*,:after,:before { + box-sizing: border-box +} + +html { + position: relative; + min-height: 100% +} + +body { + font-family: robotoregular,Helvetica Neue,Helvetica,Arial,sans-serif; + font-family: var(--font-family-base); + font-size: 14px; + font-size: var(--font-size-base); + line-height: 1.429; + line-height: var(--line-height-base); + color: rgba(0,0,0,.87); + color: var(--text-color); + background-color: #f2f2f2; + background-color: var(--body-bg); + margin-bottom: 60px; + margin-bottom: var(--footer-height) +} + +a { + text-decoration: none; + transition: color .2s ease-out; + transition: var(--link-transition) +} + +a:hover { + text-decoration: underline +} + +pre { + word-break: break-all; + word-wrap: break-word; + border-radius: 4px +} + +.cf:before,.clearfix:before { + content: " "; + display: table +} + +.cf:after,.clearfix:after { + content: " "; + display: table; + clear: both +} + +.container:after,.container:before { + content: " "; + display: table +} + +.container:after { + clear: both +} + +.container { + margin-right: auto; + margin-left: auto; + padding-left: 15px; + padding-left: calc(var(--grid-gutter-width)/2); + padding-right: 15px; + padding-right: calc(var(--grid-gutter-width)/2) +} + +.row:after,.row:before { + content: " "; + display: table +} + +.row:after { + clear: both +} + +.row { + margin-left: -15px; + margin-left: calc(var(--grid-gutter-width)/-2); + margin-right: -15px; + margin-right: calc(var(--grid-gutter-width)/-2) +} + +.details { + padding-top: 146px; + padding-top: calc(var(--navbar-height) + 24px) +} + +.z-depth-0 { + box-shadow: none!important +} + +.z-depth-1 { + box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12) +} + +.z-depth-1-half { + box-shadow: 0 5px 11px 0 rgba(0,0,0,.18),0 4px 15px 0 rgba(0,0,0,.15) +} + +.z-depth-2 { + box-shadow: 0 8px 17px 0 rgba(0,0,0,.2),0 6px 20px 0 rgba(0,0,0,.19) +} + +.z-depth-3 { + box-shadow: 0 12px 15px 0 rgba(0,0,0,.24),0 17px 50px 0 rgba(0,0,0,.19) +} + +.z-depth-4 { + box-shadow: 0 16px 28px 0 rgba(0,0,0,.22),0 25px 55px 0 rgba(0,0,0,.21) +} + +.z-depth-5 { + box-shadow: 0 27px 24px 0 rgba(0,0,0,.2),0 40px 77px 0 rgba(0,0,0,.22) +} + +@media (min-width: 768px) { + .container { + width:750px; + width: var(--container-sm) + } + + .details { + padding-top: 80px; + padding-top: calc(var(--navbar-height-short) + 24px) + } +} + +@media (min-width: 992px) { + .container { + width:970px; + width: var(--container-md) + } +} + +@media (min-width: 1200px) { + .container { + width:1170px; + width: var(--container-lg) + } +} + +:root { + --screen-sm-min: 768px; + --screen-md-min: 992px; + --screen-lg-min: 1200px; + --grid-gutter-width: 30px; + --container-sm: calc(720px + var(--grid-gutter-width)); + --container-md: calc(940px + var(--grid-gutter-width)); + --container-lg: calc(1140px + var(--grid-gutter-width)); + --navbar-height: 122px; + --navbar-height-short: 56px; + --summary-height-stacked: 82px; + --statusbar-height-stacked: 54px; + --footer-height: 60px; + --default-transition-duration: 0.2s; + --default-transition-easing: ease; + --gray-base: #000; + --gray-darker-faded: color(var(--gray-darker) alpha(95%)); + --gray-darker: color(var(--gray-base) tint(13.5%)); + --gray-dark: color(var(--gray-base) tint(20%)); + --gray: color(var(--gray-base) tint(33.5%)); + --gray-light: color(var(--gray-base) tint(46.7%)); + --gray-medium: color(var(--gray-base) tint(73.5%)); + --gray-lighter: color(var(--gray-base) tint(93.5%)); + --gray-lighter-faded: color(var(--gray-lighter) alpha(95%)); + --gray-border: color(var(--gray-base) tint(80%)); + --grey50: #eceff1; + --grey100: #f5f5f5; + --grey300: #e0e0e0; + --grey500: #9e9e9e; + --grey700: #616161; + --green100: #c8e6c9; + --green200: #a5d6a7; + --green300: #81c784; + --green500: #4caf50; + --green700: #388e3c; + --red100: #ffcdd2; + --red300: #e57373; + --red500: #f44336; + --red700: #d32f2f; + --ltblue100: #b3e5fc; + --ltblue300: #4fc3f7; + --ltblue500: #03a9f4; + --ltblue700: #0288d1; + --black87: rgba(0,0,0,0.87); + --black54: rgba(0,0,0,0.54); + --black38: rgba(0,0,0,0.38); + --bluegrey500: #607d8b; + --bluegrey800: #37474f; + --bluegrey900: #263238; + --light-icon-active: #fff; + --light-icon-inactive: hsla(0,0%,100%,0.5); + --dark-icon-active: var(--black54); + --dark-icon-inactive: var(--black38); + --amber300: #ffd54f; + --amber400: #ffca28; + --amber500: #ffc107; + --yellow700: #fbc02d; + --yellow800: #f9a825; + --brand-primary: color(#428bca shade(6.5%)); + --brand-success: #4caf50; + --brand-info: #5bc0de; + --brand-warning: #f0ad4e; + --brand-danger: #d9534f; + --text-color: var(--black87); + --body-bg: #f2f2f2; + --link-color: var(--brand-primary); + --link-hover-color: color(var(--link-color) shade(15%)); + --list-group-border: #ddd; + --font-family-sans-serif: "robotoregular","Helvetica Neue",Helvetica,Arial,sans-serif; + --font-family-base: var(--font-family-sans-serif); + --font-family-mono: "Menlo","Monaco","Consolas","Courier New",monospace; + --font-size-base: 14px; + --line-height-base: 1.429; + --line-height-computed: 20px; + --headings-font-family: inherit; + --headings-font-weight: 400; + --headings-line-height: 1.1; + --headings-color: inherit; + --headings-small-color: var(--gray-light); + --font-size-h1: 36px; + --font-size-h2: 30px; + --font-size-h3: 24px; + --font-size-h4: 18px; + --font-size-h5: var(--font-size-base); + --font-size-h6: 12px; + --font-family-light: "robotolight"; + --font-family-regular: "robotoregular"; + --font-family-medium: "robotomedium"; + --link-transition: color 0.2s ease-out +} + +.navbar--trans-color---1tk7E { + transition: color .2s ease-out; + transition: var(--link-transition) +} + +.navbar--component---2UCEi:after,.navbar--component---2UCEi:before { + content: " "; + display: table +} + +.navbar--component---2UCEi:after { + clear: both +} + +.navbar--component---2UCEi { + position: fixed; + -webkit-flex-direction: column; + flex-direction: column; + top: 0; + right: 0; + left: 0; + z-index: 1030; + min-height: 122px; + min-height: var(--navbar-height); + height: 122px; + height: var(--navbar-height); + margin-bottom: 0; + border: none; + background: #37474f; + background: var(--bluegrey800) +} + +.navbar--component---2UCEi,.navbar--report-info-cnt---8y9Bb { + display: -webkit-flex; + display: flex +} + +.navbar--report-info-cnt---8y9Bb { + overflow: hidden; + padding-right: 12px +} + +.navbar--menu-button---1ZRpz { + border: none; + background: transparent; + padding: 0 +} + +.navbar--menu-button---1ZRpz:focus { + box-shadow: 0 0 2px 0 #03a9f4; + box-shadow: 0 0 2px 0 var(--ltblue500); + outline: none +} + +.navbar--menu-button---1ZRpz { + cursor: pointer; + transition: color .2s ease-out; + transition: var(--link-transition); + height: 40px; + margin: 8px 8px 0; + padding: 8px; + color: hsla(0,0%,100%,.5); + color: var(--light-icon-inactive) +} + +.navbar--menu-button---1ZRpz:hover { + color: #fff; + color: var(--light-icon-active) +} + +.navbar--report-title---3bXCv { + -webkit-flex-grow: 1; + flex-grow: 1; + font-family: var(--font-family--light); + color: #fff; + font-size: 18px; + line-height: 52px; + line-height: calc(var(--navbar-height-short) - 4px); + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap +} + +.navbar--pct-bar---3EwW-:after,.navbar--pct-bar---3EwW-:before { + content: " "; + display: table +} + +.navbar--pct-bar---3EwW-:after { + clear: both +} + +.navbar--pct-bar---3EwW- { + display: -webkit-flex; + display: flex; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 4px +} + +.navbar--pct-bar---3EwW- .navbar--pass---2oR-w { + background-color: #4caf50; + background-color: var(--green500) +} + +.navbar--pct-bar---3EwW- .navbar--fail---3mN80 { + background-color: #f44336; + background-color: #f4433669; +} + +.navbar--pct-bar---3EwW- .navbar--pend---2iqjh { + background-color: #03a9f4; + background-color: var(--ltblue500) +} + +.navbar--pct-bar-segment---3T0_o { + height: 4px +} + +@media (min-width: 768px) { + .navbar--component---2UCEi { + min-height:56px; + min-height: var(--navbar-height-short); + height: 56px; + height: var(--navbar-height-short); + -webkit-flex-direction: initial; + flex-direction: row + } + + .navbar--report-info-cnt---8y9Bb { + -webkit-flex-grow: 1; + flex-grow: 1 + } +} + +:root { + --screen-sm-min: 768px; + --screen-md-min: 992px; + --screen-lg-min: 1200px; + --grid-gutter-width: 30px; + --container-sm: calc(720px + var(--grid-gutter-width)); + --container-md: calc(940px + var(--grid-gutter-width)); + --container-lg: calc(1140px + var(--grid-gutter-width)); + --navbar-height: 122px; + --navbar-height-short: 56px; + --summary-height-stacked: 82px; + --statusbar-height-stacked: 54px; + --footer-height: 60px; + --default-transition-duration: 0.2s; + --default-transition-easing: ease; + --gray-base: #000; + --gray-darker-faded: color(var(--gray-darker) alpha(95%)); + --gray-darker: color(var(--gray-base) tint(13.5%)); + --gray-dark: color(var(--gray-base) tint(20%)); + --gray: color(var(--gray-base) tint(33.5%)); + --gray-light: color(var(--gray-base) tint(46.7%)); + --gray-medium: color(var(--gray-base) tint(73.5%)); + --gray-lighter: color(var(--gray-base) tint(93.5%)); + --gray-lighter-faded: color(var(--gray-lighter) alpha(95%)); + --gray-border: color(var(--gray-base) tint(80%)); + --grey50: #eceff1; + --grey100: #f5f5f5; + --grey300: #e0e0e0; + --grey500: #9e9e9e; + --grey700: #616161; + --green100: #c8e6c9; + --green200: #a5d6a7; + --green300: #81c784; + --green500: #4caf50; + --green700: #388e3c; + --red100: #ffcdd2; + --red300: #e57373; + --red500: #f44336; + --red700: #d32f2f; + --ltblue100: #b3e5fc; + --ltblue300: #4fc3f7; + --ltblue500: #03a9f4; + --ltblue700: #0288d1; + --black87: rgba(0,0,0,0.87); + --black54: rgba(0,0,0,0.54); + --black38: rgba(0,0,0,0.38); + --bluegrey500: #607d8b; + --bluegrey800: #37474f; + --bluegrey900: #263238; + --light-icon-active: #fff; + --light-icon-inactive: hsla(0,0%,100%,0.5); + --dark-icon-active: var(--black54); + --dark-icon-inactive: var(--black38); + --amber300: #ffd54f; + --amber400: #ffca28; + --amber500: #ffc107; + --yellow700: #fbc02d; + --yellow800: #f9a825; + --brand-primary: color(#428bca shade(6.5%)); + --brand-success: #4caf50; + --brand-info: #5bc0de; + --brand-warning: #f0ad4e; + --brand-danger: #d9534f; + --text-color: var(--black87); + --body-bg: #f2f2f2; + --link-color: var(--brand-primary); + --link-hover-color: color(var(--link-color) shade(15%)); + --list-group-border: #ddd; + --font-family-sans-serif: "robotoregular","Helvetica Neue",Helvetica,Arial,sans-serif; + --font-family-base: var(--font-family-sans-serif); + --font-family-mono: "Menlo","Monaco","Consolas","Courier New",monospace; + --font-size-base: 14px; + --line-height-base: 1.429; + --line-height-computed: 20px; + --headings-font-family: inherit; + --headings-font-weight: 400; + --headings-line-height: 1.1; + --headings-color: inherit; + --headings-small-color: var(--gray-light); + --font-size-h1: 36px; + --font-size-h2: 30px; + --font-size-h3: 24px; + --font-size-h4: 18px; + --font-size-h5: var(--font-size-base); + --font-size-h6: 12px; + --font-family-light: "robotolight"; + --font-family-regular: "robotoregular"; + --font-family-medium: "robotomedium"; + --link-transition: color 0.2s ease-out +} + +.quick-summary--trans-color---HUJqE { + transition: color .2s ease-out; + transition: var(--link-transition) +} + +.quick-summary--cnt---3s38x { + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + flex-direction: column; + padding: 0 12px +} + +.quick-summary--list---2_80W:after,.quick-summary--list---2_80W:before { + content: " "; + display: table +} + +.quick-summary--list---2_80W:after { + clear: both +} + +.quick-summary--list---2_80W { + list-style: none; + padding-left: 0; + transition: opacity .2s ease-out; + margin: 0 0 8px +} + +.quick-summary--item---bfSQ0,.quick-summary--list---2_80W { + display: -webkit-flex; + display: flex +} + +.quick-summary--item---bfSQ0 { + font-family: var(--font-family--light); + -webkit-align-items: flex-start; + align-items: flex-start; + color: #fff; + font-size: 16px; + -webkit-flex-basis: 25%; + flex-basis: 25% +} + +.quick-summary--item---bfSQ0 button { + border: none; + background: transparent; + padding: 0 +} + +.quick-summary--item---bfSQ0 button:focus { + box-shadow: 0 0 2px 0 #03a9f4; + box-shadow: 0 0 2px 0 var(--ltblue500); + outline: none +} + +.quick-summary--item---bfSQ0 button { + transition: color .2s ease-out; + transition: var(--link-transition); + display: -webkit-flex; + display: flex; + -webkit-align-items: center; + align-items: center; + color: #fff; + cursor: pointer +} + +.quick-summary--item---bfSQ0 button:hover .quick-summary--icon---TW1oG { + border-color: #fff +} + +.quick-summary--item---bfSQ0.quick-summary--tests---2nNut { + color: #fff +} + +.quick-summary--item---bfSQ0.quick-summary--passes---3IjYH .quick-summary--icon---TW1oG { + color: #388e3c; + color: var(--green700); + background-color: #c8e6c9; + background-color: var(--green100) +} + +.quick-summary--single-filter---31Thy .quick-summary--item---bfSQ0.quick-summary--passes---3IjYH .quick-summary--icon---TW1oG { + background-color: #e0e0e0; + background-color: var(--grey300); + color: #9e9e9e; + color: var(--grey500) +} + +.quick-summary--single-filter--passed---3QnUL .quick-summary--item---bfSQ0.quick-summary--passes---3IjYH .quick-summary--icon---TW1oG { + color: #fff; + background-color: #388e3c; + background-color: var(--green700) +} + +.quick-summary--item---bfSQ0.quick-summary--failures---14s29 .quick-summary--icon---TW1oG { + color: #d32f2f; + color: var(--red700); + background-color: #ffcdd2; + background-color: var(--red100) +} + +.quick-summary--single-filter---31Thy .quick-summary--item---bfSQ0.quick-summary--failures---14s29 .quick-summary--icon---TW1oG { + background-color: #e0e0e0; + background-color: var(--grey300); + color: #9e9e9e; + color: var(--grey500) +} + +.quick-summary--single-filter--failed---3_tAw .quick-summary--item---bfSQ0.quick-summary--failures---14s29 .quick-summary--icon---TW1oG { + color: #fff; + background-color: #d32f2f; + background-color: var(--red700) +} + +.quick-summary--item---bfSQ0.quick-summary--pending---261aV .quick-summary--icon---TW1oG { + color: #0288d1; + color: var(--ltblue700); + background-color: #b3e5fc; + background-color: var(--ltblue100) +} + +.quick-summary--single-filter---31Thy .quick-summary--item---bfSQ0.quick-summary--pending---261aV .quick-summary--icon---TW1oG { + background-color: #e0e0e0; + background-color: var(--grey300); + color: #9e9e9e; + color: var(--grey500) +} + +.quick-summary--single-filter--pending---21lZM .quick-summary--item---bfSQ0.quick-summary--pending---261aV .quick-summary--icon---TW1oG { + color: #fff; + background-color: #0288d1; + background-color: var(--ltblue700) +} + +.quick-summary--item---bfSQ0.quick-summary--skipped---tyOc4 .quick-summary--icon---TW1oG { + color: #616161; + color: var(--grey700); + background-color: #f5f5f5; + background-color: var(--grey100) +} + +.quick-summary--single-filter---31Thy .quick-summary--item---bfSQ0.quick-summary--skipped---tyOc4 .quick-summary--icon---TW1oG { + background-color: #e0e0e0; + background-color: var(--grey300); + color: #9e9e9e; + color: var(--grey500) +} + +.quick-summary--single-filter--skipped---1AdZA .quick-summary--item---bfSQ0.quick-summary--skipped---tyOc4 .quick-summary--icon---TW1oG { + color: #fff; + background-color: #616161; + background-color: var(--grey700) +} + +.quick-summary--icon---TW1oG { + position: relative; + top: 2px; + font-size: 18px; + margin-right: 4px +} + +.quick-summary--circle-icon---1HDS7 { + font-size: 12px; + border-radius: 50%; + padding: 2px; + border: 1px solid transparent; + transition: border-color .2s ease-out +} + +@media (min-width: 768px) { + .quick-summary--cnt---3s38x { + -webkit-flex-direction:initial; + flex-direction: row; + padding: 14px 12px 0 0 + } + + .quick-summary--list---2_80W { + margin: 0 + } + + .quick-summary--item---bfSQ0 { + font-size: 18px; + -webkit-flex-basis: initial; + flex-basis: auto; + margin: 0 12px + } + + .quick-summary--icon---TW1oG { + font-size: 24px; + width: 24px; + top: 0 + } + + .quick-summary--circle-icon---1HDS7 { + font-size: 18px + } +} + +:root { + --screen-sm-min: 768px; + --screen-md-min: 992px; + --screen-lg-min: 1200px; + --grid-gutter-width: 30px; + --container-sm: calc(720px + var(--grid-gutter-width)); + --container-md: calc(940px + var(--grid-gutter-width)); + --container-lg: calc(1140px + var(--grid-gutter-width)); + --navbar-height: 122px; + --navbar-height-short: 56px; + --summary-height-stacked: 82px; + --statusbar-height-stacked: 54px; + --footer-height: 60px; + --default-transition-duration: 0.2s; + --default-transition-easing: ease; + --gray-base: #000; + --gray-darker-faded: color(var(--gray-darker) alpha(95%)); + --gray-darker: color(var(--gray-base) tint(13.5%)); + --gray-dark: color(var(--gray-base) tint(20%)); + --gray: color(var(--gray-base) tint(33.5%)); + --gray-light: color(var(--gray-base) tint(46.7%)); + --gray-medium: color(var(--gray-base) tint(73.5%)); + --gray-lighter: color(var(--gray-base) tint(93.5%)); + --gray-lighter-faded: color(var(--gray-lighter) alpha(95%)); + --gray-border: color(var(--gray-base) tint(80%)); + --grey50: #eceff1; + --grey100: #f5f5f5; + --grey300: #e0e0e0; + --grey500: #9e9e9e; + --grey700: #616161; + --green100: #c8e6c9; + --green200: #a5d6a7; + --green300: #81c784; + --green500: #4caf50; + --green700: #388e3c; + --red100: #ffcdd2; + --red300: #e57373; + --red500: #f44336; + --red700: #d32f2f; + --ltblue100: #b3e5fc; + --ltblue300: #4fc3f7; + --ltblue500: #03a9f4; + --ltblue700: #0288d1; + --black87: rgba(0,0,0,0.87); + --black54: rgba(0,0,0,0.54); + --black38: rgba(0,0,0,0.38); + --bluegrey500: #607d8b; + --bluegrey800: #37474f; + --bluegrey900: #263238; + --light-icon-active: #fff; + --light-icon-inactive: hsla(0,0%,100%,0.5); + --dark-icon-active: var(--black54); + --dark-icon-inactive: var(--black38); + --amber300: #ffd54f; + --amber400: #ffca28; + --amber500: #ffc107; + --yellow700: #fbc02d; + --yellow800: #f9a825; + --brand-primary: color(#428bca shade(6.5%)); + --brand-success: #4caf50; + --brand-info: #5bc0de; + --brand-warning: #f0ad4e; + --brand-danger: #d9534f; + --text-color: var(--black87); + --body-bg: #f2f2f2; + --link-color: var(--brand-primary); + --link-hover-color: color(var(--link-color) shade(15%)); + --list-group-border: #ddd; + --font-family-sans-serif: "robotoregular","Helvetica Neue",Helvetica,Arial,sans-serif; + --font-family-base: var(--font-family-sans-serif); + --font-family-mono: "Menlo","Monaco","Consolas","Courier New",monospace; + --font-size-base: 14px; + --line-height-base: 1.429; + --line-height-computed: 20px; + --headings-font-family: inherit; + --headings-font-weight: 400; + --headings-line-height: 1.1; + --headings-color: inherit; + --headings-small-color: var(--gray-light); + --font-size-h1: 36px; + --font-size-h2: 30px; + --font-size-h3: 24px; + --font-size-h4: 18px; + --font-size-h5: var(--font-size-base); + --font-size-h6: 12px; + --font-family-light: "robotolight"; + --font-family-regular: "robotoregular"; + --font-family-medium: "robotomedium"; + --link-transition: color 0.2s ease-out +} + +.radio-button--trans-color---egsik { + transition: color .2s ease-out; + transition: var(--link-transition) +} + +.radio-button--component---1ix3c:after,.radio-button--component---1ix3c:before { + content: " "; + display: table +} + +.radio-button--component---1ix3c:after { + clear: both +} + +.radio-button--component---1ix3c { + position: relative; + height: 24px +} + +.radio-button--outer---a_NqL { + position: absolute; + top: 50%; + right: 0; + margin-top: -9px; + width: 18px; + height: 18px; + border: 2px solid #4caf50; + border: 2px solid var(--green500); + border-radius: 12px; + cursor: pointer; + transition: border-color .2s ease-out +} + +.radio-button--off---dBAOK { + border-color: color(#000 tint(73.5%)); + border-color: var(--gray-medium) +} + +.radio-button--inner---3bo9Q { + display: block; + position: absolute; + top: 2px; + right: 2px; + width: 10px; + height: 10px; + border-radius: 100%; + background-color: #4caf50; + background-color: var(--green500) +} + +.radio-button--off---dBAOK .radio-button--inner---3bo9Q { + background-color: #fff; + -webkit-transform: scale(0); + transform: scale(0) +} + +.radio-button--inner---3bo9Q { + transition: all .15s cubic-bezier(.23,1,.32,1) +} + +:root { + --screen-sm-min: 768px; + --screen-md-min: 992px; + --screen-lg-min: 1200px; + --grid-gutter-width: 30px; + --container-sm: calc(720px + var(--grid-gutter-width)); + --container-md: calc(940px + var(--grid-gutter-width)); + --container-lg: calc(1140px + var(--grid-gutter-width)); + --navbar-height: 122px; + --navbar-height-short: 56px; + --summary-height-stacked: 82px; + --statusbar-height-stacked: 54px; + --footer-height: 60px; + --default-transition-duration: 0.2s; + --default-transition-easing: ease; + --gray-base: #000; + --gray-darker-faded: color(var(--gray-darker) alpha(95%)); + --gray-darker: color(var(--gray-base) tint(13.5%)); + --gray-dark: color(var(--gray-base) tint(20%)); + --gray: color(var(--gray-base) tint(33.5%)); + --gray-light: color(var(--gray-base) tint(46.7%)); + --gray-medium: color(var(--gray-base) tint(73.5%)); + --gray-lighter: color(var(--gray-base) tint(93.5%)); + --gray-lighter-faded: color(var(--gray-lighter) alpha(95%)); + --gray-border: color(var(--gray-base) tint(80%)); + --grey50: #eceff1; + --grey100: #f5f5f5; + --grey300: #e0e0e0; + --grey500: #9e9e9e; + --grey700: #616161; + --green100: #c8e6c9; + --green200: #a5d6a7; + --green300: #81c784; + --green500: #4caf50; + --green700: #388e3c; + --red100: #ffcdd2; + --red300: #e57373; + --red500: #f44336; + --red700: #d32f2f; + --ltblue100: #b3e5fc; + --ltblue300: #4fc3f7; + --ltblue500: #03a9f4; + --ltblue700: #0288d1; + --black87: rgba(0,0,0,0.87); + --black54: rgba(0,0,0,0.54); + --black38: rgba(0,0,0,0.38); + --bluegrey500: #607d8b; + --bluegrey800: #37474f; + --bluegrey900: #263238; + --light-icon-active: #fff; + --light-icon-inactive: hsla(0,0%,100%,0.5); + --dark-icon-active: var(--black54); + --dark-icon-inactive: var(--black38); + --amber300: #ffd54f; + --amber400: #ffca28; + --amber500: #ffc107; + --yellow700: #fbc02d; + --yellow800: #f9a825; + --brand-primary: color(#428bca shade(6.5%)); + --brand-success: #4caf50; + --brand-info: #5bc0de; + --brand-warning: #f0ad4e; + --brand-danger: #d9534f; + --text-color: var(--black87); + --body-bg: #f2f2f2; + --link-color: var(--brand-primary); + --link-hover-color: color(var(--link-color) shade(15%)); + --list-group-border: #ddd; + --font-family-sans-serif: "robotoregular","Helvetica Neue",Helvetica,Arial,sans-serif; + --font-family-base: var(--font-family-sans-serif); + --font-family-mono: "Menlo","Monaco","Consolas","Courier New",monospace; + --font-size-base: 14px; + --line-height-base: 1.429; + --line-height-computed: 20px; + --headings-font-family: inherit; + --headings-font-weight: 400; + --headings-line-height: 1.1; + --headings-color: inherit; + --headings-small-color: var(--gray-light); + --font-size-h1: 36px; + --font-size-h2: 30px; + --font-size-h3: 24px; + --font-size-h4: 18px; + --font-size-h5: var(--font-size-base); + --font-size-h6: 12px; + --font-family-light: "robotolight"; + --font-family-regular: "robotoregular"; + --font-family-medium: "robotomedium"; + --link-transition: color 0.2s ease-out +} + +.test--trans-color---3sP2r { + transition: color .2s ease-out; + transition: var(--link-transition) +} + +.test--component---1mwsi { + border-bottom: 1px solid #e0e0e0; + border-bottom: 1px solid var(--grey300) +} + +.test--component---1mwsi.test--expanded---3hI0z.test--passed---38wAs .test--body-wrap---3EGPT,.test--component---1mwsi.test--expanded---3hI0z.test--passed---38wAs .test--header-btn---mI0Oy { + border-left-color: #4caf50; + border-left-color: var(--green500) +} + +.test--component---1mwsi.test--expanded---3hI0z.test--failed---2PZhW .test--body-wrap---3EGPT,.test--component---1mwsi.test--expanded---3hI0z.test--failed---2PZhW .test--header-btn---mI0Oy { + border-left-color: #f44336; + border-left-color: var(--red500) +} + +.test--list---24Hjy { + list-style-type: none; + margin: 0; + padding: 0 +} + +.test--header-btn---mI0Oy { + display: -webkit-flex; + display: flex; + position: relative; + background: #252525; + border: none; + border-left: 3px solid transparent; + cursor: pointer; + -webkit-flex-wrap: wrap; + flex-wrap: wrap; + padding: 10px 16px 10px 13px; + transition: border-color .2s ease-out; + width: 100% +} + +.test--header-btn---mI0Oy[disabled] { + cursor: default +} + +.test--header-btn---mI0Oy:focus { + box-shadow: 0 0 2px 0 #03a9f4; + box-shadow: 0 0 2px 0 var(--ltblue500); + outline: none +} + +.test--header-btn---mI0Oy:focus:not([disabled]),.test--header-btn---mI0Oy:hover:not([disabled]) { + border-left-color: #9e9e9e; + border-left-color: var(--grey500) +} + +.test--title---4c0rg { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + -webkit-flex-grow: 1; + flex-grow: 1; + font-family: var(--font-family--regular); + font-size: 13px; + line-height: 24px; + margin: 0; + padding-right: 12px; + text-align: left +} + +.test--hook---3T4lI .test--title---4c0rg { + color: rgba(0,0,0,.54); + color: var(--black54) +} + +.test--expanded---3hI0z .test--title---4c0rg { + line-height: 1.5; + padding-top: 3px; + white-space: normal +} + +.test--icon---2jgH_ { + -webkit-align-self: flex-start; + align-self: flex-start; + padding: 3px; + border-radius: 50%; + color: #fff; + margin-right: 16px +} + +.test--icon---2jgH_.test--pass---C1Mk7 { + color: #c8e6c9; + color: var(--green100); + background-color: #4caf50; + background-color: var(--green500) +} + +.test--icon---2jgH_.test--fail---3u2w0 { + color: #ffcdd2; + color: var(--red100); + background-color: #f44336; + background-color: var(--red500) +} + +.test--icon---2jgH_.test--pending---3Ctfm { + color: #b3e5fc; + color: var(--ltblue100); + background-color: #03a9f4; + background-color: var(--ltblue500) +} + +.test--icon---2jgH_.test--skipped---3aU0Y { + color: #f5f5f5; + color: var(--grey100); + background-color: #9e9e9e; + background-color: var(--grey500) +} + +.test--icon---2jgH_.test--hook---3T4lI { + color: rgba(0,0,0,.38); + color: var(--black38); + padding: 0 +} + +.test--failed---2PZhW .test--icon---2jgH_.test--hook---3T4lI { + color: #f44336; + color: var(--red500) +} + +.test--info---1UQNw { + display: -webkit-flex; + display: flex +} + +.test--duration---2tVp5 { + font-family: var(--font-family--regular); + line-height: 24px; + color: rgba(0,0,0,.54); + color: var(--black54) +} + +.test--component---1mwsi:hover:not(.test--pending---3Ctfm) .test--duration---2tVp5,.test--expanded---3hI0z .test--duration---2tVp5 { + color: rgba(0,0,0,.87); + color: var(--black87) +} + +.test--duration---2tVp5 { + transition: color .2s ease-out +} + +.test--duration-icon---2KnOU { + margin-left: 4px; + line-height: 24px!important; + color: rgba(0,0,0,.38); + color: var(--black38) +} + +.test--duration-icon---2KnOU.test--slow---MQOnF { + color: #e57373; + color: var(--red300) +} + +.test--duration-icon---2KnOU.test--medium---5j890 { + color: #fbc02d; + color: var(--yellow700) +} + +.test--context-icon---2POzC { + position: relative; + line-height: 24px!important; + color: rgba(0,0,0,.38); + color: var(--black38); + margin-right: 8px; + top: 1px +} + +.test--body-wrap---3EGPT { + border-left: 3px solid transparent; + transition: border-color .2s ease-out +} + +.test--expanded---3hI0z .test--body-wrap---3EGPT { + display: block; + padding-bottom: 10px +} + +.test--body---Ox0q_ { + display: none; + background-color: #181818; + border: 1px solid #eceff1; + border: 1px solid var(--grey50); + border-radius: 4px +} + +.test--expanded---3hI0z .test--body---Ox0q_ { + display: block; + margin: 0 16px 0 13px +} + +.test--error-message---3Grn0 { + color: #f44336; + color: var(--red500); + font-size: 12px; + margin: 10px 0 0; + text-align: left; + width: 100%; + word-break: break-word +} + +.test--code-snippet---3H5Xj { + position: relative; + font-size: 11px; + margin: 0; + border-radius: 0 +} + +.test--code-snippet---3H5Xj+.test--code-snippet---3H5Xj { + border-top: 1px solid #fff +} + +.test--code-snippet---3H5Xj.hljs { + padding: 1em; + background: none; +} + +.test--code-diff---2XQsb code>span:first-child { + margin-right: 11px +} + +.test--code-diff-expected---1QWLl span { + color: #859900 +} + +.test--inline-diff---3OmYO .test--code-diff-expected---1QWLl { + background-color: #859900; + color: #fff +} + +.test--code-diff-actual---3MMxN span { + color: #dc322f +} + +.test--inline-diff---3OmYO .test--code-diff-actual---3MMxN { + background-color: #dc322f; + color: #fff +} + +.test--code-label---1QEUY { + position: absolute; + font-family: var(--font-family--regular); + top: 0; + right: 0; + padding: .2em .6em; + background-color: #9e9e9e; + background-color: var(--grey500); + color: #fff +} + +.test--context---1YYgX { + background-color: #fff; + border-top: 1px solid #eceff1; + border-top: 1px solid var(--grey50); + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px +} + +.test--context-title---HHH10 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-family--regular); + font-size: 13px; + color: rgba(0,0,0,.54); + color: var(--black54); + margin: 0; + padding: 11px 11px 0 +} + +.test--context-item---R1NNU { + padding-top: 11px +} + +.test--context-item---R1NNU .test--code-snippet---3H5Xj { + padding-top: 0 +} + +.test--context-item-title---1KxIO { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-family--medium); + font-size: 13px; + margin: 0; + padding: 0 11px 11px +} + +.test--text-link---2_cSn { + display: inline-block; + padding: 0 1em 1em; + font-family: Menlo,Monaco,Consolas,Courier New,monospace; + font-family: var(--font-family-mono); + font-size: 11px; + color: #0288d1; + color: var(--ltblue700) +} + +.test--text-link---2_cSn:hover { + color: #03a9f4; + color: var(--ltblue500) +} + +.test--image-link---PUFPJ,.test--video-link---1L-2D { + display: inline-block; + font-size: 11px; + padding: 0 1em 1em +} + +.test--image---2Z5X2,.test--video---2JK7O { + display: block; + max-width: 100%; + height: auto +} + +:root { + --screen-sm-min: 768px; + --screen-md-min: 992px; + --screen-lg-min: 1200px; + --grid-gutter-width: 30px; + --container-sm: calc(720px + var(--grid-gutter-width)); + --container-md: calc(940px + var(--grid-gutter-width)); + --container-lg: calc(1140px + var(--grid-gutter-width)); + --navbar-height: 122px; + --navbar-height-short: 56px; + --summary-height-stacked: 82px; + --statusbar-height-stacked: 54px; + --footer-height: 60px; + --default-transition-duration: 0.2s; + --default-transition-easing: ease; + --gray-base: #000; + --gray-darker-faded: color(var(--gray-darker) alpha(95%)); + --gray-darker: color(var(--gray-base) tint(13.5%)); + --gray-dark: color(var(--gray-base) tint(20%)); + --gray: color(var(--gray-base) tint(33.5%)); + --gray-light: color(var(--gray-base) tint(46.7%)); + --gray-medium: color(var(--gray-base) tint(73.5%)); + --gray-lighter: color(var(--gray-base) tint(93.5%)); + --gray-lighter-faded: color(var(--gray-lighter) alpha(95%)); + --gray-border: color(var(--gray-base) tint(80%)); + --grey50: #eceff1; + --grey100: #f5f5f5; + --grey300: #e0e0e0; + --grey500: #9e9e9e; + --grey700: #616161; + --green100: #c8e6c9; + --green200: #a5d6a7; + --green300: #81c784; + --green500: #4caf50; + --green700: #388e3c; + --red100: #ffcdd2; + --red300: #e57373; + --red500: #f44336; + --red700: #d32f2f; + --ltblue100: #b3e5fc; + --ltblue300: #4fc3f7; + --ltblue500: #03a9f4; + --ltblue700: #0288d1; + --black87: rgba(0,0,0,0.87); + --black54: rgba(0,0,0,0.54); + --black38: rgba(0,0,0,0.38); + --bluegrey500: #607d8b; + --bluegrey800: #37474f; + --bluegrey900: #263238; + --light-icon-active: #fff; + --light-icon-inactive: hsla(0,0%,100%,0.5); + --dark-icon-active: var(--black54); + --dark-icon-inactive: var(--black38); + --amber300: #ffd54f; + --amber400: #ffca28; + --amber500: #ffc107; + --yellow700: #fbc02d; + --yellow800: #f9a825; + --brand-primary: color(#428bca shade(6.5%)); + --brand-success: #4caf50; + --brand-info: #5bc0de; + --brand-warning: #f0ad4e; + --brand-danger: #d9534f; + --text-color: var(--black87); + --body-bg: #f2f2f2; + --link-color: var(--brand-primary); + --link-hover-color: color(var(--link-color) shade(15%)); + --list-group-border: #ddd; + --font-family-sans-serif: "robotoregular","Helvetica Neue",Helvetica,Arial,sans-serif; + --font-family-base: var(--font-family-sans-serif); + --font-family-mono: "Menlo","Monaco","Consolas","Courier New",monospace; + --font-size-base: 14px; + --line-height-base: 1.429; + --line-height-computed: 20px; + --headings-font-family: inherit; + --headings-font-weight: 400; + --headings-line-height: 1.1; + --headings-color: inherit; + --headings-small-color: var(--gray-light); + --font-size-h1: 36px; + --font-size-h2: 30px; + --font-size-h3: 24px; + --font-size-h4: 18px; + --font-size-h5: var(--font-size-base); + --font-size-h6: 12px; + --font-family-light: "robotolight"; + --font-family-regular: "robotoregular"; + --font-family-medium: "robotomedium"; + --link-transition: color 0.2s ease-out +} + +.suite--trans-color---2pu6T { + transition: color .2s ease-out; + transition: var(--link-transition) +} + +.suite--component---22Vxk:after,.suite--component---22Vxk:before { + content: " "; + display: table +} + +.suite--component---22Vxk:after { + clear: both +} + +.suite--component---22Vxk { + position: relative; + background-color: #141414; + margin-bottom: 20px +} + +.suite--component---22Vxk>.suite--body---1itCO>ul>li>.suite--component---22Vxk { + border: 1px solid #e0e0e0; + border: 1px solid var(--grey300); + border-right: none; + border-bottom: none; + margin: 16px 0 16px 16px +} + +.suite--component---22Vxk>.suite--body---1itCO>ul>li>.suite--component---22Vxk.suite--no-tests---l47BS { + border-bottom: 1px solid #e0e0e0; + border-bottom: 1px solid var(--grey300) +} + +.suite--list---3WtMK { + list-style-type: none; + margin: 0; + padding: 0 +} + +.suite--list-main---3KCXR>li>.suite--component---22Vxk,.suite--root-suite---ZDRuj { + box-shadow: 0 2px 5px 0 rgb(197 197 197 / 16%), 0 2px 10px 0 rgb(57 57 57 / 12%); + margin: 0 0 24px +} + +.suite--list-main---3KCXR>.suite--no-tests---l47BS>.suite--body---1itCO>ul>li>.suite--component---22Vxk:not(.suite--no-suites---2PQFQ) { + border-bottom: 1px solid #e0e0e0; + border-bottom: 1px solid var(--grey300) +} + +.suite--header---TddSn:after,.suite--header---TddSn:before { + content: " "; + display: table +} + +.suite--header---TddSn:after { + clear: both +} + +.suite--header---TddSn { + border-bottom: 1px solid #e0e0e0; + border-bottom: 1px solid var(--grey300) +} + +.suite--no-tests---l47BS>.suite--header---TddSn { + padding-bottom: 0; + border-bottom: none +} + +.suite--header-btn---25qLz { + background: #1d1c1c; + border: none; + cursor: pointer; + padding: 12px 16px; + text-align: left; + width: 100% +} + +.suite--header-btn---25qLz:focus { + box-shadow: 0 0 2px 0 #03a9f4; + box-shadow: 0 0 2px 0 var(--ltblue500); + outline: none +} + +.suite--title---3T6OR { + display: -webkit-flex; + display: flex; + font-family: var(--font-family--light); + font-size: 21px; + margin: 0 +} + +.suite--title---3T6OR span { + margin-right: auto +} + +.suite--title---3T6OR .suite--icon---2KPe5 { + margin-left: 58px +} + +.suite--filename---1u8oo { + color: rgba(0,0,0,.54); + color: var(--black54); + font-family: var(--font-family--regular); + margin: 6px 0 0 +} + +.suite--body---1itCO:after,.suite--body---1itCO:before { + content: " "; + display: table +} + +.suite--body---1itCO:after { + clear: both +} + +.suite--body---1itCO.suite--hide---2i8QF { + display: none +} + +.suite--has-suites---3OYDf>.suite--body---1itCO { + border-bottom: 1px solid #e0e0e0; + border-bottom: 1px solid var(--grey300) +} + +.suite--chart-wrap---7hvUh { + display: none; + position: absolute; + top: 12px; + right: 36px; + width: 50px; + height: 50px +} + +.suite--chart-slice---1XN2j { + stroke: #fff; + stroke-width: 2px +} + +.ct-series-a .suite--chart-slice---1XN2j { + fill: #4caf50; + fill: var(--green500) +} + +.ct-series-b .suite--chart-slice---1XN2j { + fill: #f44336; + fill: var(--red500) +} + +.ct-series-c .suite--chart-slice---1XN2j { + fill: #03a9f4; + fill: var(--ltblue500) +} + +.ct-series-d .suite--chart-slice---1XN2j { + fill: rgba(0,0,0,.38); + fill: var(--black38) +} + +@media (min-width: 768px) { + .suite--chart-wrap---7hvUh { + display:block + } + + .suite--chart-enabled---1N-VF:not(.suite--no-tests---l47BS) .suite--header---TddSn { + min-height: 66px + } +} + +:root { + --screen-sm-min: 768px; + --screen-md-min: 992px; + --screen-lg-min: 1200px; + --grid-gutter-width: 30px; + --container-sm: calc(720px + var(--grid-gutter-width)); + --container-md: calc(940px + var(--grid-gutter-width)); + --container-lg: calc(1140px + var(--grid-gutter-width)); + --navbar-height: 122px; + --navbar-height-short: 56px; + --summary-height-stacked: 82px; + --statusbar-height-stacked: 54px; + --footer-height: 60px; + --default-transition-duration: 0.2s; + --default-transition-easing: ease; + --gray-base: #000; + --gray-darker-faded: color(var(--gray-darker) alpha(95%)); + --gray-darker: color(var(--gray-base) tint(13.5%)); + --gray-dark: color(var(--gray-base) tint(20%)); + --gray: color(var(--gray-base) tint(33.5%)); + --gray-light: color(var(--gray-base) tint(46.7%)); + --gray-medium: color(var(--gray-base) tint(73.5%)); + --gray-lighter: color(var(--gray-base) tint(93.5%)); + --gray-lighter-faded: color(var(--gray-lighter) alpha(95%)); + --gray-border: color(var(--gray-base) tint(80%)); + --grey50: #eceff1; + --grey100: #f5f5f5; + --grey300: #e0e0e0; + --grey500: #9e9e9e; + --grey700: #616161; + --green100: #c8e6c9; + --green200: #a5d6a7; + --green300: #81c784; + --green500: #4caf50; + --green700: #388e3c; + --red100: #ffcdd2; + --red300: #e57373; + --red500: #f44336; + --red700: #d32f2f; + --ltblue100: #b3e5fc; + --ltblue300: #4fc3f7; + --ltblue500: #03a9f4; + --ltblue700: #0288d1; + --black87: rgba(0,0,0,0.87); + --black54: rgba(0,0,0,0.54); + --black38: rgba(0,0,0,0.38); + --bluegrey500: #607d8b; + --bluegrey800: #37474f; + --bluegrey900: #263238; + --light-icon-active: #fff; + --light-icon-inactive: hsla(0,0%,100%,0.5); + --dark-icon-active: var(--black54); + --dark-icon-inactive: var(--black38); + --amber300: #ffd54f; + --amber400: #ffca28; + --amber500: #ffc107; + --yellow700: #fbc02d; + --yellow800: #f9a825; + --brand-primary: color(#428bca shade(6.5%)); + --brand-success: #4caf50; + --brand-info: #5bc0de; + --brand-warning: #f0ad4e; + --brand-danger: #d9534f; + --text-color: var(--black87); + --body-bg: #f2f2f2; + --link-color: var(--brand-primary); + --link-hover-color: color(var(--link-color) shade(15%)); + --list-group-border: #ddd; + --font-family-sans-serif: "robotoregular","Helvetica Neue",Helvetica,Arial,sans-serif; + --font-family-base: var(--font-family-sans-serif); + --font-family-mono: "Menlo","Monaco","Consolas","Courier New",monospace; + --font-size-base: 14px; + --line-height-base: 1.429; + --line-height-computed: 20px; + --headings-font-family: inherit; + --headings-font-weight: 400; + --headings-line-height: 1.1; + --headings-color: inherit; + --headings-small-color: var(--gray-light); + --font-size-h1: 36px; + --font-size-h2: 30px; + --font-size-h3: 24px; + --font-size-h4: 18px; + --font-size-h5: var(--font-size-base); + --font-size-h6: 12px; + --font-family-light: "robotolight"; + --font-family-regular: "robotoregular"; + --font-family-medium: "robotomedium"; + --link-transition: color 0.2s ease-out +} + +.suite-summary--trans-color---14JXk { + transition: color .2s ease-out; + transition: var(--link-transition) +} + +.suite-summary--component---cFAkx:after,.suite-summary--component---cFAkx:before { + content: " "; + display: table +} + +.suite-summary--component---cFAkx:after { + clear: both +} + +.suite-summary--component---cFAkx { + list-style: none; + padding-left: 0; + display: -webkit-flex; + display: flex; + font-family: var(--font-family--regular); + font-size: 15px; + margin: 16px 0 0 +} + +.suite-summary--component---cFAkx.suite-summary--no-margin---3WX9n { + margin: 0 +} + +.suite-summary--summary-item---JHYFN { + display: -webkit-flex; + display: flex; + line-height: 18px; + margin: 0 8px; + color: rgba(0,0,0,.54); + color: var(--black54) +} + +.suite-summary--summary-item---JHYFN:first-child { + margin-left: 0 +} + +.suite-summary--summary-item---JHYFN.suite-summary--duration---AzGUQ,.suite-summary--summary-item---JHYFN.suite-summary--tests---3Zhct { + color: rgba(0,0,0,.54); + color: var(--black54) +} + +.suite-summary--summary-item---JHYFN.suite-summary--passed---24BnC { + color: #4caf50; + color: var(--green500) +} + +.suite-summary--summary-item---JHYFN.suite-summary--failed---205C4 { + color: #f44336; + color: var(--red500) +} + +.suite-summary--summary-item---JHYFN.suite-summary--pending---3_Nkj { + color: #03a9f4; + color: var(--ltblue500) +} + +.suite-summary--summary-item---JHYFN.suite-summary--skipped---TovqF { + color: rgba(0,0,0,.38); + color: var(--black38) +} + +.suite-summary--icon---3rZ6G { + margin-right: 2px +} + +:root { + color-scheme: dark; + --screen-sm-min: 768px; + --screen-md-min: 992px; + --screen-lg-min: 1200px; + --grid-gutter-width: 30px; + --container-sm: calc(720px + var(--grid-gutter-width)); + --container-md: calc(940px + var(--grid-gutter-width)); + --container-lg: calc(1140px + var(--grid-gutter-width)); + --navbar-height: 122px; + --navbar-height-short: 56px; + --summary-height-stacked: 82px; + --statusbar-height-stacked: 54px; + --footer-height: 60px; + --default-transition-duration: 0.2s; + --default-transition-easing: ease; + --gray-base: #000; + --gray-darker-faded: color(var(--gray-darker) alpha(95%)); + --gray-darker: color(var(--gray-base) tint(13.5%)); + --gray-dark: color(var(--gray-base) tint(20%)); + --gray: color(var(--gray-base) tint(33.5%)); + --gray-light: color(var(--gray-base) tint(46.7%)); + --gray-medium: color(var(--gray-base) tint(73.5%)); + --gray-lighter: color(var(--gray-base) tint(93.5%)); + --gray-lighter-faded: color(var(--gray-lighter) alpha(95%)); + --gray-border: color(var(--gray-base) tint(80%)); + --grey50: #eceff1; + --grey100: #f5f5f5; + --grey300: #828282; + --grey500: #9e9e9e; + --grey700: #616161; + --green100: #c8e6c9; + --green200: #a5d6a7; + --green300: #81c784; + --green500: #4caf50; + --green700: #388e3c; + --red100: #ffcdd2; + --red300: #e57373; + --red500: #f44336; + --red700: #d32f2f; + --ltblue100: #b3e5fc; + --ltblue300: #4fc3f7; + --ltblue500: #03a9f4; + --ltblue700: #0288d1; + --black87: rgb(188 188 188); + --black54: rgb(255 255 255 / 54%); + --black38: rgba(0,0,0,0.38); + --bluegrey500: #607d8b; + --bluegrey800: #37474f; + --bluegrey900: #263238; + --light-icon-active: #fff; + --light-icon-inactive: hsla(0,0%,100%,0.5); + --dark-icon-active: var(--black54); + --dark-icon-inactive: var(--black38); + --amber300: #ffd54f; + --amber400: #ffca28; + --amber500: #ffc107; + --yellow700: #fbc02d; + --yellow800: #f9a825; + --brand-primary: color(#428bca shade(6.5%)); + --brand-success: #4caf50; + --brand-info: #5bc0de; + --brand-warning: #f0ad4e; + --brand-danger: #d9534f; + --text-color: var(--black87); + --body-bg: #1b1a1a; + --link-color: var(--brand-primary); + --link-hover-color: color(var(--link-color) shade(15%)); + --list-group-border: #ddd; + --font-family-sans-serif: "robotoregular","Helvetica Neue",Helvetica,Arial,sans-serif; + --font-family-base: var(--font-family-sans-serif); + --font-family-mono: "Menlo","Monaco","Consolas","Courier New",monospace; + --font-size-base: 14px; + --line-height-base: 1.429; + --line-height-computed: 20px; + --headings-font-family: inherit; + --headings-font-weight: 400; + --headings-line-height: 1.1; + --headings-color: #d1d1d1; + --headings-small-color: var(--gray-light); + --font-size-h1: 36px; + --font-size-h2: 30px; + --font-size-h3: 24px; + --font-size-h4: 18px; + --font-size-h5: var(--font-size-base); + --font-size-h6: 12px; + --font-family-light: "robotolight"; + --font-family-regular: "robotoregular"; + --font-family-medium: "robotomedium"; + --link-transition: color 0.2s ease-out +} + +.toggle-switch--trans-color---16in9 { + transition: color .2s ease-out; + transition: var(--link-transition) +} + +.toggle-switch--component---3vjvh:after,.toggle-switch--component---3vjvh:before { + content: " "; + display: table +} + +.toggle-switch--component---3vjvh:after { + clear: both +} + +.toggle-switch--component---3vjvh { + height: 24px +} + +.toggle-switch--label---1Lu8U { + display: -webkit-flex; + display: flex; + -webkit-align-items: center; + align-items: center +} + +.toggle-switch--toggle-input---3BB7e { + position: absolute; + opacity: 0 +} + +.toggle-switch--toggle-input---3BB7e:checked+.toggle-switch--toggle---2kPqc { + background-color: #a5d6a7; + background-color: var(--green200) +} + +.toggle-switch--toggle-input---3BB7e:checked+.toggle-switch--toggle---2kPqc:before { + background-color: #4caf50; + background-color: var(--green500); + -webkit-transform: translateX(14px); + transform: translateX(14px) +} + +.toggle-switch--toggle-input---3BB7e:focus+.toggle-switch--toggle---2kPqc:before { + box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12),0 0 2px 0 #03a9f4; + box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12),0 0 2px 0 var(--ltblue500) +} + +.toggle-switch--toggle---2kPqc { + display: inline-block; + position: relative; + background-color: #e0e0e0; + background-color: var(--grey300); + border-radius: 7px; + cursor: pointer; + height: 14px; + margin-left: auto; + transition: background-color .15s cubic-bezier(.4,0,.2,1) 0s; + width: 34px +} + +.toggle-switch--toggle---2kPqc:before { + box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12); + content: ""; + position: absolute; + background-color: #9e9e9e; + background-color: var(--grey500); + border-radius: 100%; + height: 20px; + left: 0; + top: -3px; + width: 20px; + transition: -webkit-transform .15s cubic-bezier(.4,0,.2,1) 0s; + transition: transform .15s cubic-bezier(.4,0,.2,1) 0s; + transition: transform .15s cubic-bezier(.4,0,.2,1) 0s,-webkit-transform .15s cubic-bezier(.4,0,.2,1) 0s +} + +.toggle-switch--disabled---1qDLf { + opacity: .6 +} + +.toggle-switch--disabled---1qDLf .toggle-switch--icon---348nT { + color: rgba(0,0,0,.38); + color: var(--black38) +} + +.toggle-switch--disabled---1qDLf .toggle-switch--toggle---2kPqc { + cursor: default +} diff --git a/test/api/mocha/data/appdata/op.test.js b/test/api/mocha/data/operation/op.test.js similarity index 61% rename from test/api/mocha/data/appdata/op.test.js rename to test/api/mocha/data/operation/op.test.js index 282ff0ed4..fe330aaa4 100644 --- a/test/api/mocha/data/appdata/op.test.js +++ b/test/api/mocha/data/operation/op.test.js @@ -5,13 +5,15 @@ const expect = chai.expect const config = require('../../testConfig.json') const utils = require('../../utils/testUtils.js') const iterations = require('../../iterations.js') +const reference = require('../../referenceData.js') describe('GET - Op', () => { + let disabledCollection before(async function () { this.timeout(4000) await utils.uploadTestStigs() await utils.loadAppData() - // await utils.createDisabledCollectionsandAssets() + ;({collection: disabledCollection} = await utils.createDisabledCollectionsandAssets()) }) for(const iteration of iterations){ @@ -42,21 +44,27 @@ describe('GET - Op', () => { expect(res).to.have.status(200) }) }) - describe('getDetails - /op/details', () => { + describe('getAppInfo - /op/appinfo', () => { it('Return API Deployment Details', async () => { - const res = await chai.request(config.baseUrl) - .get(`/op/details?elevate=true`) - .set('Authorization', `Bearer ${iteration.token}`) - if(iteration.name !== "stigmanadmin"){ - expect(res).to.have.status(403) - return - } - expect(res).to.have.status(200) - expect(res.body).to.be.an('object') - expect(res.body.dbInfo).to.exist - expect(res.body.dbInfo).to.have.property('tables') - expect(res.body.stigmanVersion).to.exist - + const res = await chai.request(config.baseUrl) + .get(`/op/appinfo?elevate=true`) + .set('Authorization', `Bearer ${iteration.token}`) + if(iteration.name !== "stigmanadmin"){ + expect(res).to.have.status(403) + return + } + expect(res).to.have.status(200) + expect(res.body).to.be.an('object') + const rtc = reference.testCollection + expect(res.body).to.nested.include({ + schema: 'stig-manager-appinfo-v1.0', + [`collections.${rtc.collectionId}.state`]: rtc.appinfo.state, + [`collections.${rtc.collectionId}.assets`]: rtc.appinfo.assets, + [`collections.${rtc.collectionId}.assetsDisabled`]: rtc.appinfo.assetsDisabled, + [`collections.${rtc.collectionId}.reviews`]: rtc.appinfo.reviews, + [`collections.${rtc.collectionId}.reviewsDisabled`]: rtc.appinfo.reviewsDisabled, + [`collections.${disabledCollection.collectionId}.state`]: 'disabled' + }) }) }) describe('getDefinition - /op/definition', () => { diff --git a/test/api/mocha/referenceData.js b/test/api/mocha/referenceData.js index 7075072bd..ed6affc30 100644 --- a/test/api/mocha/referenceData.js +++ b/test/api/mocha/referenceData.js @@ -193,6 +193,13 @@ const reference = { grantCount: 7, checklistCount: 6, }, + appinfo: { + state: 'enabled', + assets: 4, + assetsDisabled: 1, + reviews: 17, + reviewsDisabled: 1 + }, labelsProjected: [ { name: "test-label-full", diff --git a/test/api/runMocha.sh b/test/api/runMocha.sh index 2a32f399d..39a7c2179 100755 --- a/test/api/runMocha.sh +++ b/test/api/runMocha.sh @@ -75,4 +75,4 @@ if [ ${#PATTERNS[@]} -eq 0 ] && [ ${#FILES[@]} -eq 0 ] && [ ${#DIRECTORIES[@]} - fi echo "Running command: $COMMAND" -eval $COMMAND \ No newline at end of file +eval $COMMAND