├── .gitignore ├── favicon.ico ├── old ├── favicon.ico ├── img │ ├── icon-192.png │ ├── icon-512.png │ ├── manifest.json │ └── favicon.svg ├── README.md ├── config-example.js ├── tools │ └── downloadAllArtifactsFromLatestBuildByBuildTypeID.js ├── js │ ├── time.js │ ├── user.js │ ├── projects.js │ └── render.js ├── css │ └── tcview.css └── index.html ├── README.md ├── css ├── wrappersnocolor.css ├── builds.css ├── main.css ├── buildsnocolor.css └── wrappers.css ├── config ├── config-example.js └── fields.js ├── src ├── user.js ├── time.js ├── query.js ├── render.js ├── main.js └── data.js └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | config.js 2 | .gitignore 3 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deltares/tcBuildViewer/master/favicon.ico -------------------------------------------------------------------------------- /old/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deltares/tcBuildViewer/master/old/favicon.ico -------------------------------------------------------------------------------- /old/img/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deltares/tcBuildViewer/master/old/img/icon-192.png -------------------------------------------------------------------------------- /old/img/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deltares/tcBuildViewer/master/old/img/icon-512.png -------------------------------------------------------------------------------- /old/img/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "icons": [ 3 | { "src": "icon-192.png", "type": "image/png", "sizes": "192x192" }, 4 | { "src": "icon-512.png", "type": "image/png", "sizes": "512x512" } 5 | ] 6 | } -------------------------------------------------------------------------------- /old/README.md: -------------------------------------------------------------------------------- 1 | # TeamCity Build Viewer 2 | 3 | This web-app provides a bird's-eye view on the build-result history/correlations for big projects to help tackle technical debt towards passing test-benches. 4 | 5 | ## Installation steps 6 | 1. Host tcBuildViewer on a webserver. 7 | 2. Copy config-example.js to config.js and change the values. 8 | 3. Add the domain of your tcBuildViewer to the CORS (cross origin resource sharing) list for your TeamCity (read config-example.js for more information) 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TeamCity Build Viewer 2 | 3 | This web-app provides a bird's-eye view on the build-result history/correlations for big projects to help tackle technical debt towards passing test-benches. 4 | 5 | In a later stage (Q1 2023) we will implement features to better support build-results before merging branches. 6 | 7 | ## Installation steps 8 | 1. Host tcBuildViewer on a webserver. 9 | 2. Copy config-example.js to config.js and change the values. 10 | 3. Add the domain of your tcBuildViewer to the CORS (cross origin resource sharing) list for your TeamCity (read config-example.js for more information) 11 | -------------------------------------------------------------------------------- /css/wrappersnocolor.css: -------------------------------------------------------------------------------- 1 | .nocolor .buildTypePart { 2 | background-color: #eee; 3 | padding: 5px; 4 | margin-bottom: 8px; 5 | } 6 | 7 | .nocolor .buildTypePart.SUCCESS { 8 | background-color: #eee !important; 9 | } 10 | 11 | .nocolor .buildTypePart.FAILURE { 12 | background-color: #eee; 13 | opacity: 0.8; 14 | background-image: linear-gradient(135deg, #ccc 25%, transparent 25%), linear-gradient(225deg, #ccc 25%, transparent 25%), linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(315deg, #ccc 25%, #eee 25%); 15 | background-position: 20px 0, 20px 0, 0 0, 0 0; 16 | background-size: 20px 20px; 17 | background-repeat: repeat; 18 | } 19 | 20 | .nocolor .buildTypePart.statusChanged { 21 | border-top: 3px solid black; 22 | border-bottom: 3px solid black; 23 | } 24 | 25 | .nocolor .buildTypePart.buildTypeTitle.statusChanged { 26 | border-left: 3px solid black; 27 | padding-left: 2px; 28 | } 29 | 30 | .nocolor .buildTypePart.buildList.statusChanged { 31 | border-right: 3px solid black; 32 | padding-right: 2px; 33 | } -------------------------------------------------------------------------------- /old/config-example.js: -------------------------------------------------------------------------------- 1 | // 2 | // Because of CORS (cross origin resource sharing), it is easiest to host this app within the same domain as TeamCity. 3 | // 4 | // e.g. https:///tcview/ 5 | // 6 | // Else, add the domain of your tcBuildViewer to the rest.cors.origins variable on the TeamCity server. 7 | // 8 | // Option 1: Add your domain to /config/internal.properties 9 | // Option 2: Use a Java .properties file with to set the variable 10 | // Option 3: Start the Java process with -Drest.cors.origins= 11 | // 12 | // More info on CORS: 13 | // https://www.jetbrains.com/help/teamcity/rest/teamcity-rest-api-documentation.html#CORS-support 14 | // https://www.jetbrains.com/help/teamcity/server-startup-properties.html 15 | // 16 | 17 | // If your tcBuildViewer is hosted on the same domain as your TeamCity. 18 | const teamcity_base_url = '' 19 | // If you host your tcBuildViewer on a different domain: 20 | // const teamcity_base_url = 'https://' 21 | 22 | // Which projects to traverse and which to ignore. 23 | // This is used as a fallback when the user has 24 | // no favorite ("starred") projects in TeamCity. 25 | const default_selection = { 26 | 27 | important_buildtypes: [ 28 | "buildType1", 29 | "buildType2", 30 | ], 31 | 32 | include_projects: [ 33 | "MyProject1", 34 | "MyProject2", 35 | ], 36 | 37 | exclude_projects:[ 38 | "ArchivedProjects", 39 | "ExperimentalProject", 40 | ], 41 | } 42 | 43 | // Retrieve last X builds of every build type: 44 | let build_count = 14 45 | 46 | // Don't retrieve builds that are older than X days: 47 | let build_cutoff_days = 14 -------------------------------------------------------------------------------- /config/config-example.js: -------------------------------------------------------------------------------- 1 | // 2 | // Because of CORS (cross origin resource sharing), it is easiest to host this app within the same domain as TeamCity. 3 | // 4 | // e.g. https:///tcview/ 5 | // 6 | // Else, add the domain of your tcBuildViewer to the rest.cors.origins variable on the TeamCity server. 7 | // 8 | // Option 1: Add your domain to /config/internal.properties 9 | // Option 2: Use a Java .properties file with to set the variable 10 | // Option 3: Start the Java process with -Drest.cors.origins= 11 | // 12 | // More info on CORS: 13 | // https://www.jetbrains.com/help/teamcity/rest/teamcity-rest-api-documentation.html#CORS-support 14 | // https://www.jetbrains.com/help/teamcity/server-startup-properties.html 15 | // 16 | 17 | // If your tcBuildViewer is hosted on the same domain as your TeamCity. 18 | const teamcity_base_url = '' 19 | // If you host your tcBuildViewer on a different domain: 20 | // const teamcity_base_url = 'https://' 21 | 22 | // Which projects to traverse and which to ignore. 23 | // This is used as a fallback when the user has 24 | // no favorite ("starred") projects in TeamCity. 25 | const default_selection = { 26 | 27 | important_buildtypes: [ 28 | "buildType1", 29 | "buildType2", 30 | ], 31 | 32 | include_projects: [ 33 | "MyProject1", 34 | "MyProject2", 35 | ], 36 | 37 | exclude_projects:[ 38 | "ArchivedProjects", 39 | "ExperimentalProject", 40 | ], 41 | } 42 | 43 | // Retrieve last X builds of every build type: 44 | let build_count = 14 45 | 46 | // Don't retrieve builds that are older than X days: 47 | let build_cutoff_days = 14 -------------------------------------------------------------------------------- /old/tools/downloadAllArtifactsFromLatestBuildByBuildTypeID.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const fs = require('fs'); 3 | 4 | // Constants 5 | const baseUrl = 'https://'; 6 | 7 | // Get the buildType ID, API token, and branch name from the command-line arguments 8 | const buildTypeId = process.argv[2]; 9 | const apiToken = process.argv[3]; 10 | const branchName = process.argv[4]; 11 | 12 | // Print help message if required arguments are missing 13 | if (!buildTypeId || !apiToken) { 14 | console.log('Usage: node downloadArtifacts.js BUILD_TYPE_ID API_TOKEN [BRANCH_NAME]'); 15 | console.log('Example: node downloadArtifacts.js MyProject_Build MyToken123456'); 16 | console.log('Example: node downloadArtifacts.js MyProject_Build MyToken123456 feature/new-feature'); 17 | process.exit(1); 18 | } 19 | 20 | // Make a request to the builds endpoint using the buildType ID 21 | let buildsUrl = `${baseUrl}/app/rest/builds?buildType=id:${buildTypeId}&count=1`; 22 | if (branchName) { 23 | buildsUrl += `&branch=name:${encodeURIComponent(branchName)}`; 24 | } 25 | axios.get(buildsUrl, { 26 | headers: { 27 | 'Authorization': `Bearer ${apiToken}`, 28 | 'Accept': 'application/json' 29 | } 30 | }) 31 | .then(response => { 32 | // Get the build ID from the response data 33 | const buildId = response.data.build[0].id; 34 | // Make a request to the artifacts endpoint using the build ID 35 | return axios.get(`${baseUrl}/app/rest/builds/id:${buildId}/artifacts/children`, { 36 | headers: { 37 | 'Authorization': `Bearer ${apiToken}`, 38 | 'Accept': 'application/json' 39 | } 40 | }); 41 | }) 42 | .then(response => { 43 | // Get the list of artifact objects from the response data 44 | if (Array.isArray(response.data.file)) { 45 | artifacts = response.data.file; 46 | } else { 47 | artifacts = [response.data.file]; 48 | } 49 | // Download each artifact 50 | return Promise.all(artifacts.map(artifact => { 51 | // Check if the content property exists 52 | if (artifact.content) { 53 | // Get the URL of the artifact file 54 | const artifactUrl = baseUrl + artifact.content.href; 55 | // Get the name of the artifact file 56 | const artifactName = artifact.name; 57 | // Download the artifact file 58 | return axios({ 59 | url: artifactUrl, 60 | method: 'GET', 61 | responseType: 'stream', 62 | headers: { 63 | 'Authorization': `Bearer ${apiToken}` 64 | } 65 | }).then(response => { 66 | // Save the artifact file to the current directory 67 | response.data.pipe(fs.createWriteStream(`./${artifactName}`)); 68 | }); 69 | } 70 | })); 71 | }) 72 | .catch(error => { 73 | console.error(`HTTP error: ${error.response.status}`); 74 | }); 75 | -------------------------------------------------------------------------------- /css/builds.css: -------------------------------------------------------------------------------- 1 | .build { 2 | line-height: 1.5em; 3 | float: right; 4 | } 5 | 6 | /* Define all possible test visible */ 7 | .testIconchanged::after { 8 | content: '⬤'; 9 | font-size: 12px; 10 | color: #FFE8B3; 11 | margin: 0 4.2px; 12 | position: relative; 13 | z-index: 1; 14 | top: -2px; 15 | } 16 | 17 | .testIconSUCCESS::after { 18 | content: '⬤'; 19 | font-size: 12px; 20 | color: #B5DBB6; 21 | margin: 0 4.2px; 22 | position: relative; 23 | z-index: 1; 24 | top: -2px; 25 | } 26 | 27 | .testIconFAILURE::after { 28 | content: '⬤'; 29 | font-size: 12px; 30 | color: #F0C0B3; 31 | margin: 0 4.2px; 32 | position: relative; 33 | z-index: 1; 34 | top: -2px; 35 | } 36 | 37 | .testIconrunning::after { 38 | content: '⬤'; 39 | font-size: 12px; 40 | color: white; 41 | margin: 0 4.2px; 42 | position: relative; 43 | z-index: 1; 44 | top: -2px; 45 | } 46 | 47 | .testIconqueued::after { 48 | content: '⬤'; 49 | font-size: 12px; 50 | color: white; 51 | margin: 0 4.2px; 52 | position: relative; 53 | z-index: 1; 54 | top: -2px; 55 | } 56 | 57 | .testIconUNKNOWN::after { 58 | content: '⬤'; 59 | font-size: 12px; 60 | color: #666; 61 | margin: 0 4.2px; 62 | position: relative; 63 | z-index: 1; 64 | top: -2px; 65 | } 66 | 67 | .testBorderSUCCESS{ 68 | position: absolute; 69 | } 70 | 71 | .testBorderSUCCESS::after { 72 | content: '⬤'; 73 | color: #B5DBB6; 74 | font-size: 22px; 75 | } 76 | 77 | .testBorderFAILURE { 78 | position: absolute; 79 | } 80 | 81 | .testBorderFAILURE::after { 82 | content: '⬤'; 83 | color: #F0C0B3; 84 | font-size: 22px; 85 | } 86 | 87 | .testBorderqueued { 88 | position: absolute; 89 | } 90 | 91 | 92 | .testBorderqueued::after { 93 | content: '⬤'; 94 | color: #666; 95 | font-size: 22px; 96 | } 97 | 98 | .testBorderrunning { 99 | position: absolute; 100 | } 101 | 102 | 103 | .testBorderrunning::after { 104 | content: '⬤'; 105 | color: #666; 106 | font-size: 22px; 107 | } 108 | 109 | .testBorderUNKNOWN { 110 | position: absolute; 111 | } 112 | 113 | .testBorderUNKNOWN::after { 114 | content: '⬤'; 115 | color: #666; 116 | font-size: 22px; 117 | } 118 | 119 | .build:first-child > .testIconchanged::after, 120 | .buildLegend > .testIconchanged::after { 121 | color: gold; 122 | } 123 | 124 | .build:first-child > .testBorderqueued::after, 125 | .build:first-child > .testBorderrunning::after, 126 | .buildLegend > .testBorderqueued::after { 127 | color: black; 128 | } 129 | 130 | .build:first-child > .testBorderSUCCESS::after, 131 | .build:first-child > .testIconSUCCESS::after, 132 | .buildLegend > .testBorderSUCCESS::after, 133 | .buildLegend > .testIconSUCCESS::after { 134 | color: green; 135 | } 136 | 137 | .build:first-child > .testBorderFAILURE::after, 138 | .build:first-child > .testIconFAILURE:after, 139 | .buildLegend > .testBorderFAILURE::after, 140 | .buildLegend > .testIconFAILURE:after { 141 | color: red; 142 | } 143 | 144 | .build:first-child > .testBorderUNKNOWN::after, 145 | .build:first-child > .testIconUNKNOWN::after, 146 | .buildLegend > .testBorderUNKNOWN::after, 147 | .buildLegend > .testIconUNKNOWN::after { 148 | color: black; 149 | } 150 | 151 | .build:hover > div:nth-child(2)::after { 152 | color: black !important; 153 | } -------------------------------------------------------------------------------- /old/js/time.js: -------------------------------------------------------------------------------- 1 | // Convert TeamCity's weird time notation to Unix timestamp. 2 | function tcTimeToUnix(tcTime) { 3 | split = tcTime.split('') 4 | year = split.slice(0, 4).join('') 5 | month = split.slice(4, 6).join('') 6 | day = split.slice(6, 8).join('') 7 | t = split.slice(8, 9).join('') 8 | hour = split.slice(9, 11).join('') 9 | minute = split.slice(11, 13).join('') 10 | second = split.slice(13, 15).join('') 11 | timezone = split.slice(15, 23).join('') 12 | let date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.000${timezone}`) 13 | return date.getTime() // Unix timestamp from Date object. 14 | } 15 | 16 | // Convert Date to TeamCity's weird time notation. 17 | function DateToTcTime(date) { 18 | year = date.toISOString().substr(0, 4) 19 | month = date.toISOString().substr(5, 2) 20 | day = date.toISOString().substr(8, 2) 21 | hour = '00' // Well... let's not get nitty gritty here. 22 | minute = '00' 23 | second = '00' 24 | timezone = '%2B0000' // +0000 25 | let tcTime = `${year}${month}${day}T${hour}${minute}${second}${timezone}` // TeamCity time format: 20221206T080035+0100 26 | return tcTime 27 | } 28 | 29 | // Convert HTML input datetime-local to TeamCity's weird time notation. 30 | function htmlDateTimeToTcTime(htmlDateTime) { 31 | split = htmlDateTime.split('') // 2022-12-22T23:15 32 | year = split.slice(0, 4).join('') 33 | month = split.slice(5, 7).join('') 34 | day = split.slice(8, 10).join('') 35 | t = split.slice(10, 11).join('') 36 | hour = split.slice(11, 13).join('') 37 | minute = split.slice(14, 16).join('') 38 | second = '00' 39 | timezone = '%2B0000' // +0000 40 | let tcTime = `${year}${month}${day}T${hour}${minute}${second}${timezone}` // TeamCity time format: 20221206T080035+0100 41 | return tcTime 42 | } 43 | 44 | // Convert TeamCity's weird time notation to Unix timestamp. 45 | function htmlDateTimeToUnix(htmlDateTime) { 46 | split = htmlDateTime.split('') // 2022-12-22T23:15 47 | year = split.slice(0, 4).join('') 48 | month = split.slice(5, 7).join('') 49 | day = split.slice(8, 10).join('') 50 | t = split.slice(10, 11).join('') 51 | hour = split.slice(11, 13).join('') 52 | minute = split.slice(14, 16).join('') 53 | let date = new Date(`${year}-${month}-${day}T${hour}:${minute}`) 54 | return date.getTime() // Unix timestamp from Date object. 55 | } 56 | 57 | // Convert TeamCity's weird time notation to Unix timestamp. 58 | function htmlDateTimeToDate(htmlDateTime) { 59 | split = htmlDateTime.split('') // 2022-12-22T23:15 60 | year = split.slice(0, 4).join('') 61 | month = split.slice(5, 7).join('') 62 | day = split.slice(8, 10).join('') 63 | t = split.slice(10, 11).join('') 64 | hour = split.slice(11, 13).join('') 65 | minute = split.slice(14, 16).join('') 66 | return date = new Date(`${year}-${month}-${day}T${hour}:${minute}`) 67 | } 68 | 69 | // Cut-off date in TeamCity's weird time notation, used for API calls. 70 | const cutoffTcString = function (d) { 71 | if (!d) 72 | d = new Date() 73 | d.setDate(d.getDate()-build_cutoff_days) 74 | return DateToTcTime(d) 75 | } 76 | 77 | // Ol' reliable Unix-time. 78 | const cutoffUnixTime = function () { 79 | let d = new Date() 80 | d.setDate(d.getDate()-build_cutoff_days) 81 | return d.getTime() 82 | }; -------------------------------------------------------------------------------- /src/user.js: -------------------------------------------------------------------------------- 1 | /* User 2 | / 3 | / Manage teamcity user credentials for login 4 | / 5 | / Add a login field if the user is not logged in in teamcity 6 | / Handle cookies and favorite projects 7 | */ 8 | 9 | class UserHandler { 10 | 11 | async getCurrentUser() { 12 | 13 | if (!await query.userLoggedIn()) { 14 | 15 | // Show login button if the user is not logged in. 16 | render.loginElement('waiting for login.', false) 17 | 18 | do { 19 | main.debug("waiting for TeamCity login ...", false) 20 | await new Promise(resolve => setTimeout(resolve, 1000)) 21 | } while (! await query.userLoggedIn()) 22 | 23 | } 24 | render.loginElement(query.tcUser.username, true) 25 | 26 | } 27 | 28 | // Get favorite projects from TeamCity API. 29 | async getFavoriteProjects() { 30 | 31 | // Assume that things work, now that the user is logged in. 32 | const projects = await query.getFavoriteTcProjects() 33 | 34 | const all_project_ids = projects.project.map(x => x.id) // Only need IDs to (array-)filter on. 35 | 36 | // Only projects whose parent projects are not in the list, to avoid redundancy. 37 | const favoriteProjectObjects = projects.project.filter( project => { 38 | return !all_project_ids.includes(project.parentProjectId) 39 | }) 40 | 41 | const favorite_projects = favoriteProjectObjects.map(x => x.id) // Only need IDs for selection. 42 | 43 | // Selection JSON structure. 44 | let api_selection = { 45 | include_projects: favorite_projects, 46 | exclude_projects: [], 47 | } 48 | return api_selection 49 | } 50 | 51 | // Create 'named project selection' to switch between. 52 | storeNamedSelection(name) { 53 | 54 | if (!main.named_selection[name]) { 55 | render.addNameDropdown(name) 56 | } 57 | 58 | main.named_selection[name] = main.selection 59 | 60 | main.debug(main.named_selection, false) 61 | 62 | this.setCookie('tcNamedSelection',JSON.stringify(main.named_selection),365) 63 | 64 | } 65 | 66 | // Remove 'named project selection'. 67 | removeNamedSelection(name) { 68 | 69 | if (name == 'none') { 70 | return 71 | } 72 | 73 | render.removeNameDropdown(name) 74 | 75 | delete main.named_selection[`${name}`] 76 | 77 | main.debug(JSON.stringify(main.named_selection, undefined, 2), false) 78 | 79 | this.setCookie('tcNamedSelection',JSON.stringify(main.named_selection),365) 80 | 81 | } 82 | 83 | setCookie(cname, cvalue, exdays) { 84 | const d = new Date() 85 | d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000)) 86 | const expires = "expires="+d.toUTCString() 87 | document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/;SameSite=None;Secure" 88 | } 89 | 90 | getCookie(cname) { 91 | const name = cname + '=' 92 | const ca = document.cookie.split(';') 93 | for(let element of ca) { 94 | while (element.startsWith(' ')) { 95 | element = element.substring(1) 96 | } 97 | if (element.startsWith(name)) { 98 | return element.substring(name.length, element.length) 99 | } 100 | } 101 | return '' 102 | } 103 | } -------------------------------------------------------------------------------- /css/main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --deltares-blue: #0a28a3; 3 | } 4 | 5 | .legend { 6 | z-index: 10; 7 | background: white; 8 | position: fixed; 9 | top: 0px; 10 | right: 0px; 11 | padding: 5px; 12 | border-radius: 0px 0px 0px 10px; 13 | display: grid; 14 | grid-template-columns: auto auto; 15 | column-gap: 0.5em; 16 | border-left: 1px solid black; 17 | border-bottom: 1px solid black; 18 | transition: width 500ms; 19 | } 20 | 21 | .legend > div { 22 | white-space: nowrap; 23 | line-height: 1.5em; 24 | } 25 | 26 | @media (max-width: 110em) { 27 | .legend { 28 | width: 2ch; 29 | } 30 | } 31 | 32 | .legend:hover { 33 | width: 10em; 34 | } 35 | 36 | .legend > .title{ 37 | grid-column: span 2; 38 | font-weight: bold; 39 | } 40 | 41 | .headerBox { 42 | font-size: 18px; 43 | margin: 10px; 44 | padding: 10px; 45 | color: white; 46 | background-color: gray; 47 | text-align: center; 48 | } 49 | 50 | .selectionBox { 51 | display: grid; 52 | grid-template-columns: 1fr auto 1fr; 53 | column-gap: 2em; 54 | row-gap: 1em; 55 | padding: 1em 2em 2em 2em; 56 | } 57 | 58 | #selection_textarea { 59 | resize: none; 60 | width: 100%; 61 | height: 100%; 62 | box-sizing: border-box; 63 | background: black; 64 | } 65 | 66 | .code { 67 | padding: 1em; 68 | font-family: 'Courier New', Courier, monospace; 69 | text-align: left; 70 | white-space: pre; 71 | background-color: #666; 72 | color: white; 73 | font-size: 16px; 74 | line-height: 1.8em; 75 | border-radius: 0.5em; 76 | overflow: scroll; 77 | min-height: 20em; 78 | } 79 | 80 | .main { 81 | min-width: 40em; 82 | max-width: 90em; 83 | margin: 0 auto; 84 | } 85 | 86 | .wide { 87 | grid-column: 1/-1; 88 | } 89 | 90 | .stats { 91 | position: absolute; 92 | padding: 10px; 93 | font-family: 'Courier New', Courier, monospace; 94 | display: grid; 95 | grid-template-columns: auto auto; 96 | column-gap: 0.5em; 97 | font-size: 0.8em 98 | } 99 | 100 | .hidden { 101 | display: none !important; 102 | } 103 | 104 | /* From Deltares.nl */ 105 | body { 106 | font-family: Helvetica, Arial, sans-serif; 107 | font-size: 16px; 108 | padding: 10px 3ch; 109 | } 110 | 111 | button { 112 | font-family: Helvetica, Arial, sans-serif !important; 113 | font-size: 18px; 114 | letter-spacing: 1px; 115 | font-weight: normal !important; 116 | color: white; 117 | background-color: #666; 118 | border: 3px solid #666; 119 | padding: 0px 10px; 120 | border-radius: 5px; 121 | line-height: 1.5em; 122 | } 123 | 124 | button:disabled { 125 | opacity: 25%; 126 | } 127 | 128 | button:disabled:hover { 129 | opacity: 25%; 130 | background-color: #666 !important; 131 | border: 3px solid #666 !important; 132 | } 133 | 134 | button.toggle.active { 135 | background-color: black; 136 | } 137 | 138 | button:hover { 139 | border: 3px solid black !important; 140 | background-color: #777; 141 | } 142 | 143 | .header { 144 | min-width: 40em; 145 | max-width: 90em; 146 | margin: auto; 147 | } 148 | 149 | /* Deltares blue */ 150 | .deltaresLogo, h1 { 151 | text-align: center; 152 | color: var(--deltares-blue); 153 | fill: var(--deltares-blue); /* SVG */ 154 | } 155 | 156 | h1 { 157 | font-size: 24px; 158 | font-style: italic; 159 | margin-top: 2px; 160 | font-weight: normal; 161 | } 162 | 163 | form { 164 | height: 100%; 165 | } 166 | 167 | .topBox { 168 | margin: 0.5rem 0px; 169 | font-size: 1.5rem; 170 | } 171 | 172 | .buttonList { 173 | display: flex; 174 | justify-content: center; 175 | column-gap: 10px; 176 | margin: 10px; 177 | } 178 | 179 | .loading { 180 | cursor: wait !important; 181 | } 182 | 183 | .loading a { 184 | cursor: wait !important; 185 | } 186 | -------------------------------------------------------------------------------- /src/time.js: -------------------------------------------------------------------------------- 1 | class TimeUtilities { 2 | 3 | // Convert TeamCity's weird time notation to Unix timestamp. 4 | static tcTimeToUnix(tcTime) { 5 | let split = tcTime.split('') 6 | let year = split.slice(0, 4).join('') 7 | let month = split.slice(4, 6).join('') 8 | let day = split.slice(6, 8).join('') 9 | let t = split.slice(8, 9).join('') 10 | let hour = split.slice(9, 11).join('') 11 | let minute = split.slice(11, 13).join('') 12 | let second = split.slice(13, 15).join('') 13 | let timezone = split.slice(15, 23).join('') 14 | let date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.000${timezone}`) 15 | return date.getTime() // Unix timestamp from Date object. 16 | } 17 | 18 | // Convert Date to TeamCity's weird time notation. 19 | static DateToTcTime(date) { 20 | let year = date.toISOString().substr(0, 4) 21 | let month = date.toISOString().substr(5, 2) 22 | let day = date.toISOString().substr(8, 2) 23 | let hour = '00' // Well... let's not get nitty gritty here. 24 | let minute = '00' 25 | let second = '00' 26 | let timezone = '%2B0000' // +0000 27 | let tcTime = `${year}${month}${day}T${hour}${minute}${second}${timezone}` // TeamCity time format: 20221206T080035+0100 28 | return tcTime 29 | } 30 | 31 | // Convert HTML input datetime-local to TeamCity's weird time notation. 32 | static htmlDateTimeToTcTime(htmlDateTime) { 33 | let split = htmlDateTime.split('') // 2022-12-22T23:15 34 | let year = split.slice(0, 4).join('') 35 | let month = split.slice(5, 7).join('') 36 | let day = split.slice(8, 10).join('') 37 | let t = split.slice(10, 11).join('') 38 | let hour = split.slice(11, 13).join('') 39 | let minute = split.slice(14, 16).join('') 40 | let second = '00' 41 | let timezone = '%2B0000' // +0000 42 | let tcTime = `${year}${month}${day}T${hour}${minute}${second}${timezone}` // TeamCity time format: 20221206T080035+0100 43 | return tcTime 44 | } 45 | 46 | // Convert TeamCity's weird time notation to Unix timestamp. 47 | static htmlDateTimeToUnix(htmlDateTime) { 48 | let split = htmlDateTime.split('') // 2022-12-22T23:15 49 | let year = split.slice(0, 4).join('') 50 | let month = split.slice(5, 7).join('') 51 | let day = split.slice(8, 10).join('') 52 | let t = split.slice(10, 11).join('') 53 | let hour = split.slice(11, 13).join('') 54 | let minute = split.slice(14, 16).join('') 55 | let date = new Date(`${year}-${month}-${day}T${hour}:${minute}`) 56 | return date.getTime() // Unix timestamp from Date object. 57 | } 58 | 59 | // Convert TeamCity's weird time notation to Unix timestamp. 60 | static htmlDateTimeToDate(htmlDateTime) { 61 | let split = htmlDateTime.split('') // 2022-12-22T23:15 62 | let year = split.slice(0, 4).join('') 63 | let month = split.slice(5, 7).join('') 64 | let day = split.slice(8, 10).join('') 65 | let t = split.slice(10, 11).join('') 66 | let hour = split.slice(11, 13).join('') 67 | let minute = split.slice(14, 16).join('') 68 | let date = new Date(`${year}-${month}-${day}T${hour}:${minute}`) 69 | return date 70 | } 71 | 72 | // subtract build cutoff days from teamcity for api requests. 73 | static cutoffTcString(date) { 74 | if (!date) 75 | date = new Date() 76 | date.setDate(date.getDate()-build_cutoff_days) 77 | return this.DateToTcTime(date) 78 | } 79 | 80 | // subtract build cutoff days from unix time for rendering as display html. 81 | static cutoffUnixTime() { 82 | let date = new Date() 83 | date.setDate(date.getDate()-build_cutoff_days) 84 | return date.getTime() 85 | }; 86 | } -------------------------------------------------------------------------------- /css/buildsnocolor.css: -------------------------------------------------------------------------------- 1 | /* 2 | SUCCESS 🗸 3 | FAILURE X 4 | PENDING - 5 | changed ⬤ (bg) 6 | PENDING ▋ (bg) 7 | */ 8 | 9 | .nocolor .build { 10 | line-height: 1.5em; 11 | width: 18px; 12 | overflow: hidden; 13 | float: right; 14 | } 15 | 16 | .nocolor .build:first-child { 17 | transform: scale(1.2); 18 | margin-left: 10px; 19 | margin-right: 2px; 20 | } 21 | 22 | /* Define all possible test visible */ 23 | .nocolor .testIconchanged::after { 24 | content: '⬤'; 25 | font-size: 22px; 26 | color: black; 27 | position: relative; 28 | z-index: 0; 29 | margin: 0; 30 | top: 0; 31 | } 32 | 33 | .nocolor .testIconSUCCESS::after { 34 | content: '✓'; 35 | font-weight: bold; 36 | font-size: 22px; 37 | color: black; 38 | position: relative; 39 | z-index: 3; 40 | margin: 0; 41 | top: 0; 42 | } 43 | 44 | .nocolor .testIconFAILURE::after { 45 | content: '✗'; 46 | font-weight: bold; 47 | font-size: 22px; 48 | color: black; 49 | position: relative; 50 | z-index: 3; 51 | margin: 0; 52 | top: 0; 53 | } 54 | 55 | .nocolor .testIconrunning::after { 56 | content: '▋'; 57 | font-weight: bold; 58 | font-size: 20px; 59 | color: black; 60 | position: relative; 61 | z-index: 1; 62 | margin: 0 0 0 3px; 63 | top: 0; 64 | } 65 | 66 | .nocolor .testIconqueued::after { 67 | content: '▋'; 68 | font-weight: bold; 69 | font-size: 20px; 70 | color: black; 71 | position: relative; 72 | z-index: 1; 73 | margin: 0 0 0 3px; 74 | top: 0; 75 | } 76 | 77 | .nocolor .testIconUNKNOWN::after { 78 | content: '-'; 79 | font-weight: bold; 80 | font-size: 30px; 81 | color: black; 82 | position: relative; 83 | z-index: 3; 84 | margin: 0; 85 | top: 0; 86 | } 87 | 88 | .nocolor .testBorderSUCCESS{ 89 | position: absolute; 90 | } 91 | 92 | .nocolor .testBorderSUCCESS::after { 93 | content: '✓'; 94 | font-weight: bold; 95 | color: white; 96 | font-size: 22px; 97 | position:relative; 98 | z-index: 2; 99 | } 100 | 101 | .nocolor .testBorderFAILURE { 102 | position: absolute; 103 | } 104 | 105 | .nocolor .testBorderFAILURE::after { 106 | content: '✗'; 107 | font-weight: bold; 108 | color: white; 109 | font-size: 22px; 110 | position:relative; 111 | z-index: 2; 112 | } 113 | 114 | .nocolor .testBorderqueued { 115 | position: absolute; 116 | } 117 | 118 | 119 | .nocolor .testBorderqueued::after { 120 | content: '-'; 121 | font-weight: bold; 122 | color: white; 123 | font-size: 30px; 124 | position:relative; 125 | z-index: 2; 126 | } 127 | 128 | .nocolor .testBorderrunning { 129 | position: absolute; 130 | } 131 | 132 | 133 | .nocolor .testBorderrunning::after { 134 | content: '-'; 135 | font-weight: bold; 136 | color: white; 137 | font-size: 30px; 138 | position:relative; 139 | z-index: 2; 140 | } 141 | 142 | .nocolor .testBorderUNKNOWN { 143 | position: absolute; 144 | } 145 | 146 | .nocolor .testBorderUNKNOWN::after { 147 | content: '-'; 148 | font-weight: bold; 149 | color: white; 150 | font-size: 30px; 151 | position:relative; 152 | z-index: 2; 153 | } 154 | 155 | /* override first child color */ 156 | .nocolor .build:first-child > .testIconchanged::after, 157 | .nocolor .build:first-child > .testIconSUCCESS::after, 158 | .nocolor .build:first-child > .testIconFAILURE:after, 159 | .nocolor .build:first-child > .testIconUNKNOWN::after { 160 | color: black; 161 | } 162 | 163 | .nocolor .build:first-child > .testBorderqueued::after, 164 | .nocolor .build:first-child > .testBorderrunning::after, 165 | .nocolor .build:first-child > .testBorderSUCCESS::after, 166 | .nocolor .build:first-child > .testBorderFAILURE::after, 167 | .nocolor .build:first-child > .testBorderUNKNOWN::after { 168 | color: white; 169 | } 170 | 171 | .nocolor .build:hover > div:nth-child(2)::after { 172 | content: '✪'; 173 | font-size: 22px; 174 | color: black !important; 175 | z-index: 4; 176 | margin: 0; 177 | } -------------------------------------------------------------------------------- /config/fields.js: -------------------------------------------------------------------------------- 1 | /* Fields 2 | / 3 | / set all field variables to get from the api. 4 | */ 5 | 6 | class ApiTcFields { 7 | 8 | //constructor() { 9 | 10 | static fields = `{ 11 | "project_fields":{ 12 | "id": "id", 13 | "name": "name", 14 | "parentProjectId": "parentProjectId", 15 | "projects": { 16 | "project": { 17 | "id": "id" 18 | } 19 | }, 20 | "buildTypes": { 21 | "buildType": { 22 | "id": "id", 23 | "name": "name", 24 | "projectId": "projectId" 25 | } 26 | } 27 | }, 28 | "important_fields":{ 29 | "id": "id", 30 | "name": "name" 31 | }, 32 | "buildType_fields":{ 33 | "build": { 34 | "id": "id", 35 | "state": "state", 36 | "buildTypeId": "buildTypeId", 37 | "number": "number", 38 | "branchName": "branchName", 39 | "status": "status", 40 | "tags": { 41 | "tag": "tag" 42 | }, 43 | "agent": { 44 | "id": "id", 45 | "name": "name" 46 | }, 47 | "plannedAgent": { 48 | "id": "id", 49 | "name": "name" 50 | }, 51 | "finishOnAgentDate": "finishOnAgentDate", 52 | "finishEstimate": "finishEstimate", 53 | "running-info": { 54 | "leftSeconds": "leftSeconds" 55 | }, 56 | "statusText": "statusText", 57 | "failedToStart": "failedToStart", 58 | "problemOccurrences": "problemOccurrences", 59 | "testOccurrences": { 60 | "count": "count", 61 | "muted": "muted", 62 | "ignored": "ignored", 63 | "passed": "passed", 64 | "failed": "failed", 65 | "newFailed": "newFailed" 66 | } 67 | } 68 | }, 69 | "message_fields": { 70 | "messages": "messages" 71 | }, 72 | "buildDetails_fields": { 73 | "count": "count", 74 | "passed": "passed", 75 | "failed": "failed", 76 | "muted": "muted", 77 | "ignored": "ignored", 78 | "newFailed": "newFailed", 79 | "testOccurrence": { 80 | "id": "id", 81 | "name": "name", 82 | "status": "status", 83 | "details": "details", 84 | "newFailure": "newFailure", 85 | "muted": "muted", 86 | "failed": "failed", 87 | "ignored": "ignored", 88 | "test": { 89 | "id": "id", 90 | "name": "name", 91 | "parsedTestName": "parsedTestName", 92 | "investigations": { 93 | "investigation": { 94 | "assignee": "assignee" 95 | } 96 | } 97 | }, 98 | "logAnchor": "logAnchor" 99 | } 100 | }, 101 | "change_fields": { 102 | "change": { 103 | "id": "id", 104 | "date": "date", 105 | "version": "version", 106 | "user": "user", 107 | "username": "username", 108 | "comment": "comment", 109 | "files": { 110 | "file": { 111 | "file": "file", 112 | "relative-file": "relative-file" 113 | } 114 | } 115 | } 116 | }, 117 | "testOccurrences_fields": { 118 | "newFailed": "newFailed", 119 | "testOccurrence": { 120 | "status": "status", 121 | "currentlyInvestigated": "currentlyInvestigated" 122 | }, 123 | "ignored": "ignored", 124 | "muted": "muted", 125 | "passed": "passed", 126 | "count": "count" 127 | }, 128 | "progressinfo_fields": { 129 | "estimatedTotalSeconds": "estimatedTotalSeconds" 130 | } 131 | }` 132 | //} 133 | } -------------------------------------------------------------------------------- /old/js/user.js: -------------------------------------------------------------------------------- 1 | // 1. Check if the user is logged in. 2 | // 2. Present login button if not logged in. 3 | // 3. Check every second if the user is logged in. 4 | // 4. When the user is logged in:" 5 | // 5. Hide login button 6 | // 6. Return function 7 | async function getCurrentUser() { 8 | 9 | if (!await userLoggedIn()) { 10 | 11 | // Show login button if the user is not logged in. 12 | document.getElementById('login').classList.remove('hidden') 13 | document.getElementById('user_name').innerHTML = 'waiting for login.' 14 | 15 | do { 16 | console.log("waiting for TeamCity login ...") 17 | await new Promise(resolve => setTimeout(resolve, 1000)) 18 | } while (! await userLoggedIn()) 19 | 20 | // Remove login button if the user is logged in. 21 | document.getElementById('login').classList.add('hidden') 22 | 23 | } 24 | 25 | document.getElementById('user_name').innerHTML = user.username 26 | 27 | } 28 | 29 | // Basically just show a login button. 30 | function showLoginButton() { 31 | 32 | document.getElementById('login').classList.remove('hidden') 33 | 34 | } 35 | 36 | // Returns true/false. 37 | async function userLoggedIn() { 38 | 39 | try { 40 | 41 | const promise = await fetch(`${teamcity_base_url}/app/rest/users/current`, { 42 | headers: { 43 | 'Accept': 'application/json', 44 | }, 45 | credentials: 'include', 46 | }) 47 | 48 | if (promise?.ok) { 49 | user = await promise.json() 50 | return true 51 | } else { 52 | return false 53 | } 54 | 55 | } catch (err) { 56 | console.log(err) 57 | return false 58 | } 59 | 60 | } 61 | 62 | // Get favorite projects from TeamCity API. 63 | async function getFavoriteProjects() { 64 | 65 | const promise = await fetch(`${teamcity_base_url}/app/rest/projects?locator=archived:false,selectedByUser:(user:(current),mode:selected)&fields=project(id,parentProjectId)`, { 66 | headers: { 67 | 'Accept': 'application/json', 68 | }, 69 | credentials: 'include', 70 | }) 71 | 72 | // Assume that things work, now that the user is logged in. 73 | const projects = await promise.json() 74 | 75 | const all_project_ids = projects.project.map(x => x.id) // Only need IDs to (array-)filter on. 76 | 77 | // Only projects whose parent projects are not in the list, to avoid redundancy. 78 | const favoriteProjectObjects = projects.project.filter( project => { 79 | return !all_project_ids.includes(project.parentProjectId) 80 | }) 81 | 82 | const favorite_projects = favoriteProjectObjects.map(x => x.id) // Only need IDs for selection. 83 | 84 | // Selection JSON structure. 85 | return api_selection = { 86 | include_projects: favorite_projects, 87 | exclude_projects: [], 88 | } 89 | 90 | } 91 | 92 | // The part where the user can edit the selection JSON. 93 | function updateSelectionForm() { 94 | const selectionDiv = document.getElementById('selection_code') 95 | selection_textarea.value = JSON.stringify(edit_selection, undefined, 2) 96 | selectionDiv.innerText = JSON.stringify(selection, undefined, 2) 97 | } 98 | 99 | // Create 'named project selection' to switch between. 100 | function storeNamedSelection(name) { 101 | 102 | if (!named_selection[name]) { 103 | let option = document.createElement('option') 104 | option.setAttribute('value',name) 105 | option.setAttribute('id',`namedSelectionOption_${name}`) 106 | option.text = name 107 | let dropdown = document.getElementById('named_selection') 108 | dropdown.appendChild(option) 109 | dropdown.disabled = false 110 | } 111 | 112 | named_selection[name] = selection 113 | 114 | setCookie('tcNamedSelection',JSON.stringify(named_selection),365) 115 | 116 | } 117 | 118 | // Remove 'named project selection'. 119 | function removeNamedSelection(name) { 120 | 121 | if (name == 'none') { 122 | return 123 | } 124 | 125 | let dropdown = document.getElementById('named_selection') 126 | let option = dropdown.namedItem(`namedSelectionOption_${name}`) 127 | dropdown.removeChild(option) 128 | 129 | if (dropdown.length < 2) 130 | dropdown.disabled = true 131 | 132 | delete named_selection[`${name}`] 133 | 134 | console.log(JSON.stringify(named_selection, undefined, 2)) 135 | 136 | setCookie('tcNamedSelection',JSON.stringify(named_selection),365) 137 | 138 | } 139 | 140 | function setCookie(cname, cvalue, exdays) { 141 | const d = new Date() 142 | d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000)) 143 | const expires = "expires="+d.toUTCString() 144 | document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/;SameSite=None;Secure" 145 | } 146 | 147 | function getCookie(cname) { 148 | const name = cname + '=' 149 | const ca = document.cookie.split(';') 150 | for(let i = 0; i < ca.length; i++) { 151 | let c = ca[i] 152 | while (c.charAt(0) == ' ') { 153 | c = c.substring(1) 154 | } 155 | if (c.indexOf(name) == 0) { 156 | return c.substring(name.length, c.length) 157 | } 158 | } 159 | return '' 160 | } -------------------------------------------------------------------------------- /src/query.js: -------------------------------------------------------------------------------- 1 | /* Query 2 | / 3 | / Establish connection with user.js and communicate with teamcity rest api 4 | / 5 | / Send api requests and pass data to function in main to data.js 6 | */ 7 | 8 | class QueryHelper { 9 | 10 | //Fetch data from teamcity rest api with specified locater_url 11 | apiQuery(locaterUrl) 12 | { 13 | 14 | let result = fetch(`${teamcity_base_url}/app/${locaterUrl}`, { 15 | headers: { 16 | "Accept": "application/json" 17 | }, 18 | credentials: 'include', 19 | priority: 'high' 20 | }, this) 21 | .then((result) => { 22 | if (result.status == 200) { 23 | return result.json() 24 | } else if (result.status == 404){ 25 | return Promise.reject('Page not found.') 26 | } else { 27 | return Promise.reject('User not logged in.') 28 | } 29 | }) 30 | .then((output) => { 31 | return output 32 | }) 33 | .catch(error => main.debug(error, true)) 34 | 35 | return result 36 | 37 | } 38 | 39 | //Check in api if user is logged in and return result 40 | async userLoggedIn() { 41 | 42 | try { 43 | 44 | const promise = await fetch(`${teamcity_base_url}/app/rest/users/current`, { 45 | headers: { 46 | 'Accept': 'application/json', 47 | }, 48 | credentials: 'include', 49 | }) 50 | 51 | if (promise?.ok) { 52 | this.tcUser = await promise.json() 53 | return true 54 | } else { 55 | return false 56 | } 57 | 58 | } catch (err) { 59 | main.debug(err, false) 60 | return false 61 | } 62 | 63 | } 64 | 65 | async getImportantBuildType(buildTypeId) { 66 | let locatorUrl = `rest/buildTypes/id:${buildTypeId}?${main.importantFields}` 67 | let output = await this.apiQuery(locatorUrl) 68 | return output 69 | } 70 | 71 | //Setup url to get projects with specified fields and return fetched data 72 | async getProject(projectId) 73 | { 74 | let locatorUrl = `rest/projects/id:${projectId}?${main.projectFields}` 75 | let output = await this.apiQuery(locatorUrl) 76 | return output 77 | } 78 | 79 | //Setup url to get builds with specified fields and return fetched data 80 | async getBuilds(buildTypeId) { 81 | let queuedBoundries = '' 82 | if (main.end_time){ 83 | queuedBoundries += `queuedDate:(date:${TimeUtilities.cutoffTcString(TimeUtilities.htmlDateTimeToDate(main.end_time))},condition:after),` 84 | queuedBoundries += `queuedDate:(date:${TimeUtilities.htmlDateTimeToTcTime(main.end_time)},condition:before)` 85 | } else { 86 | queuedBoundries += `queuedDate:(date:${TimeUtilities.cutoffTcString()},condition:after)` 87 | } 88 | let constantvars = 'locator=defaultFilter:false,branch:default:true,state:any,' 89 | let flexiblevars = `buildType:(id:${buildTypeId}),${queuedBoundries},count:${main.build_count}&${main.buildTypeFields}` 90 | let locatorUrl = `rest/builds?${constantvars}${flexiblevars}` 91 | let output = await this.apiQuery(locatorUrl) 92 | return output 93 | } 94 | 95 | //Get all test results with only result. 96 | async getTestOccurrences(buildId) { 97 | 98 | let locatorUrl = `rest/testOccurrences?locator=build:(id:${buildId}),count:-1&${main.testOccurrencesFields}` 99 | let output = await this.apiQuery(locatorUrl) 100 | return output 101 | } 102 | 103 | //Get test results with specified status in more detail. 104 | async getTestOccurrencesDetailed(buildId, testStatus) { 105 | 106 | let locatorUrl = `rest/testOccurrences?locator=build:(id:${buildId}),count:-1,status:(${testStatus})&${main.buildDetailsFields}` 107 | let output = await this.apiQuery(locatorUrl) 108 | return output 109 | } 110 | 111 | //Get messages of a build by the build id 112 | async getMessages(buildId) { 113 | 114 | let locatorUrl = `messages?buildId=${buildId}&filter=important&${main.messageFields}` 115 | let output = await this.apiQuery(locatorUrl) 116 | return output 117 | } 118 | 119 | //Get submessages of a build by the build id and the message id. 120 | async getMoreMessages(buildId, messageId) { 121 | //%23 is a # 122 | let locatorUrl = `messages?buildId=${buildId}&filter=important&messageId=${messageId}&view=flowAware&_focus=${messageId}%23_state=0,${messageId}` 123 | let output = await this.apiQuery(locatorUrl) 124 | return output 125 | } 126 | 127 | //Get changes made if newfailed is true 128 | async getChanges(buildId) { 129 | 130 | let locatorUrl = `rest/changes?locator=build:(id:${buildId})&${main.changeFields}` 131 | let output = await this.apiQuery(locatorUrl) 132 | return output 133 | } 134 | 135 | //Get favorite projects from teamcity of user that is logged in 136 | async getFavoriteTcProjects() { 137 | 138 | let locatorUrl = `rest/projects?locator=archived:false,selectedByUser:(user:(current),mode:selected)&fields=project(id,parentProjectId)` 139 | let output = await this.apiQuery(locatorUrl) 140 | return output 141 | } 142 | } -------------------------------------------------------------------------------- /css/wrappers.css: -------------------------------------------------------------------------------- 1 | 2 | /* Setup Project wrapper styles */ 3 | .projectTitleWrapper { 4 | display: grid; 5 | margin: 0 0.4em 5px 0; 6 | grid-template-columns: auto auto 1fr; 7 | } 8 | 9 | .projectTitle { 10 | text-decoration: none; 11 | color: black; 12 | display: inline-block; 13 | font-weight: bold; 14 | margin: 0; 15 | } 16 | 17 | a.projectTitle:hover { 18 | text-decoration: underline; 19 | } 20 | 21 | a.projectTitle:hover > .linkIcon { 22 | color: black; 23 | } 24 | 25 | .projectStats { 26 | text-align: right; 27 | color: black; 28 | display: inline-block; 29 | font-weight: bold; 30 | } 31 | 32 | .linkIcon { 33 | font-weight: normal; 34 | color: #ddd; 35 | margin-left: 5px; 36 | display: inline-block; 37 | } 38 | 39 | .collapseButton { 40 | display: inline-block; 41 | margin: 0 0.2em; 42 | width: 1em; 43 | background: none; 44 | transition: transform 0.4s; 45 | } 46 | 47 | .collapseButton.collapsed { 48 | transform: rotate(-90deg); 49 | background: none; 50 | } 51 | 52 | #_projects > .projectWrapper > .project > .projectTitleWrapper, 53 | #_important > .importantWrapper > .project > .projectTitleWrapper { 54 | font-size: 150%; 55 | border: none; 56 | } 57 | 58 | .project { 59 | border-left: 2px dashed lightgray; 60 | border-top: 2px dashed lightgray; 61 | display: grid; 62 | } 63 | 64 | .project > .project { 65 | margin-left: 20px; 66 | } 67 | 68 | .project:not(:has(.build)) { 69 | display: none !important; 70 | } 71 | 72 | .project:not(:has(.buildTypeTitle:not(.hideGreen,.hideNotChanged))) { 73 | display:none !important; 74 | } 75 | 76 | .hideGreen { 77 | display: none !important; 78 | } 79 | 80 | .hideNotChanged { 81 | display: none !important; 82 | } 83 | 84 | .projectWrapper, .importantWrapper { 85 | margin-bottom: 2em; 86 | border-bottom: 2px dashed lightgray; 87 | } 88 | 89 | /* Handle collapsed elements */ 90 | .collapsed { 91 | background: #eee; 92 | } 93 | 94 | .collapsed > .project, 95 | .collapsed > .buildTypesContainer { 96 | display: none !important; 97 | } 98 | 99 | /* Setup BuildType wrapper styles */ 100 | .buildTypesContainer { 101 | display: grid; 102 | grid-template-columns: 1fr auto auto auto; 103 | row-gap: 0px; 104 | font-family: 'Courier New', Courier, monospace; 105 | font-weight: initial; 106 | margin-left: 20px; 107 | } 108 | 109 | .buildTypeTitle { 110 | text-decoration: none; 111 | color: black; 112 | grid-column: 1; 113 | } 114 | 115 | .buildTypeTitle:hover { 116 | text-decoration: underline; 117 | } 118 | 119 | .buildTypeTitle:hover > .linkIcon { 120 | color: black; 121 | } 122 | 123 | .testStatisticsText { 124 | grid-column: 2; 125 | text-align: right; 126 | } 127 | 128 | .miscellaneous { 129 | grid-column: 3; 130 | } 131 | 132 | .buildList { 133 | grid-column: 4; 134 | text-align: end; 135 | } 136 | 137 | .buildTypePart { 138 | background-color: #eee; 139 | padding: 5px; 140 | margin-bottom: 8px; 141 | } 142 | 143 | .buildTypePart.SUCCESS { 144 | background-color: #efe !important; 145 | } 146 | 147 | .buildTypePart.FAILURE { 148 | background-color: #fee !important; 149 | } 150 | 151 | .buildTypePart.statusChanged { 152 | border-top: 3px solid gold; 153 | border-bottom: 3px solid gold; 154 | } 155 | 156 | .buildTypePart.buildTypeTitle.statusChanged { 157 | border-left: 3px solid gold; 158 | padding-left: 2px; 159 | } 160 | 161 | .buildTypePart.buildList.statusChanged { 162 | border-right: 3px solid gold; 163 | padding-right: 2px; 164 | } 165 | 166 | .buildSteps { 167 | grid-column: 1 / -1; 168 | background-color: #eee; 169 | border: black solid 1px; 170 | border-radius: 5px; 171 | padding: 5px; 172 | margin-bottom: 5px; 173 | } 174 | 175 | .buildButtonBar { 176 | display: grid; 177 | grid-template-columns: repeat(5, 1fr); 178 | column-gap: 1em; 179 | } 180 | 181 | .tests, .messages, .changes { 182 | background-color: white; 183 | padding: 0.5em; 184 | line-break: anywhere; 185 | } 186 | 187 | a.message:hover { 188 | text-decoration: underline; 189 | } 190 | 191 | .messageSub { 192 | border-left: 2px solid black; 193 | padding-left: 0.4em; 194 | margin-left: 0.4em; 195 | } 196 | 197 | .message { 198 | padding: 0.2em 0; 199 | text-decoration: none; 200 | } 201 | 202 | .message.error { 203 | font-weight: bold; 204 | color: rgb(207, 69, 69); 205 | } 206 | 207 | .message:nth-child(odd) { 208 | background-color: #eee; 209 | } 210 | 211 | .message:nth-child(even) { 212 | background-color: #fff; 213 | } 214 | 215 | .message .collapseButton { 216 | width: auto; 217 | } 218 | 219 | .tests { 220 | display: grid; 221 | } 222 | 223 | .tests .testInvestigated { 224 | color: var(--deltares-blue); 225 | } 226 | 227 | .testsText { 228 | display: inline; 229 | } 230 | 231 | .changes { 232 | display: grid; 233 | grid-template-columns: auto auto auto auto; 234 | gap: 0.5em; 235 | } 236 | 237 | .emptyChanges { 238 | grid-column: 1 / -1; 239 | } 240 | 241 | .changes .changeVersion { 242 | grid-column: 1; 243 | padding: 0.5em; 244 | background: #eee; 245 | border-radius: 10px; 246 | } 247 | 248 | .changes .changeLink { 249 | grid-column: 2; 250 | padding: 0.5em; 251 | background: #eee; 252 | border-radius: 10px; 253 | } 254 | 255 | .changes .changeUser { 256 | grid-column: 3; 257 | padding: 0.5em; 258 | background: #eee; 259 | border-radius: 10px; 260 | font-weight: bolder; 261 | } 262 | 263 | .changes .changeTime { 264 | grid-column: 4; 265 | padding: 0.5em; 266 | background: #eee; 267 | border-radius: 10px; 268 | font-size: 0.8em; 269 | } -------------------------------------------------------------------------------- /old/img/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 36 | 38 | 42 | 49 | 56 | 63 | 70 | 77 | 84 | 86 | 92 | 98 | 104 | 110 | 116 | 117 | 120 | 126 | 132 | 138 | 144 | 150 | 151 | 154 | 160 | 166 | 172 | 178 | 184 | 185 | 188 | 194 | 200 | 206 | 212 | 218 | 219 | 221 | 227 | 233 | 239 | 245 | 251 | 257 | 263 | 269 | 275 | 281 | 282 | 283 | 284 | -------------------------------------------------------------------------------- /src/render.js: -------------------------------------------------------------------------------- 1 | /* Render 2 | / 3 | / Data interpreted by data.js appended to index.html 4 | / 5 | / render all data received by query interpreted by data.js into index.html 6 | / Handle index.html display actions and input 7 | */ 8 | 9 | //Create a new element with given vars and return the created element 10 | class HtmlRender { 11 | 12 | createElement(ElementType, ElementClasses, ElementAttributes, textNode, parentElement, order){ 13 | 14 | //Create a new element 15 | let newElement = document.createElement(ElementType) 16 | 17 | //Add classes to element for styling purposes 18 | if(ElementClasses) { 19 | for (let ElementClass of ElementClasses) { 20 | newElement.classList.add(ElementClass) 21 | } 22 | } 23 | //Add element attributes like title and onclick 24 | if (ElementAttributes) { 25 | Object.entries(ElementAttributes).forEach(([attr, value]) => { 26 | newElement.setAttribute(attr, value) 27 | }) 28 | } 29 | //Add text to element 30 | if (textNode) { 31 | newElement.innerText = textNode 32 | } 33 | //For grid elements that are appended asynchronously add an order for consistent placements 34 | if (order) { 35 | newElement.style.order = order 36 | } 37 | //Append the created element as a child of specified parentelement 38 | parentElement.appendChild(newElement) 39 | 40 | return newElement 41 | } 42 | 43 | //Places an element as first of all the children within the parent. 44 | moveElementToTop(element, parent) { 45 | parent.insertBefore(element, parent.firstChild) 46 | } 47 | 48 | //Handle visible username and show/hide login box 49 | loginElement(username, hidden){ 50 | document.getElementById('username').innerHTML = username 51 | 52 | if (hidden){ 53 | document.getElementById('login').classList.add('hidden') 54 | } 55 | else{ 56 | document.getElementById('login').classList.remove('hidden') 57 | } 58 | } 59 | 60 | // Show or hide all build types of which the last build was successful. 61 | toggleGreen() { 62 | 63 | let greenBuildTypes = document.querySelectorAll('#_projects .buildTypePart.SUCCESS') 64 | 65 | for (let item of greenBuildTypes) { 66 | item.classList.toggle('hideGreen') 67 | } 68 | 69 | } 70 | 71 | // Show or hide all build types of which the status has not changed. 72 | toggleUnchangedBuildTypes() { 73 | 74 | let unchangedBuildTypes = document.querySelectorAll('#_projects .buildTypePart:not(.statusChanged)') 75 | 76 | for (let item of unchangedBuildTypes) { 77 | item.classList.toggle('hideNotChanged') 78 | } 79 | 80 | } 81 | 82 | // Update values for the time selection element 83 | timeElementSet(data) { 84 | 85 | document.getElementById('build_count').value = data.build_count 86 | document.getElementById('build_cutoff_days').value = data.build_cutoff_days 87 | document.getElementById('end_time').value = data.end_time 88 | } 89 | 90 | // Add a named selection option to the dropdown in JSON selection element 91 | addNameDropdown(name) { 92 | 93 | let attributes = {'id':`namedSelectionOption_${name}`,'value':name} 94 | let dropdown = document.getElementById('namedSelection') 95 | let option = this.createElement('option', null, attributes, name, dropdown, null) 96 | dropdown.disabled = false 97 | } 98 | 99 | // Remove a named selection option to the dropdown in JSON selection element 100 | removeNameDropdown(name) { 101 | 102 | let dropdown = document.getElementById('namedSelection') 103 | let option = dropdown.namedItem(`namedSelectionOption_${name}`) 104 | dropdown.removeChild(option) 105 | 106 | if (dropdown.length < 2) 107 | dropdown.disabled = true 108 | } 109 | 110 | //cleanup off all elements when (re)fetching data. 111 | cleanDataWrapper(important) { 112 | 113 | document.getElementById('_projects').innerHTML = '' 114 | 115 | document.getElementById('important_wrapper').innerHTML = '' 116 | 117 | document.getElementById('_important').hidden = !important 118 | } 119 | 120 | //Create elements for projects declared in the selection to append to. 121 | addParentProjectElements(includeProjects) { 122 | 123 | for (let project of includeProjects) { 124 | let elementClass = ['projectWrapper'] 125 | let attributes = {'id':`${project}_wrapper`} 126 | let element = document.getElementById('_projects') 127 | this.createElement('div', elementClass, attributes, null, element, null) 128 | } 129 | } 130 | 131 | //Clear absolute position placements to prevent overlapping where it shouldn't occur. 132 | addClearElement(element) { 133 | 134 | let clearElement = document.createElement('div') 135 | clearElement.style.clear = 'both' 136 | element.append(clearElement) 137 | } 138 | 139 | //Add specified stats text to html element 140 | updateProjectStats(projectId, suffix, text) { 141 | 142 | let element = document.getElementById(`${projectId}_stats${suffix}`) 143 | let testStatsNode = document.createTextNode(text) 144 | element.replaceChildren(testStatsNode) 145 | } 146 | 147 | setupBuildDetails(buildId, buildTypeId, suffix) { 148 | 149 | let buildDetails = document.getElementById(`${buildTypeId}_buildSteps${suffix}`) 150 | buildDetails.innerHTML = "" 151 | buildDetails.classList.remove('hidden') 152 | 153 | //create onclick actions: toggle active button and hide elements that are not active 154 | let toggleActiveBtn = `this.parentElement.getElementsByClassName('active')[0].classList.remove('active') 155 | this.classList.add('active')` 156 | let showMessages = `;this.parentElement.parentElement.getElementsByClassName('messages')[0].classList.remove('hidden')` 157 | let hideMessages = `;this.parentElement.parentElement.getElementsByClassName('messages')[0].classList.add('hidden')` 158 | let showTests = `;this.parentElement.parentElement.getElementsByClassName('tests')[0].classList.remove('hidden')` 159 | let hideTests = `;this.parentElement.parentElement.getElementsByClassName('tests')[0].classList.add('hidden')` 160 | let showChanges = `;this.parentElement.parentElement.getElementsByClassName('changes')[0].classList.remove('hidden')` 161 | let hideChanges = `;this.parentElement.parentElement.getElementsByClassName('changes')[0].classList.add('hidden')` 162 | 163 | //create button container 164 | let buildButtonBar = this.createElement('div', ['header', 'buildButtonBar'], null, null, buildDetails, null) 165 | 166 | //Create specific buttons for Logs, tests and changes 167 | let attributes = {'onclick': `${toggleActiveBtn}${showMessages}${hideTests}${hideChanges}`} 168 | this.createElement('button', ['toggle', 'active'], attributes, 'Important logs', buildButtonBar, null) 169 | 170 | attributes = {'onclick': `${toggleActiveBtn}${hideMessages}${showTests}${hideChanges}`} 171 | this.createElement('button', ['toggle'], attributes, 'Tests', buildButtonBar, null) 172 | 173 | attributes = {'onclick': `${toggleActiveBtn}${hideMessages}${hideTests}${showChanges}`} 174 | this.createElement('button', ['toggle'], attributes, 'Blame', buildButtonBar, null) 175 | 176 | attributes = {'onclick': `window.open('${teamcity_base_url}/viewLog.html?buildId=${buildId}&buildTypeId=${buildTypeId};','build_${buildId}','fullscreen=yes')`} 177 | this.createElement('button', null, attributes, 'Open in teamcity ⧉', buildButtonBar, null) 178 | 179 | attributes = {'onclick': `this.parentElement.parentElement.classList.add('hidden')`} 180 | this.createElement('button', null, attributes, 'Close', buildButtonBar, null) 181 | 182 | //Create Containers for messages, tests and changes 183 | let messageDiv = this.createElement('div', ['messages'], null, null, buildDetails, null) 184 | let testsDiv = this.createElement('div', ['tests', 'hidden'], null, null, buildDetails, null) 185 | let changesDiv = this.createElement('div', ['changes', 'hidden'], null, null, buildDetails, null) 186 | 187 | let containers = {'message': messageDiv, 'tests': testsDiv, 'changes': changesDiv} 188 | return containers 189 | } 190 | 191 | //insert selection into html elements 192 | updateSelectionForm(selection, edit_selection) { 193 | 194 | if (edit_selection) { 195 | selection_textarea.value = JSON.stringify(edit_selection, undefined, 2) 196 | } 197 | 198 | if (selection) { 199 | const selectionDiv = document.getElementById('selection_code') 200 | selectionDiv.innerText = JSON.stringify(selection, undefined, 2) 201 | } 202 | } 203 | 204 | updateQueue(addition, number) { 205 | 206 | //get the container for queuecount 207 | let queueContainer = document.getElementById('queueCount') 208 | 209 | // Add or remove the specified number to queuecount 210 | if (addition) { 211 | main.queueCount += number 212 | } else { 213 | main.queueCount -= number 214 | } 215 | 216 | queueContainer.innerHTML = main.queueCount 217 | 218 | // Add a load icon as cursor when data is still loading 219 | if (main.queueCount < 1) { 220 | document.body.classList.remove('loading') 221 | } else { 222 | document.body.classList.add('loading') 223 | } 224 | } 225 | 226 | resetFilterToggle() { 227 | let unchangedToggle = document.getElementById('toggle_unchanged') 228 | let greenToggle = document.getElementById('toggle_green') 229 | unchangedToggle.classList.remove('active') 230 | greenToggle.classList.remove('active') 231 | } 232 | } -------------------------------------------------------------------------------- /old/css/tcview.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --deltares-blue: #0a28a3; 3 | } 4 | 5 | /* Class to hide any element */ 6 | .hidden { 7 | display: none !important; 8 | } 9 | 10 | /* Class to hide unchanged build types when toggled */ 11 | .hidden_statusChanged { 12 | display: none !important; 13 | } 14 | 15 | .collapsed { 16 | background: #eee; /* A bit less white. */ 17 | } 18 | 19 | .collapsed > .project, .collapsed > .projectBuildTypesDiv { 20 | display: none !important; 21 | } 22 | 23 | .collapse_button { 24 | display: inline-block; 25 | margin-right: 0.2em; 26 | width: 1em; 27 | } 28 | 29 | .collapse_button:hover { 30 | cursor: pointer; 31 | } 32 | 33 | #legend { 34 | z-index: 10; 35 | background: white; 36 | position: fixed; 37 | top: 0px; 38 | right: 0px; 39 | padding: 5px; 40 | border-radius: 0px 0px 0px 10px; 41 | display: grid; 42 | grid-template-columns: auto auto; 43 | column-gap: 0.5em; 44 | border-left: 1px solid black; 45 | border-bottom: 1px solid black; 46 | transition: width 500ms; 47 | } 48 | 49 | #legend > div { 50 | white-space: nowrap; 51 | line-height: 1.5em; 52 | } 53 | 54 | @media (max-width: 110em) { 55 | #legend { 56 | width: 2ch; 57 | } 58 | } 59 | 60 | #legend:hover { 61 | width: 10em; 62 | } 63 | 64 | /* From Deltares.nl */ 65 | body { 66 | font-family: Helvetica, Arial, sans-serif; 67 | font-size: 16px; 68 | padding: 10px 3ch; 69 | } 70 | 71 | button { 72 | font-family: Helvetica, Arial, sans-serif !important; 73 | font-size: 18px; 74 | letter-spacing: 1px; 75 | font-weight: normal !important; 76 | color: white; 77 | background-color: #666; 78 | border: 3px solid #666; 79 | padding: 0px 10px; 80 | border-radius: 5px; 81 | line-height: 1.5em; 82 | } 83 | 84 | button:disabled { 85 | opacity: 25%; 86 | } 87 | 88 | button:disabled:hover { 89 | opacity: 25%; 90 | background-color: #666 !important; 91 | border: 3px solid #666 !important; 92 | } 93 | 94 | button.toggle.active { 95 | background-color: black; 96 | } 97 | 98 | button:hover { 99 | border: 3px solid black !important; 100 | background-color: #777; 101 | } 102 | 103 | #body_wrap { 104 | min-width: 40em; 105 | max-width: 90em; 106 | margin: auto; 107 | } 108 | 109 | .header { 110 | margin: 0.5rem 0px; 111 | font-size: 1.5rem; 112 | } 113 | 114 | #selection { 115 | display: grid; 116 | grid-template-columns: 1fr auto 1fr; 117 | column-gap: 2em; 118 | row-gap: 1em; 119 | padding: 1em 2em 2em 2em !important; 120 | } 121 | 122 | .wide { 123 | grid-column: 1/-1; 124 | } 125 | 126 | form { 127 | height: 100%; 128 | } 129 | 130 | #selection_textarea { 131 | resize: none; 132 | width: 100%; 133 | height: 100%; 134 | box-sizing: border-box; 135 | background: black !important; 136 | } 137 | 138 | .code { 139 | padding: 1em; 140 | font-family: 'Courier New', Courier, monospace; 141 | white-space: pre; 142 | background-color: #666 !important; 143 | color: white; 144 | font-size: 16px; 145 | line-height: 1.8em; 146 | border-radius: 0.5em; 147 | overflow: scroll; 148 | min-height: 20em; 149 | } 150 | 151 | #stats { 152 | position: absolute; 153 | padding: 10px; 154 | font-family: 'Courier New', Courier, monospace; 155 | display: grid; 156 | grid-template-columns: auto auto; 157 | column-gap: 0.5em; 158 | font-size: 0.8em 159 | } 160 | 161 | /* Deltares blue */ 162 | #deltares_logo, h1 { 163 | text-align: center; 164 | color: var(--deltares-blue); 165 | fill: var(--deltares-blue); /* SVG */ 166 | } 167 | 168 | h1 { 169 | font-size: 24px; 170 | font-style: italic; 171 | margin-top: 2px; 172 | font-weight: normal; 173 | } 174 | 175 | /* Default link color and styling */ 176 | a { 177 | color: black; 178 | text-decoration: none; 179 | } 180 | 181 | .build.running > a, .build.queued > a { 182 | color: white !important; 183 | animation-duration: 0.5s; 184 | animation-name: pulsate; 185 | animation-iteration-count: infinite; 186 | animation-direction: alternate; 187 | } 188 | 189 | a:hover.buildType { 190 | text-decoration: underline; 191 | } 192 | 193 | a:hover.project_title { 194 | text-decoration: underline; 195 | } 196 | 197 | /* Overrule link color when hovered */ 198 | a:hover { 199 | color: black !important; 200 | } 201 | 202 | /* Project/BuildType link icon ⧉ */ 203 | a > div.linkIcon { 204 | font-weight: normal; 205 | color: #ddd; 206 | margin-left: 5px; 207 | display: inline-block; 208 | } 209 | 210 | /* Project/BuildType link icon ⧉ */ 211 | a:hover > div.linkIcon { 212 | color: black; 213 | margin-left: 5px; 214 | display: inline-block; 215 | } 216 | 217 | /* This link should blend in */ 218 | #info a, #login a, #firefox a { 219 | color: white; 220 | text-decoration: underline; 221 | } 222 | 223 | /* Buttons below the page header */ 224 | #buttonList { 225 | display: flex; 226 | justify-content: center; 227 | column-gap: 10px; 228 | margin: 10px; 229 | } 230 | 231 | /* visible when the user is not logged into TeamCity */ 232 | #login, #firefox, #info, #time { 233 | text-align: center; 234 | font-size: 150%; 235 | padding: 1em !important; 236 | display: flex; flex-direction: column; 237 | } 238 | 239 | /* Hidden divisions */ 240 | #firefox, #login, #info, #selection, #time { 241 | font-size: 18px; 242 | margin: 10px; 243 | padding: 10px; 244 | color: white; 245 | background-color: gray; 246 | } 247 | 248 | /* Any project */ 249 | #_projects .project, #_important .project { 250 | margin-left: 20px; 251 | border-left: 2px dashed lightgray; 252 | border-top: 2px dashed lightgray; 253 | padding: 4px; 254 | padding-right: 0px; 255 | display: grid; 256 | grid-template-columns: auto; 257 | } 258 | 259 | /* First level of projects. */ 260 | #_projects > div.project_wrapper > div.project, 261 | #_important > div.project_wrapper > div.project { 262 | margin-left: 0px; 263 | margin-bottom: 2em; 264 | font-weight: bold; 265 | border-bottom: 2px dashed lightgray; 266 | } 267 | 268 | /* Title/link of first level of projects. */ 269 | #_projects > div.project_wrapper > div.project > div > a.project_title, 270 | #_projects > div.project_wrapper > div.project > div > div.collapse_button, 271 | #_important > div.project_wrapper > div.project > div > p.project_title, 272 | #_important > div.project_wrapper > div.project > div > div.collapse_button { 273 | font-size: 150%; 274 | } 275 | 276 | #_important > div.project_wrapper > div.project > div > p.project_title{ 277 | margin: 0px; 278 | } 279 | 280 | /* All other levels of projects. */ 281 | #_projects > div.project_wrapper > div.project > div.project { 282 | font-size: initial; 283 | } 284 | 285 | /* Hide projects without builds. */ 286 | .project:not(:has(.build)) { 287 | display:none !important; 288 | } 289 | 290 | /* Hide projects with only hidden build types. */ 291 | .project:not(:has(.buildType:not(.hidden,.hidden_statusChanged))) { 292 | display:none !important; 293 | } 294 | 295 | /* Build types where the last build failed. */ 296 | .buildTypePart.FAILURE { 297 | background-color: #fee !important; 298 | } 299 | 300 | /* Build types where the last build succeeded. */ 301 | .buildTypePart.SUCCESS { 302 | background-color: #efe !important; 303 | } 304 | 305 | /* Build types where the last build succeeded. */ 306 | .buildTypePart.UNKNOWN { 307 | background-color: #fee !important; 308 | } 309 | 310 | /* Name of the build type. */ 311 | .buildType > a { 312 | display: inline-block; 313 | grid-column: 1; 314 | word-break: break-all; 315 | } 316 | 317 | .projectBuildTypesDiv { 318 | font-family: 'Courier New', Courier, monospace; 319 | font-weight: initial; 320 | /*padding: 0.2em; 321 | margin: 0.2em;*/ 322 | margin-left: 20px; 323 | } 324 | 325 | /*.BuildTypeLink { 326 | color: black; 327 | background-color: #eee; 328 | }*/ 329 | 330 | /* When the last build has a different status than the second-last. */ 331 | .buildTypePart.statusChanged { 332 | border-top: 3px solid gold; 333 | border-bottom: 3px solid gold; 334 | padding: 5px 2px; 335 | } 336 | 337 | .buildTypePart.buildTypeLink.statusChanged { 338 | border-left: 3px solid gold; 339 | } 340 | 341 | .buildTypePart.buildList.statusChanged { 342 | border-right: 3px solid gold; 343 | } 344 | 345 | /* List of builds for the build type. */ 346 | .buildList { 347 | display: block; 348 | grid-column: 3; 349 | text-align: right; 350 | white-space: nowrap; 351 | letter-spacing: 0.0625em; 352 | } 353 | 354 | /* ⬤ = build */ 355 | .build { 356 | display: inline-block; 357 | font-style: initial; 358 | text-decoration: none; 359 | } 360 | 361 | .build > a { 362 | display: inline-block; 363 | margin: 0px 2px; 364 | } 365 | 366 | .build.FAILURE > a { 367 | color: #F0C0B3; 368 | -webkit-text-stroke: 4px #F0C0B3; 369 | } 370 | 371 | .build.SUCCESS > a { 372 | color: #B5DBB6; 373 | -webkit-text-stroke: 4px #B5DBB6; 374 | } 375 | 376 | .build.UNKNOWN > a { 377 | color: #666; 378 | -webkit-text-stroke: 4px #666; 379 | } 380 | 381 | .build.NORESULT > a{ 382 | -webkit-text-stroke: 4px black; 383 | } 384 | 385 | .build.newFailed.FAILURE > a { 386 | border: none; 387 | color: #FFE8B3; 388 | -webkit-text-stroke: 4px #F0C0B3; 389 | } 390 | 391 | .build.newFailed.SUCCESS > a { 392 | border: none; 393 | color: #FFE8B3; 394 | -webkit-text-stroke: 4px #B5DBB6; 395 | } 396 | 397 | .build.FAILURE:last-child > a { 398 | color: red; 399 | -webkit-text-stroke: 4px red; 400 | } 401 | 402 | .build.SUCCESS:last-child > a { 403 | color: green; 404 | -webkit-text-stroke: 4px green; 405 | } 406 | 407 | .build.UNKNOWN:last-child > a { 408 | color: black; 409 | -webkit-text-stroke: 4px black; 410 | } 411 | 412 | .build.newFailed.FAILURE:last-child > a { 413 | border: none; 414 | color: gold; 415 | -webkit-text-stroke: 4px red; 416 | } 417 | 418 | .build.newFailed.SUCCESS:last-child > a { 419 | border: none; 420 | color: gold; 421 | -webkit-text-stroke: 4px green; 422 | } 423 | 424 | .build:hover { 425 | color: black; /* Fix/overrule race-condition in CSS styling */ 426 | cursor: pointer; 427 | } 428 | 429 | /* Button to expand build list info. */ 430 | .buildButtonBar { 431 | display: grid; 432 | grid-template-columns: repeat(5, 1fr); 433 | column-gap: 1em; 434 | margin: 0px; 435 | } 436 | 437 | /* Build steps on expansion of build type. */ 438 | .buildSteps { 439 | display: flex; 440 | flex-direction: column; 441 | background: white; 442 | margin: 1em; 443 | padding: 0em 1em 1em 1em; 444 | grid-column: 1 / -1; 445 | min-width: 0; 446 | min-height: 0; 447 | border: 1px solid black; 448 | border-radius: 10px; 449 | } 450 | 451 | .buildSteps > a { 452 | text-align: center; 453 | font-weight: bold; 454 | } 455 | 456 | .buildSteps > a:hover { 457 | cursor: pointer; 458 | } 459 | 460 | .message.error { 461 | font-weight: bold; 462 | color: rgb(207, 69, 69); 463 | } 464 | 465 | .message.warning { 466 | font-weight: bold; 467 | color: black; 468 | } 469 | 470 | .message.unknown { 471 | font-weight: bold; 472 | color: gray; 473 | } 474 | 475 | .message.normal { 476 | color:black; 477 | font-weight: normal; 478 | } 479 | .message span:empty::before{ 480 | content: ""; 481 | display: inline-block; 482 | } 483 | 484 | .message, .change { 485 | grid-column: 1; 486 | margin: 0.2em; 487 | padding: 0.2em; 488 | display: block; 489 | } 490 | 491 | .message:nth-child(odd), .change:nth-child(odd) { 492 | background: #eee; 493 | } 494 | 495 | .message:nth-child(even), .change:nth-child(even) { 496 | background: #fff; 497 | } 498 | 499 | .message > p { 500 | display: inline; 501 | } 502 | 503 | .changes { 504 | display: grid; 505 | grid-template-columns: fit-content(25%) auto fit-content(50%) fit-content(50%); 506 | gap: 0.5em; 507 | } 508 | 509 | .changes > * { 510 | padding: 0.5em; 511 | background: #eee; 512 | border-radius: 10px; 513 | } 514 | 515 | .smaller { 516 | font-size: 0.8em; 517 | } 518 | 519 | .build_user_name { 520 | text-align: center; 521 | font-weight: bolder; 522 | } 523 | 524 | .build_user_time { 525 | text-align: right; 526 | white-space: nowrap; 527 | } 528 | 529 | @keyframes pulsate { 530 | from { 531 | transform: scale(1); 532 | } 533 | 534 | to { 535 | transform: scale(1.4); 536 | } 537 | } 538 | 539 | .branch_selected { 540 | 541 | } 542 | 543 | .project_header_wrapper { 544 | margin-right: 0.4em; 545 | display: grid; 546 | grid-template-columns: fit-content(100%) fit-content(100%) auto; 547 | margin-bottom: 5px; 548 | } 549 | 550 | .projectBuildTypesDiv { 551 | display: grid; 552 | grid-template-columns: 1fr auto auto auto; 553 | row-gap: 0px; 554 | } 555 | 556 | .projectBuildTypesDiv > * { 557 | margin: 5px 0px; 558 | padding: 5px; 559 | background-color: #eee; 560 | } 561 | 562 | .test_statistics_text { 563 | text-align: right; 564 | } 565 | 566 | .tests, .messages, .changes { 567 | background-color: white; 568 | padding: 0.5em; 569 | line-break: anywhere; 570 | } 571 | 572 | .projectStats { 573 | text-align: right; 574 | } 575 | 576 | .collapse_message_button:hover { 577 | cursor: pointer; 578 | } 579 | 580 | .collapse_message_button::first-letter { 581 | width: 1em; 582 | } 583 | -------------------------------------------------------------------------------- /old/js/projects.js: -------------------------------------------------------------------------------- 1 | // API field selectors for optimization. 2 | const project_fields = 'fields=id,name,parentProjectId,projects(project(id)),buildTypes(buildType(id,name,projectId))' 3 | const important_fields = 'fields=id,name' 4 | const buildType_fields = 'fields=build(id,agent(id,name),state,buildTypeId,number,branchName,status,tags(tag),finishOnAgentDate,finishEstimate,running-info(leftSeconds),statusText,failedToStart,problemOccurrences,testOccurrences(count,muted,ignored,passed,failed,newFailed))' 5 | const message_fields = 'fields=messages' 6 | const buildDetails_fields = 'fields=count,passed,failed,muted,ignored,newFailed,testOccurrence(id,name,status,details,newFailure,muted,failed,ignored,test(id,name,parsedTestName,investigations(investigation(assignee))),build(id,buildTypeId),logAnchor)' 7 | const change_fields = 'fields=change(id,date,version,user,username,comment,files(file(file,relative-file)))' 8 | const testOccurrences_fields = 'fields=newFailed,testOccurrence(status,currentlyInvestigated),ignored,muted,passed,count' 9 | const progressinfo_fields = 'fields=estimatedTotalSeconds' 10 | 11 | // Keep track of pending downloads. 12 | let download_queue_length = 0 13 | 14 | /* PROJECTS 15 | / 16 | / Recursively traverse (sub-)projects 17 | / 18 | / projects[]: Array to append projects to 19 | / projectId: (String) Project ID to recursively append 20 | / parentProjectStats: keep track of cumulative stats of all projects 21 | / parentProjectIds: List of project ID's that are parents 22 | / 23 | / Note: Project IDs in exclude_projects[] are skipped 24 | */ 25 | async function append_projects_recursively(projectId, order, parentProjectStats, parentProjectIds) { 26 | 27 | // Excluded projects are skipped entirely. 28 | if (selection.exclude_projects.includes(projectId)) 29 | return 30 | 31 | // Should be array instead of undefined 32 | if (!parentProjectStats) { 33 | parentProjectStats = [] 34 | parentProjectIds = [] 35 | } 36 | 37 | parentProjectIds.push(projectId) 38 | 39 | // Will enable/disable buttons when there are downloads in progress. 40 | checkFilterButtons(++download_queue_length) 41 | 42 | fetch(`${teamcity_base_url}/app/rest/projects/id:${projectId}?${project_fields}`, { 43 | headers: { 44 | 'Accept': 'application/json', 45 | }, 46 | credentials: 'include', 47 | priority: 'high', 48 | }, this) 49 | .then((result) => { 50 | if (result.status == 200) { 51 | return result.json() 52 | } else { 53 | return Promise.reject('User not logged in.') 54 | } 55 | }) 56 | .then((project) => { 57 | 58 | let projectStats = {} 59 | 60 | project.order = order // Consistent ordering of projects. 61 | 62 | projectStats.newFailed = 0 63 | projectStats.failedInvestigated = 0 64 | projectStats.failedNotInvestigated = 0 65 | projectStats.ignored = 0 66 | projectStats.muted = 0 67 | projectStats.passed = 0 68 | projectStats.count = 0 69 | parentProjectStats[project.id] = projectStats; 70 | 71 | // For quick reference, store the element with the project. 72 | project.div = renderProject(project) 73 | 74 | // Check for builds to add to project. 75 | if (project.buildTypes.buildType) { 76 | 77 | Object.entries(project.buildTypes.buildType).forEach(([key, buildType]) => { 78 | buildType.order = key // Consistent ordering of buildTypes. 79 | add_builds_to_buildtype(project.buildTypes.buildType[key], parentProjectStats, parentProjectIds) 80 | }, this) 81 | 82 | } 83 | 84 | // Check for sub-projects. 85 | if (project.projects.project) { 86 | Object.entries(project.projects.project).forEach(([key, subproject]) => { 87 | append_projects_recursively(subproject.id, project.buildTypes?project.buildTypes.buildType.length+key:key, parentProjectStats, [...parentProjectIds]) // Make sure that projects are below the buildTypes. 88 | }, this) 89 | } 90 | 91 | }) 92 | .catch(err => { console.log(err) }) 93 | .finally(() => {checkFilterButtons(--download_queue_length)}) 94 | } 95 | 96 | async function append_important(buildTypeId, buildTypeOrder, parentProjectStats, parentProjectIds) { 97 | 98 | if (!parentProjectStats) { 99 | parentProjectStats = [] 100 | parentProjectIds = [] 101 | } 102 | 103 | fetch(`${teamcity_base_url}/app/rest/buildTypes/id:${buildTypeId}?${important_fields}`, { 104 | headers: { 105 | 'Accept': 'application/json', 106 | }, 107 | credentials: 'include', 108 | priority: 'high', 109 | },this) 110 | .then((result) => result.json()) 111 | .then((buildType) => { 112 | checkFilterButtons(++download_queue_length) 113 | let project = [] 114 | project.parentProjectId = 'important' 115 | project.id = 'important_buildtypes' 116 | project.name = 'Important build types' 117 | project.important_buildtype = true 118 | buildType.projectId = 'important_buildtypes' 119 | buildType.locationSuffix = '_important' 120 | //buildType.parentProjectId = 'important' 121 | buildType.order = buildTypeOrder 122 | if (buildTypeOrder < 1) 123 | renderProject(project) 124 | add_builds_to_buildtype(buildType, parentProjectStats, parentProjectIds) 125 | }) 126 | .catch(err => { console.log(err) }) 127 | .finally(() => {checkFilterButtons(--download_queue_length)}) 128 | } 129 | 130 | /* BUILDTYPES & BUILDS 131 | / 132 | / projects[]: Array to append projects to 133 | / projectId: (String) Project ID to recursively append 134 | / parentProjectStats: keep track of cumulative stats of all projects 135 | / parentProjectIds: List of project ID's that are parents 136 | / 137 | / Note: Project IDs in exclude_projects[] are skipped 138 | */ 139 | async function add_builds_to_buildtype(buildType, parentProjectStats, parentProjectIds) { 140 | 141 | // Will enable/disable buttons when there are downloads in progress. 142 | checkFilterButtons(++download_queue_length) 143 | 144 | let time_boundries 145 | if (end_time) { 146 | time_boundries = `queuedDate:(date:${cutoffTcString(htmlDateTimeToDate(end_time))},condition:after),queuedDate:(date:${htmlDateTimeToTcTime(end_time)},condition:before)` 147 | } else { 148 | time_boundries = `queuedDate:(date:${cutoffTcString()},condition:after)` 149 | } 150 | 151 | fetch(`${teamcity_base_url}/app/rest/builds?locator=defaultFilter:false,branch:default:true,state:any,buildType:(id:${buildType.id}),${time_boundries},count:${build_count}&${buildType_fields}`, { 152 | headers: { 153 | 'Accept': 'application/json', 154 | }, 155 | credentials: 'include', 156 | priority: 'high', 157 | },this) 158 | .then((result) => result.json()) 159 | .then((output) => { 160 | 161 | buildType.builds = output 162 | 163 | // Check if the latest build result has changed. 164 | if (buildType.builds.build?.[0]?.problemOccurrences?.newFailed > 0) { 165 | buildType.statusChanged = true 166 | } else if (buildType.builds.build?.[0]?.status != buildType.builds.build[1]?.status) { 167 | buildType.statusChanged = true 168 | } else if (buildType.builds.build?.[0]?.testOccurrences?.passed != buildType.builds.build[1]?.testOccurrences?.passed) { 169 | buildType.statusChanged = true 170 | } else { 171 | buildType.statusChanged = false 172 | } 173 | 174 | // The last build determines the buildtype status. 175 | if (buildType.builds.build?.[0]?.status && (buildType.builds.build?.[0]?.state=='finished' || buildType.builds.build?.[0]?.status=='FAILURE')) { 176 | buildType.status = buildType.builds.build?.[0]?.status 177 | } 178 | 179 | renderBuildType(buildType) 180 | 181 | // Check for every build if the result has changed since the previous build. 182 | if (buildType.builds.build?.[0]) { 183 | 184 | let build = buildType.builds.build 185 | 186 | build.stats = add_tests_to_build(buildType.builds.build?.[0]?.id, buildType.id, buildType.locationSuffix, parentProjectStats, parentProjectIds) 187 | 188 | 189 | for (i=0; i { console.log(err) }) 219 | .finally(() => {checkFilterButtons(--download_queue_length)}) 220 | } 221 | 222 | // Display test results of buildId to the build type and (parent)projects. 223 | async function add_tests_to_build(buildId, buildTypeId, locationSuffix, parentProjectStats, parentProjectIds) { 224 | fetch(`${teamcity_base_url}/app/rest/testOccurrences?locator=build:(id:${buildId}),count:-1&${testOccurrences_fields}`, { 225 | headers: { 226 | 'Accept': 'application/json', 227 | }, 228 | credentials: 'include', 229 | priority: 'low', 230 | },this) 231 | .then((result) => result.json()) 232 | .then((output) => { 233 | 234 | if (output.testOccurrence[0]) { 235 | let buildStats = Object(); 236 | buildStats.buildId = buildId 237 | buildStats.buildTypeId = buildTypeId 238 | buildStats.testOccurrences = output 239 | renderBuildTypeStats(buildStats, locationSuffix, parentProjectStats, parentProjectIds) 240 | } 241 | 242 | }) 243 | .catch(err => { console.log(err) }) 244 | } 245 | 246 | // On-demand information when a build is clicked. 247 | async function get_build_details(buildId, buildStepsDivId) { 248 | 249 | // MESSAGES 250 | let messagesRequest = await fetch(`${teamcity_base_url}/app/messages?buildId=${buildId}&filter=important&${message_fields}`, { 251 | headers: { 252 | 'Accept': 'application/json', 253 | }, 254 | credentials: 'include', 255 | }) 256 | 257 | let messagesJSON = await messagesRequest.json() 258 | 259 | let messages = messagesJSON.messages 260 | 261 | // FAILED TESTS 262 | let testsRequestFailed = await fetch(`${teamcity_base_url}/app/rest/testOccurrences?locator=count:-1,build:(id:${buildId}),status:(failure)&${buildDetails_fields}`, { 263 | headers: { 264 | 'Accept': 'application/json', 265 | }, 266 | credentials: 'include', 267 | }) 268 | 269 | let testsFailedJSON = await testsRequestFailed.json() 270 | 271 | // ERROR TESTS 272 | let testsRequestError = await fetch(`${teamcity_base_url}/app/rest/testOccurrences?locator=count:-1,build:(id:${buildId}),status:(error)&${buildDetails_fields}`, { 273 | headers: { 274 | 'Accept': 'application/json', 275 | }, 276 | credentials: 'include', 277 | }) 278 | 279 | let testsErrorJSON = await testsRequestError.json() 280 | 281 | // WARNING TESTS 282 | let testsRequestWarning = await fetch(`${teamcity_base_url}/app/rest/testOccurrences?locator=count:-1,build:(id:${buildId}),status:(warning)&${buildDetails_fields}`, { 283 | headers: { 284 | 'Accept': 'application/json', 285 | }, 286 | credentials: 'include', 287 | }) 288 | 289 | let testsWarningJSON = await testsRequestWarning.json() 290 | 291 | // UNKNOWN TESTS 292 | let testsRequestUnknown = await fetch(`${teamcity_base_url}/app/rest/testOccurrences?locator=count:-1,build:(id:${buildId}),status:(unknown)&${buildDetails_fields}`, { 293 | headers: { 294 | 'Accept': 'application/json', 295 | }, 296 | credentials: 'include', 297 | }) 298 | 299 | let testsUnknownJSON = await testsRequestUnknown.json() 300 | 301 | let tests = [] 302 | tests = tests.concat(testsFailedJSON.testOccurrence, testsErrorJSON.testOccurrence, testsWarningJSON.testOccurrence, testsUnknownJSON.testOccurrence) 303 | 304 | // CHANGES (svn commits) 305 | let changesRequest = await fetch(`${teamcity_base_url}/app/rest/changes?locator=build:(id:${buildId})&${change_fields}`, { 306 | headers: { 307 | 'Accept': 'application/json', 308 | }, 309 | credentials: 'include', 310 | }) 311 | 312 | let changesJSON = await changesRequest.json() 313 | 314 | let changes = changesJSON.change 315 | 316 | renderBuildDetails(buildId, buildStepsDivId, messages, tests, changes) 317 | } 318 | 319 | // RECURSIVE BUILD MESSAGES 320 | async function get_more_messages(buildId,messageId) { 321 | 322 | let messagesRequest = await fetch(`${teamcity_base_url}/app/messages?buildId=${buildId}&filter=important&messageId=${messageId}&view=flowAware&_focus=${messageId}%23_state%3D0%2C${messageId}`, { 323 | headers: { 324 | 'Accept': 'application/json', 325 | }, 326 | credentials: 'include', 327 | }) 328 | 329 | let messagesJSON = await messagesRequest.json() 330 | 331 | let messages = messagesJSON.messages.filter((message) => {return message.parentId == messageId}) 332 | 333 | return messages 334 | 335 | } 336 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | /* Main 2 | / 3 | / run function to setup all the functions 4 | / create global variables for fields 5 | / setup the connections to fetch data by query and then send to interpret by data.js 6 | / load in configuration cookies and selections. 7 | */ 8 | 9 | const render = new HtmlRender() 10 | const query = new QueryHelper() 11 | const data = new ApiDataInterpreter() 12 | const user = new UserHandler() 13 | 14 | class Main { 15 | 16 | constructor() { 17 | 18 | this.end_time 19 | this.build_count 20 | this.build_cutoff_days 21 | 22 | this.queueCount = 0 23 | this.edit_selection = null 24 | this.api_selection = null 25 | this.selection 26 | this.named_selection = [] 27 | 28 | this.projectFields 29 | this.importantFields 30 | this.buildTypeFields 31 | this.messageFields 32 | this.changeFields 33 | this.testOccurrencesFields 34 | this.buildDetailsFields 35 | } 36 | 37 | debug(message, priority) { 38 | 39 | // Priority debugs are giving an html alert 40 | if (priority) { 41 | alert(message) 42 | console.error(message) 43 | } else { 44 | console.log(message) 45 | } 46 | } 47 | 48 | initialize() { 49 | 50 | // Initialize dropdown for named selection with named selections from cookie 51 | this.initNamedSelection() 52 | 53 | // Initialize fields to get from rest API 54 | this.getFields() 55 | 56 | // Set time element to now 57 | this.timeSelectReset(false) 58 | } 59 | 60 | initNamedSelection() { 61 | 62 | //Fill dropdown with named selections from Cookie 63 | if (this.named_selection && user.getCookie('tcNamedSelection')!= "") { 64 | this.named_selection = JSON.parse(user.getCookie('tcNamedSelection')) 65 | for (let name in this.named_selection) { 66 | render.addNameDropdown(name) 67 | } 68 | } 69 | } 70 | 71 | run() { 72 | 73 | //Login to the teamcity server 74 | user.getCurrentUser().then(userTmp => { 75 | 76 | //initialize the selection to grab from API by current, cookie or named selections 77 | this.initializeSelection().then(selection => { 78 | 79 | //Clean up elements to append data to. 80 | render.cleanDataWrapper(selection.important_buildtypes) 81 | 82 | //Reset activate class on filters, because new run won't filter directly 83 | render.resetFilterToggle() 84 | 85 | //Add the include projects wrapper elements 86 | render.addParentProjectElements(selection.include_projects) 87 | 88 | //Grab all projects by selection 89 | this.doProjects(selection) 90 | 91 | //Grab all Important BuildTypes 92 | this.doImportant(selection) 93 | 94 | }) 95 | 96 | }) 97 | 98 | } 99 | 100 | async doProjects(selection) { 101 | 102 | //Iterate over give projects from selection and handle them asynchronous 103 | for (let project of selection.include_projects) { 104 | let parentProjectData = [] 105 | this.projectHandler(project, null, parentProjectData) 106 | } 107 | } 108 | 109 | async doImportant(selection) { 110 | 111 | render.updateQueue(true, 1) 112 | 113 | // Create an important container in the project container style to append buildtypes to 114 | let projectData = {} 115 | projectData.important = true 116 | projectData.id = 'importantBuildTypes' 117 | projectData.name = 'Important' 118 | projectData.order = 0 119 | projectData.parentProjectId = 'important_wrapper' 120 | data.interpretProject(projectData) 121 | 122 | // Iterate over all specified buildtypes in the important selection with consistent order 123 | let order = 0 124 | for (let buildTypeId of selection.important_buildtypes) { 125 | order++ 126 | render.updateQueue(true, 1) 127 | 128 | // Query name data for containers from teamcity add some other specific data and send to handler. 129 | let importantBuildType = await query.getImportantBuildType(buildTypeId) 130 | importantBuildType.projectId = 'importantBuildTypes' 131 | importantBuildType.order = order 132 | importantBuildType.suffix = '_important' 133 | this.buildTypeHandler(importantBuildType) 134 | 135 | render.updateQueue(false, 1) 136 | } 137 | } 138 | 139 | async projectHandler(project, order, parentProjectData) { 140 | 141 | render.updateQueue(true, 1) 142 | 143 | //fetch data of the project with give Id 144 | let projectData = await query.getProject(project) 145 | 146 | projectData.order = order 147 | 148 | parentProjectData.push({'id': project, 'testSuccess': 0, 'testTotal': 0}) 149 | 150 | data.interpretProject(projectData) 151 | 152 | //If there are buildTypes send them to handler 153 | if (projectData.buildTypes.buildType) { 154 | Object.entries(projectData.buildTypes.buildType).forEach(([key, buildType]) =>{ 155 | buildType.order = key 156 | buildType.suffix = '' 157 | this.buildTypeHandler(buildType, parentProjectData) 158 | }) 159 | } 160 | 161 | //If project has subprojects go through all 162 | if (projectData.projects.project) { 163 | //Place projects under buildTypes 164 | let suborder = projectData.buildTypes?projectData.buildTypes.buildType.length:0 165 | 166 | Object.entries(projectData.projects.project).forEach(([key, subProject]) => { 167 | subProject.parentProjectData = [ ...parentProjectData ] 168 | this.projectHandler(subProject.id, suborder+key, subProject.parentProjectData) 169 | }) 170 | } 171 | 172 | } 173 | 174 | //Handle specified buildType 175 | async buildTypeHandler(buildType, parentProjectData) { 176 | 177 | render.updateQueue(true, 1) 178 | 179 | //get builds of buildType 180 | buildType.builds = await query.getBuilds(buildType.id) 181 | 182 | if (!buildType.builds.build[0]){ 183 | render.updateQueue(false, 1) 184 | return 185 | } 186 | 187 | //interpret and render details of the buildType 188 | data.interpretBuildType(buildType) 189 | 190 | let builds = buildType.builds.build 191 | 192 | //iterate over all the builds 193 | for (let i = 0; i < builds.length; i++){ 194 | 195 | render.updateQueue(true, 1) 196 | 197 | if (i+1 < builds.length && builds[i]?.testOccurrences?.passed != builds[i+1]?.testOccurrences?.passed) { 198 | builds[i].statusChanged = true 199 | } 200 | if (builds[i]['running-info']) { 201 | builds[i].runningInfo = builds[i]['running-info'] 202 | } 203 | 204 | // Get a build date to show in details 205 | builds[i] = data.getBuildDate(builds[i]) 206 | 207 | //get suffix to prevent duplicates for buildtypes in important selection and project selection 208 | builds[i].suffix = buildType.suffix 209 | 210 | //interpret and render build 211 | data.interpretBuild(builds[i]) 212 | } 213 | 214 | data.interpretMisc(builds[0]) 215 | 216 | this.doBuildStats(builds[0].id, buildType.id, buildType.suffix, parentProjectData) 217 | } 218 | 219 | //Add statistics of last builds to buildTypes and projects 220 | async doBuildStats(buildId, buildTypeId, suffix, parentProjectData) { 221 | 222 | render.updateQueue(true, 1) 223 | 224 | let testOccurrences = await query.getTestOccurrences(buildId) 225 | 226 | data.interpretBuildStats(buildTypeId, suffix, testOccurrences, parentProjectData) 227 | } 228 | 229 | async getBuildDetails(buildId, buildTypeId, suffix) { 230 | 231 | render.updateQueue(true, 5) 232 | 233 | //Create container for build details 234 | let container = render.setupBuildDetails(buildId, buildTypeId, suffix) 235 | 236 | //Get data for testOccurrences with more detail 237 | let testOccurrences = {} 238 | testOccurrences.failure = await query.getTestOccurrencesDetailed(buildId, "failure") 239 | testOccurrences.error = await query.getTestOccurrencesDetailed(buildId, "error") 240 | testOccurrences.warning = await query.getTestOccurrencesDetailed(buildId, "warning") 241 | testOccurrences.unknown = await query.getTestOccurrencesDetailed(buildId, "unknown") 242 | 243 | data.interpretTests(testOccurrences, buildTypeId, buildId, container.tests) 244 | 245 | //Get messages for buildId 246 | let mainMessages = await query.getMessages(buildId) 247 | Object.entries(mainMessages.messages).forEach(async ([key, mainMessage]) => { 248 | 249 | render.updateQueue(true, 1) 250 | 251 | let messageDiv = data.interpretMessage(mainMessage, container.message) 252 | 253 | //Iterate over all messages that are from buildId with a message parent 254 | this.messageRecursively(buildId, mainMessage, messageDiv) 255 | }) 256 | 257 | //Get changes for buildId 258 | let changes = await query.getChanges(buildId) 259 | data.interpretChanges(changes, container.changes) 260 | } 261 | 262 | //Get messages that are placed under other messages. 263 | async messageRecursively(buildId, message, messageDiv) { 264 | 265 | //Check if message has sub message 266 | if (message.containsMessages && message.id != 0) { 267 | 268 | //Get submessages 269 | let moreMessages = await query.getMoreMessages(buildId, message.id) 270 | 271 | //iterate over submessages render them and check if these also contain submessages 272 | Object.entries(moreMessages.messages).forEach(([key, subMessage]) => { 273 | 274 | //Filter on messages that are submessages of the parent 275 | if (subMessage.parentId == message.id) { 276 | 277 | render.updateQueue(true, 1) 278 | 279 | let subMessageDiv = data.interpretMessage(subMessage, messageDiv) 280 | 281 | //Iterate over all messages that are from buildId with this submessage as parent 282 | this.messageRecursively(buildId, subMessage, subMessageDiv) 283 | } 284 | }) 285 | } 286 | } 287 | 288 | async initializeSelection() { 289 | 290 | //Check teamcity if user has favorite projects 291 | await user.getFavoriteProjects().then((response_selection) => { 292 | this.api_selection = response_selection 293 | 294 | if (this.edit_selection) { 295 | this.selection = this.edit_selection 296 | } else if (user.getCookie('tcSelection')!= "") { 297 | this.edit_selection = this.selection = JSON.parse(user.getCookie('tcSelection')) 298 | } else if (api_selection.include_projects?.length == 0) { 299 | this.edit_selection = this.selection = this.default_selection 300 | } else { 301 | this.edit_selection = this.selection = this.api_selection 302 | } 303 | }) 304 | 305 | render.updateSelectionForm(this.selection, this.edit_selection) 306 | 307 | return this.selection 308 | } 309 | 310 | selectFavorite() { 311 | user.getFavoriteProjects() 312 | .then((favorite_projects) => { 313 | edit_selection = favorite_projects 314 | render.updateSelectionForm(null, edit_selection) 315 | }) 316 | } 317 | 318 | //Make a string of the fields that are placed in a json variable declared in fields.js 319 | getFields() { 320 | 321 | let Fields = JSON.parse(ApiTcFields.fields) 322 | 323 | this.projectFields = `fields=${this.getFieldsRecursively(Fields.project_fields)}` 324 | this.importantFields = `fields=${this.getFieldsRecursively(Fields.important_fields)}` 325 | this.buildTypeFields = `fields=${this.getFieldsRecursively(Fields.buildType_fields)}` 326 | this.messageFields = `fields=${this.getFieldsRecursively(Fields.message_fields)}` 327 | this.changeFields = `fields=${this.getFieldsRecursively(Fields.change_fields)}` 328 | this.testOccurrencesFields = `fields=${this.getFieldsRecursively(Fields.testOccurrences_fields)}` 329 | this.buildDetailsFields = `fields=${this.getFieldsRecursively(Fields.buildDetails_fields)}` 330 | 331 | } 332 | 333 | //add the fields key to string and check if it has an object as value rerun function if it does. 334 | getFieldsRecursively(objFields) { 335 | 336 | //Initialize some variables for iteration 337 | let fieldsString = '' 338 | let index = 0 339 | let fieldEntries = Object.entries(objFields) 340 | 341 | //Check if objFields is declared 342 | if (objFields){ 343 | 344 | //Iterate over the object. 345 | fieldEntries.forEach(([key, element]) => { 346 | index++ 347 | 348 | //Add key value of the object to string 349 | if (key) 350 | fieldsString += key 351 | 352 | //check if element is an object and if it is iterate over it 353 | if (typeof element == 'object') 354 | fieldsString += '('+this.getFieldsRecursively(element)+')' 355 | 356 | //Check if entry is last element if not add a ',' 357 | if (index < fieldEntries.length) 358 | fieldsString += ',' 359 | }) 360 | } 361 | else { 362 | this.debug('no fields sepicified in fields.js', true) 363 | } 364 | 365 | return fieldsString 366 | } 367 | 368 | // Select a file to save selection data to 369 | selectFile() { 370 | 371 | const link = document.createElement("a") 372 | link.download = "tcBuildViewer_selection.json" 373 | const blob = new Blob([JSON.stringify(selection, undefined, 2)], { type: "text/plain" }) 374 | link.href=window.URL.createObjectURL(blob) 375 | link.click() 376 | 377 | } 378 | 379 | // Select a file to load selection data from 380 | selectRunFile() { 381 | 382 | const selectionFile = document.createElement("input") 383 | selectionFile.type = "file" 384 | 385 | selectionFile.onchange = e => { 386 | const file = e.target.files[0] 387 | const reader = new FileReader() 388 | 389 | reader.readAsBinaryString(file) 390 | 391 | reader.onload = readerEvent => { 392 | edit_selection = JSON.parse(readerEvent.target.result) 393 | console.log(edit_selection) 394 | render.updateSelectionForm() 395 | } 396 | } 397 | 398 | selectionFile.click() 399 | 400 | } 401 | 402 | //Reset the time for the build selection to now 403 | timeSelectReset(doRun) { 404 | 405 | this.build_count = 14 406 | this.build_cutoff_days = 14 407 | this.end_time = new Date(new Date().toString().split('GMT')[0]+' UTC').toISOString().split('.')[0] 408 | 409 | render.timeElementSet({'build_count':this.build_count, 'build_cutoff_days':this.build_cutoff_days, 'end_time':this.end_time}) 410 | this.end_time = null 411 | 412 | if(doRun) { 413 | this.run() 414 | } 415 | } 416 | 417 | //Apply values set in time selection on the webpage to used variables 418 | applyTimeSelect() { 419 | 420 | this.build_count = document.getElementById('build_count').value 421 | this.build_cutoff_days = document.getElementById('build_cutoff_days').value 422 | this.end_time = document.getElementById('end_time').value 423 | 424 | this.run() 425 | } 426 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Deltares TeamCity Build Viewer 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |
Build result:
28 |
29 |
30 |
31 |
32 |
success
33 |
34 |
35 |
36 |
37 |
failed
38 |
39 |
40 |
41 |
42 |
running success
43 |
44 |
45 |
46 |
47 |
running failed
48 |
49 |
50 |
51 |
52 |
queued
53 |
54 |
55 |
56 |
57 |
recovered
58 |
59 |
60 |
61 |
62 |
changed
63 |
64 |
65 |
66 |
67 |
unknown
68 |
Tests failures:
69 |
🚩
new failed
70 |
🕵
investigating
71 |
🙈
uninvestigated
72 |
🙉
ignored
73 |
🙊
muted
74 |
Other:
75 |
Est. finish time
76 |
📌
Tags
77 |
78 | 79 | 80 |
81 | 82 |
83 |
 user:
unknown
84 |
queue:
0
85 |
86 | 87 | 94 | 95 |

TeamCity Build Viewer

96 | 97 |
98 | 99 | 101 | 102 | 104 | 105 | 107 | 108 | 110 | 111 | 113 | 114 | 120 | 121 |
122 | 123 | 124 | 128 | 129 | 130 | 218 | 219 | 220 | 244 | 245 | 246 | 259 | 260 |
261 | 262 | 263 |
264 |
265 |
266 |
267 | 268 |
269 |
270 |
271 | 272 | 273 | -------------------------------------------------------------------------------- /src/data.js: -------------------------------------------------------------------------------- 1 | /* Data 2 | / 3 | / Interpret API data fetched by query.js 4 | / 5 | / Direct link to render() functions 6 | */ 7 | 8 | // Interprete and render project using parentProjectId, id and name 9 | class ApiDataInterpreter { 10 | 11 | async interpretProject(project) { 12 | 13 | // Create main element 14 | let elementClass = ['project', project.parentProjectId] 15 | let attributes = {'id':`${project.id}`, 'title':`Project ID: ${project.id}`} 16 | let element = document.getElementById(project.parentProjectId) 17 | let order = null 18 | 19 | // Check if parent project exists and add order or create a parent project under the main element. 20 | if (element) { 21 | order = project.order+2 22 | } else { 23 | element = document.getElementById(`${project.id}_wrapper`) 24 | } 25 | let parentElement = render.createElement('div', elementClass, attributes, null, element, order) 26 | 27 | // Create BuildTypes container 28 | render.createElement('div',['buildTypesContainer'], null, null, parentElement, 2) 29 | 30 | // Create title Wrapper 31 | let titleWrapper = render.createElement('div', ['projectTitleWrapper'], null, null, parentElement, 1) 32 | 33 | // Create collapse option for project 34 | attributes = {'title':'collapse', 35 | 'onclick':`this.parentElement.parentElement.classList.toggle('collapsed');this.classList.toggle('collapsed')`} 36 | render.createElement('div', ['collapseButton'], attributes, '▼', titleWrapper, null) 37 | 38 | // Add the title and create a link except for important container don't add a link 39 | if (project.important) { 40 | render.createElement('p', ['projectTitle'], null, project.name, titleWrapper, null) 41 | } else { 42 | attributes = {'href':`${teamcity_base_url}/project.html?projectId=${project.id}`,'target':'_blank'} 43 | let projectLink = render.createElement('a', ['projectTitle'], attributes, project.name, titleWrapper, null) 44 | render.createElement('div', ['linkIcon'], null, '⧉', projectLink, null) 45 | } 46 | 47 | render.createElement('div', ['projectStats'], {'id':`${project.id}_stats`}, null, titleWrapper, null) 48 | 49 | // Remove finished project interpretation from queue 50 | render.updateQueue(false, 1) 51 | } 52 | 53 | async interpretBuildType(buildType) { 54 | 55 | // Prepare some variables to show changes in the buildType div for quick overview 56 | buildType = this.prepBuildType(buildType) 57 | 58 | // Start Render calls 59 | let parentElement = document.getElementById(buildType.projectId).getElementsByClassName('buildTypesContainer')[0] 60 | 61 | // Create buildType title with link 62 | let elementClass = ['buildTypeTitle','buildTypePart', buildType.projectId, buildType.status] 63 | if (buildType.statusChanged) 64 | elementClass.push('statusChanged') 65 | let attributes = { 66 | 'id':`buildTypeLink_${buildType.id}${buildType.suffix}`, 67 | 'title':`BuildType ID: ${buildType.id}`, 68 | 'href':`${teamcity_base_url}/viewType.html?buildTypeId=${buildType.id}`, 69 | 'target':'_blank' 70 | } 71 | let buildTypeLink = render.createElement('a', elementClass, attributes, buildType.name, parentElement, (buildType.order * 2 + 1)) 72 | render.createElement('div', ['linkIcon'], null, '⧉', buildTypeLink, null) 73 | 74 | // Create test statistics box for buildType 75 | attributes = {'id': `${buildType.id}_test_statistics${buildType.suffix}`} 76 | elementClass = ['testStatisticsText', 'buildTypePart', buildType.status] 77 | if (buildType.statusChanged) 78 | elementClass.push('statusChanged') 79 | render.createElement('div', elementClass, attributes, null, parentElement, (buildType.order * 2 + 1)) 80 | 81 | // Create Miscellaneous box for tags, time for the buildType 82 | attributes = {'id': `${buildType.id}_misc${buildType.suffix}`} 83 | elementClass[0] = 'miscellaneous' 84 | render.createElement('div', elementClass, attributes, null, parentElement, (buildType.order * 2 + 1)) 85 | 86 | // Create builds container for buildType 87 | attributes = {'id': `${buildType.id}_buildList${buildType.suffix}`} 88 | elementClass[0] = 'buildList' 89 | render.createElement('div', elementClass, attributes, null, parentElement, (buildType.order * 2 + 1)) 90 | 91 | // Create buildSteps container for buildType 92 | attributes = {'id': `${buildType.id}_buildSteps${buildType.suffix}`} 93 | elementClass = ['buildSteps','hidden'] 94 | let text = '🚧 Will display build details like important logs, blame and test!' 95 | render.createElement('div', elementClass, attributes, text, parentElement, (buildType.order * 2 + 2)) 96 | 97 | // Remove finished buildType interpretation from queue 98 | render.updateQueue(false, 1) 99 | } 100 | 101 | prepBuildType(buildType) { 102 | 103 | let build = buildType.builds.build[0] 104 | let buildTwo = buildType.builds.build?.[1] 105 | 106 | // Check if buildType should be highlighted as changed 107 | if (build?.problemOccurrences?.newfailed > 0) { 108 | buildType.statusChanged = true 109 | } else if (build?.status != buildTwo?.status) { 110 | buildType.statusChanged = true 111 | } else if (build?.testOccurrences?.passed != buildTwo?.testOccurrences?.passed) { 112 | buildType.statusChanged = true 113 | } else { 114 | buildType.statusChanged = false 115 | } 116 | 117 | // Set status of buildType if it is finished or has failed for latest build 118 | if (build?.status && (build?.state == 'finished' || build?.status=='FAILURE')) { 119 | buildType.status = build.status 120 | } 121 | 122 | return buildType 123 | } 124 | 125 | async interpretBuild(build) { 126 | 127 | let parentElement = document.getElementById(`${build.buildTypeId}_buildList${build.suffix}`) 128 | 129 | // Add build container with link and title elements. 130 | let elementClass = ['build', build.buildTypeId, build.state, build.status] 131 | let buildFinishTime = (build.state=='finished' ? 'Finished ' : 'Estimated finish: ') + new Date(build.unixTime).toLocaleString() 132 | let branch = build.branchName ? build.branchName : 'unknown' 133 | let tags = '' 134 | if (build.tags.tag.length > 0) { 135 | tags = 'Tags: ' 136 | for (let element of build.tags.tag) { 137 | tags += element.name + ' | ' 138 | } 139 | tags = tags.substring(0, tags.length - 3) 140 | } 141 | let agent = build.agent ? build.agent?.name : `${build.plannedAgent?.name} (planned)`; 142 | let buildTitle = `${tags}\nBranch: ${branch}\nID: ${build.id}\nBuild Number: ${build.number}\nState: ${build.state}\nStatus: ${build.status}\nAgent: ${agent}\n${buildFinishTime}\nStatus Message: ${build.statusText}` 143 | let attributes = { 144 | 'id': build.id, 145 | 'onclick': `main.getBuildDetails('${build.id}','${build.buildTypeId}','${build.suffix}')`, 146 | 'target': '_blank', 147 | 'title': `${buildTitle}` 148 | } 149 | let buildDiv = render.createElement('div', elementClass, attributes, null, parentElement, null) 150 | 151 | // add icon border 152 | let buildClass = [`testBorder${build.state!='queued' ? build.status : build.state}`] 153 | render.createElement('div', buildClass, null, null, buildDiv, null) 154 | 155 | // add test icon 156 | buildClass = [`testIcon${build.state=='finished' ? build.status : build.state}`] 157 | if (build.state=='finished' && (build.statusChanged || (build.problemOccurrences && build.problemOccurrences.newFailed > 0))) { 158 | buildClass = ['testIconchanged'] 159 | } 160 | render.createElement('div', buildClass, null, null, buildDiv, null) 161 | 162 | render.addClearElement(buildDiv) 163 | 164 | // Remove finished build interpretation from queue 165 | render.updateQueue(false, 1) 166 | } 167 | 168 | getBuildDate(build){ 169 | 170 | // Get time to display for build and add it to the object. 171 | if (build.finishOnAgentDate) { 172 | build.unixTime = TimeUtilities.tcTimeToUnix(build.finishOnAgentDate) 173 | } else if (build.finishEstimate) { 174 | build.unixTime = TimeUtilities.tcTimeToUnix(build.finishEstimate) 175 | } else if (build.runningInfo) { 176 | build.unixTime = (Date.now() + build.runningInfo.leftSeconds * 1000) 177 | } 178 | 179 | return build 180 | } 181 | 182 | async interpretMisc(build) { 183 | 184 | // select miscellaneous element to add elements to with data for latest build 185 | let parentElement = document.getElementById(`${build.buildTypeId}_misc${build.suffix}`) 186 | 187 | // If latest build is state running then add the finish time in miscellaneous container 188 | if (build.state != 'finished') { 189 | 190 | let buildDate = build.unixTime ? new Date(build.unixTime).toLocaleString() : 'calculating' 191 | let finishOnText = `⏰ ${buildDate}` 192 | render.createElement('div', null, null, finishOnText, parentElement, null) 193 | 194 | } 195 | 196 | // If build has tags iterate over them and place them in an element 197 | if (build.tags.tag.length > 0) { 198 | 199 | let tagsTitle = '' 200 | for (let tag of build.tags.tag) { 201 | tagsTitle += tag.name + '\n' 202 | } 203 | render.createElement('div', null, {'title': tagsTitle}, '📌', parentElement, null) 204 | } 205 | } 206 | 207 | async interpretBuildStats(buildTypeId, suffix, testOccurrences, parentProjectData) { 208 | 209 | let parentElement = document.getElementById(`${buildTypeId}_test_statistics${suffix}`) 210 | 211 | // Loop through testcases to find investigated failed tests 212 | let investigation = 0 213 | for (let i = 0; i < testOccurrences.testOccurrence.length; i++) { 214 | if (testOccurrences.testOccurrence[i].currentlyInvestigated){ 215 | investigation++ 216 | } 217 | } 218 | 219 | // Create text for testdata: investigated, newfailed, ignored, muted and calculate percentages 220 | let currentUninvestigated = testOccurrences.count - testOccurrences.passed - investigation 221 | let newFailed = testOccurrences.newFailed > 0 ? `(${testOccurrences.newFailed}x🚩)` : '' 222 | let investigated = investigation > 0 ? `(${investigation}x🕵)` : '' 223 | let unInvestigated = currentUninvestigated > 0 ? `(${currentUninvestigated}x🙈)` : '' 224 | let ignored = testOccurrences.ignored > 0 ? `(${testOccurrences.ignored}x🙉)` : '' 225 | let muted = testOccurrences.muted > 0 ? `(${testOccurrences.muted}x🙊)` : '' 226 | let percentage = Number((testOccurrences.passed/testOccurrences.count)*100).toFixed(2) 227 | let percentData = testOccurrences.count > 0 ? `[${testOccurrences.passed}/${testOccurrences.count}] = ${percentage}%` : '' 228 | let text = `${newFailed} ${investigated} ${unInvestigated} ${ignored} ${muted} ${percentData}` 229 | 230 | // Add test text to element 231 | render.createElement('div', null, null, text, parentElement, null) 232 | 233 | // If testOccurrences changed update all parentProjects with the new statistics totals 234 | if (testOccurrences.count > 0 ) { 235 | for (let i = 0; i < parentProjectData.length; i++) { 236 | 237 | parentProjectData[i].testSuccess += testOccurrences.passed 238 | parentProjectData[i].testTotal += testOccurrences.count 239 | let percentage = Number((parentProjectData[i].testSuccess/parentProjectData[i].testTotal)*100).toFixed(2) 240 | let text = `[${parentProjectData[i].testSuccess}/${parentProjectData[i].testTotal}] = ${percentage}%` 241 | 242 | render.updateProjectStats(parentProjectData[i].id, suffix, text) 243 | } 244 | } 245 | 246 | // Remove finished build statistics interpretation from queue 247 | render.updateQueue(false, 1) 248 | } 249 | 250 | async interpretChanges(changes, changesDiv) { 251 | 252 | // Iterate over all changes and add data to the element from changes: Version, Link, User, Time 253 | Object.entries(changes.change).forEach(([key, change]) => { 254 | 255 | render.createElement('div', ['changeVersion'], null, `#${change.version}`, changesDiv, null) 256 | 257 | // Create a link to view the change made and build the url for it 258 | let fileList = change.files.file.map(file => file['relative-file']).join('\n') 259 | let linkText = `#${change.comment}` 260 | let attributes = {'href': `'${teamcity_base_url}/viewModification.html?modId=${change.id}&personal=false' title='${fileList}'`, 261 | 'target': '_blank'} 262 | render.createElement('div', ['changeLink'], attributes, linkText, changesDiv, null) 263 | 264 | // Add a username to the change use the email if this is a svn repo 265 | let userText = `${change.user?change.user.name:change.username}` 266 | render.createElement('div', ['changeUser'], null, userText, changesDiv, null) 267 | 268 | // Get the date this change was made and add it to the fields with a locale time 269 | let timeText = `${new Date(TimeUtilities.tcTimeToUnix(change.date)).toLocaleString()}` 270 | render.createElement('div', ['changeTime'], null, timeText, changesDiv, null) 271 | }) 272 | 273 | if (!changes.change || changes.change.length == 0) { 274 | render.createElement('p', ['emptyChanges'], null, 'Nobody to blame... 😭', changesDiv, null) 275 | } 276 | 277 | // Remove finished changes interpretation from queue 278 | render.updateQueue(false, 1) 279 | } 280 | 281 | async interpretTests(testOccurrences, buildTypeId, buildId, testsDiv) { 282 | 283 | // Make an array to iterate over only Occurrence 284 | let testsData = [] 285 | testsData = testsData.concat(testOccurrences.failure.testOccurrence, testOccurrences.error.testOccurrence, 286 | testOccurrences.warning.testOccurrence, testOccurrences.unknown.testOccurrence) 287 | 288 | // Iterate over tests 289 | Object.entries(testsData).forEach(([key, test]) => { 290 | 291 | // Setup element variables 292 | let elementClass = ['message'] 293 | let tags = '' 294 | let investigationNames = '' 295 | let attributes = {'target': '_blank', 296 | 'href': `${teamcity_base_url}/buildConfiguration/${buildTypeId}/${buildId}?showLog=${buildId}_${test.logAnchor}`} 297 | 298 | // Check if investigation is active on failed test 299 | if (test.test?.investigations?.investigation?.length == 0) { 300 | tags += '🙈' 301 | } else { 302 | investigationNames = test.test.investigations.investigation.map((investigation) => {return investigation.assignee.name}) 303 | elementClass.push('testInvestigated') 304 | tags += '🕵' 305 | } 306 | 307 | if (test.ignored) { 308 | tags += '🙉' 309 | } 310 | if (test.muted) { 311 | tags += '🙊' 312 | } 313 | 314 | // Add element class for make up of test statusses. 315 | if (test.status == 'WARNING'){ 316 | elementClass.push('warning') 317 | } 318 | if (test.status == 'FAILURE'){ 319 | elementClass.push('error') 320 | } 321 | if (test.status == 'UNKNOWN'){ 322 | elementClass.push('unknown') 323 | } 324 | 325 | // Create text for tests to display and add a hyperlink to the teamcity tests page 326 | let text = `${tags} ${investigationNames?'('+investigationNames+')': ''} ${test.test.parsedTestName.testShortName}\n → ${test.details}` 327 | let testsLink = render.createElement('a', elementClass, attributes, null, testsDiv, null) 328 | render.createElement('p', ['testsText'], null, text, testsLink, null) 329 | 330 | // If a failed test is investigated move it to the top of the container 331 | if (investigationNames) { 332 | render.moveElementToTop(testsLink, testsDiv) 333 | } 334 | }) 335 | 336 | if (testsData.length == 0) ( 337 | render.createElement('p', ['testsText'], null, 'No failed tests!', testsDiv, null) 338 | ) 339 | 340 | // Remove finished tests interpretation from queue 341 | render.updateQueue(false, 4) 342 | } 343 | 344 | interpretMessage(message, messageDiv) { 345 | 346 | // Add status of messages as class to element 347 | let elementClasses = ['message'] 348 | if (message.status == 2) { 349 | elementClasses.push('warning') 350 | } else if (message.status == 4) { 351 | elementClasses.push('error') 352 | } else { 353 | elementClasses.push('normal') 354 | } 355 | 356 | // Create a container for the message 357 | let messageParent = render.createElement('div', elementClasses, null, null, messageDiv, null) 358 | let subMessageDiv 359 | 360 | if (message.containsMessages && message.id != 0) { 361 | 362 | // Create a collapse button for submessages of message. 363 | let attributes = {'onclick':`this.parentElement.getElementsByTagName('div')[0].classList.toggle('hidden'); 364 | this.parentElement.getElementsByTagName('span')[0].classList.toggle('collapsed')`} 365 | render.createElement('span', ['collapseButton', 'collapsed'], attributes, '▼', messageParent, null) 366 | 367 | // Create a span to show the text of the message 368 | render.createElement('span', ['messageText'], attributes, message.text, messageParent, null) 369 | 370 | // Create a container for submessages to be placed under 371 | subMessageDiv = render.createElement('div', ['messageSub', 'hidden'], null, null, messageParent, null) 372 | } else { 373 | 374 | // Create a span to show the text of the message if it doesn't have submessages. 375 | render.createElement('span', ['messageText'], null, message.text, messageParent, null) 376 | } 377 | 378 | // Remove finished message interpretation from queue 379 | render.updateQueue(false, 1) 380 | 381 | return subMessageDiv 382 | } 383 | } -------------------------------------------------------------------------------- /old/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Deltares TeamCity Build Viewer 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
Build result:
21 |
successful
22 |
failed
23 |
running success
24 |
running failed
25 |
queued
26 |
recovered
27 |
changed
28 |
unknown
29 |
Tests failures:
30 |
🚩
new failed
31 |
🕵
investigating
32 |
🙈
uninvestigated
33 |
🙉
ignored
34 |
🙊
muted
35 |
Other:
36 |
Est. finish time
37 |
📌
Tags
38 |
39 |
40 | 41 |
42 |
 user:
unknown
43 |
queue:
0
44 |
45 | 46 | 53 | 54 |

TeamCity Build Viewer

55 | 56 |
57 | 58 | 60 | 61 | 63 | 64 | 66 | 67 | 69 | 70 | 76 | 77 |
78 | 79 | 83 | 84 | 88 | 89 | 102 | 103 | 140 | 141 | 142 | 251 |
252 | 253 |
254 | 255 | 349 |
350 | 351 | 352 | 353 | -------------------------------------------------------------------------------- /old/js/render.js: -------------------------------------------------------------------------------- 1 | // Will enable/disable buttons when there are downloads in progress. 2 | async function checkFilterButtons(downloadQueueLength) { 3 | 4 | document.getElementById('queue_number').innerHTML = downloadQueueLength 5 | 6 | if (downloadQueueLength > 1) { 7 | return // Action is only necessary when the queue is 0 or 1. 8 | } 9 | else if (downloadQueueLength == 1) { 10 | document.querySelectorAll('.filter_button').forEach(button => {button.disabled = true; button.classList.remove('active')}) 11 | } 12 | else { 13 | document.querySelectorAll('.filter_button').forEach(button => {button.disabled = false}) 14 | } 15 | 16 | } 17 | 18 | /* Input: array of project IDs to render. */ 19 | function initiateProjectElements(include_projects) { 20 | 21 | document.getElementById('_projects').innerHTML = '' // Clean slate. 22 | 23 | // Prepare wrapper elements for your included projects. 24 | // This is necessary for consistent ordering. 25 | for (project of include_projects) { 26 | let projectWrapper = document.createElement("div") 27 | projectWrapper.setAttribute('id', `${project}_wrapper`) 28 | projectWrapper.classList.add('project_wrapper') 29 | document.getElementById('_projects').appendChild(projectWrapper) 30 | }; 31 | } 32 | 33 | /* Input: array of Important buildType IDs to render. */ 34 | function initiateImportantElements(include_important_buildtypes) { 35 | 36 | document.getElementById('_important').innerHTML = '' // Clean slate. 37 | 38 | document.getElementById('_important').hidden = !include_important_buildtypes 39 | 40 | let importantWrapper = document.createElement("div") 41 | importantWrapper.setAttribute('id', `important_buildtypes_wrapper`) 42 | importantWrapper.classList.add('project_wrapper') 43 | document.getElementById('_important').appendChild(importantWrapper) 44 | 45 | // Prepare wrapper elements for your included important buildtypes. 46 | // This is necessary for consistent ordering. 47 | /*for (buildType of include_important_buildtypes) { 48 | let importantWrapper = document.createElement("div") 49 | importantWrapper.setAttribute('id', `${project}_wrapper`) 50 | importantWrapper.classList.add('project_wrapper') 51 | document.getElementById('_important').appendChild(importantWrapper) 52 | };*/ 53 | } 54 | 55 | async function renderProject(project) { 56 | 57 | // Add project to parent project. 58 | let projectDiv = document.createElement("div") 59 | let parentElement = document.getElementById(project.parentProjectId) 60 | if (parentElement) { 61 | projectDiv.style.order = project.order+2 62 | parentElement.appendChild(projectDiv) 63 | } else { 64 | document.getElementById(`${project.id}_wrapper`).appendChild(projectDiv) 65 | } 66 | 67 | let projectBuildTypesDiv = document.createElement("div") 68 | projectBuildTypesDiv.classList.add('projectBuildTypesDiv') 69 | projectBuildTypesDiv.style.order = '1'; 70 | projectDiv.appendChild(projectBuildTypesDiv) 71 | 72 | // Create projectDiv. 73 | projectDiv.setAttribute('id', project.id) 74 | projectDiv.classList.add('project') 75 | projectDiv.classList.add(project.parentProjectId) 76 | projectDiv.setAttribute('title', `Project ID: ${project.id}`) 77 | 78 | // Wrapper for project collapse button and title. 79 | let projectHeaderWrapperDiv = document.createElement("div") 80 | projectHeaderWrapperDiv.classList.add('project_header_wrapper') 81 | projectHeaderWrapperDiv.style.order = '0'; 82 | projectDiv.appendChild(projectHeaderWrapperDiv) 83 | 84 | // Collapse button. 85 | let collapseDiv = document.createElement("div") 86 | collapseDiv.classList.add('collapse_button') 87 | collapseDiv.setAttribute('title','collapse') 88 | collapseDiv.setAttribute('onclick', `this.parentElement.parentElement.classList.toggle('collapsed');this.innerHTML=this.innerHTML=='▼'?'▶':'▼'`) 89 | projectHeaderWrapperDiv.appendChild(collapseDiv) 90 | 91 | // Collapse button text. 92 | let collapseDivText = document.createTextNode('▼') 93 | collapseDiv.appendChild(collapseDivText) 94 | 95 | // The 'important buildtype' uses the same code as projects, except for this: 96 | if (project.important_buildtype) { 97 | 98 | let projectTitle = document.createElement("p") 99 | projectTitle.classList.add('project_title') 100 | projectHeaderWrapperDiv.appendChild(projectTitle) 101 | 102 | let projectText = document.createTextNode(`${project.name}`) 103 | projectTitle.appendChild(projectText) 104 | 105 | } else { 106 | 107 | // Link to TeamCity project page. 108 | let projectLink = document.createElement("a") 109 | projectLink.classList.add('project_title') 110 | projectLink.setAttribute('href', `${teamcity_base_url}/project.html?projectId=${project.id}`) 111 | projectLink.setAttribute('target', '_blank') 112 | projectHeaderWrapperDiv.appendChild(projectLink) 113 | 114 | // Text for TeamCity project link. 115 | let projectText = document.createTextNode(`${project.name}`) 116 | projectLink.appendChild(projectText) 117 | 118 | // Icon ⧉ for the TeamCity project link. 119 | let projectLinkIconText = document.createTextNode('⧉') 120 | let projectLinkIcon = document.createElement("div") 121 | projectLinkIcon.appendChild(projectLinkIconText) 122 | projectLinkIcon.classList.add('linkIcon') 123 | projectLink.appendChild(projectLinkIcon) 124 | } 125 | 126 | let projectStats = document.createElement("div") 127 | projectStats.setAttribute('id',`${project.id}_stats`) 128 | projectStats.classList.add('projectStats') 129 | projectHeaderWrapperDiv.appendChild(projectStats) 130 | 131 | return projectDiv 132 | 133 | } 134 | /* 135 | function renderProjectTestStatistics(project) { 136 | if(project.testCount) { 137 | project.testPercentage = Number((project.testPassed/project.testCount)*100).toFixed(2) 138 | let testStatisticsSumText = document.createTextNode(`${project.testNewFailed?'('+project.testNewFailed+' new failed) ':''}${project.failedNotInvestigated?'['+project.failedNotInvestigated+'×🙈] ':''}${project.testIgnored?'['+project.testIgnored+'×🙉] ':''}${project.testMuted?'['+project.testMuted+'×🙊] ':''}[${project.testPassed?project.testPassed:0}/${project.testCount}] = ${project.testPercentage}%`) 139 | let testStatisticsSumDiv = document.createElement('div') 140 | testStatisticsSumDiv.style.textAlign = 'right' 141 | testStatisticsSumDiv.style.display = 'inline-block' 142 | testStatisticsSumDiv.appendChild(testStatisticsSumText) 143 | project.div.getElementsByClassName('project_title')[0].after(testStatisticsSumDiv) 144 | } 145 | } 146 | */ 147 | // Add buildType to project. 148 | async function renderBuildType(buildType) { 149 | 150 | // Skip build types with no builds. 151 | if (!buildType.builds.build[0]) 152 | return 153 | 154 | // Add buildType to project. 155 | //let buildTypeDiv = document.createElement("div") 156 | let parentElement = document.getElementById(buildType.projectId).getElementsByClassName('projectBuildTypesDiv')[0] 157 | //buildTypeDiv.style.order = buildType.order 158 | //parentElement.appendChild(buildTypeDiv) 159 | 160 | let buildTypeLink = document.createElement("a") 161 | 162 | // Create buildTextDiv. 163 | buildTypeLink.setAttribute('id', buildType.id + buildType.locationSuffix?buildType.locationSuffix:'') 164 | buildTypeLink.setAttribute('title',`BuildType ID: ${buildType.id}`) 165 | buildTypeLink.classList.add('buildType') 166 | buildTypeLink.classList.add('buildTypePart') 167 | buildTypeLink.classList.add(buildType.projectId) 168 | buildTypeLink.style.gridRow = buildType.order*2+1 169 | buildTypeLink.style.gridColumn = 1 170 | parentElement.appendChild(buildTypeLink) 171 | // Add status of last build as class. 172 | buildTypeLink.classList.add(buildType.status) 173 | 174 | // Link to TeamCity build type page. 175 | buildTypeLink.setAttribute('href', `${teamcity_base_url}/viewType.html?buildTypeId=${buildType.id}`) 176 | buildTypeLink.classList.add('buildTypeLink'); 177 | buildTypeLink.setAttribute('id', `buildTypeLink_${buildType.id}${buildType.locationSuffix?buildType.locationSuffix:''}`) 178 | buildTypeLink.setAttribute('target', '_blank') 179 | 180 | // Text for the buildType. 181 | let buildTypeText = document.createTextNode(buildType.name) 182 | buildTypeLink.appendChild(buildTypeText) 183 | 184 | // Icon ⧉ for the TeamCity build type link. 185 | let buildTypeLinkIconText = document.createTextNode('⧉') 186 | let buildTypeLinkIcon = document.createElement("div") 187 | buildTypeLinkIcon.appendChild(buildTypeLinkIconText) 188 | buildTypeLinkIcon.classList.add('linkIcon') 189 | buildTypeLink.appendChild(buildTypeLinkIcon) 190 | 191 | let testStatisticsDiv = document.createElement('div') 192 | testStatisticsDiv.classList.add('test_statistics_text') 193 | testStatisticsDiv.setAttribute('id', `${buildType.id}_test_statistics${buildType.locationSuffix?buildType.locationSuffix:''}`) 194 | testStatisticsDiv.classList.add('buildTypePart') 195 | testStatisticsDiv.style.gridRow = buildType.order*2+1 196 | testStatisticsDiv.style.gridColumn = 2 197 | parentElement.appendChild(testStatisticsDiv) 198 | 199 | let finishTimeDiv = document.createElement('div') 200 | finishTimeDiv.setAttribute('id', `${buildType.id}_finish${buildType.locationSuffix?buildType.locationSuffix:''}`) 201 | finishTimeDiv.classList.add('finish_time_text') 202 | finishTimeDiv.classList.add('buildTypePart') 203 | finishTimeDiv.style.gridRow = buildType.order*2+1 204 | finishTimeDiv.style.gridColumn = 3 205 | parentElement.appendChild(finishTimeDiv) 206 | 207 | // Element to hold the list of builds. 208 | let buildListDiv = document.createElement("div") 209 | buildListDiv.setAttribute('id', `${buildType.id}_buildList${buildType.locationSuffix?buildType.locationSuffix:''}`) 210 | buildListDiv.classList.add('buildList') 211 | buildListDiv.classList.add('buildTypePart') 212 | buildListDiv.style.gridRow = buildType.order*2+1 213 | buildListDiv.style.gridColumn = 4 214 | parentElement.appendChild(buildListDiv) 215 | 216 | let buildStepsText = document.createTextNode('🚧 Will fetch and display the (status of) individual build steps.') 217 | let buildSteps = document.createElement("div") 218 | buildSteps.setAttribute('id', `${buildType.id}_buildsteps${buildType.locationSuffix?buildType.locationSuffix:''}`) 219 | buildSteps.appendChild(buildStepsText) 220 | buildSteps.classList.add('buildSteps') 221 | buildSteps.classList.add('hidden') 222 | buildSteps.style.gridRow = buildType.order*2+2 223 | parentElement.appendChild(buildSteps) 224 | 225 | // Add statusChanged when the last build status is different. 226 | if (buildType.statusChanged) { 227 | buildTypeLink.classList.add('statusChanged') 228 | testStatisticsDiv.classList.add('statusChanged') 229 | buildListDiv.classList.add('statusChanged') 230 | finishTimeDiv.classList.add('statusChanged') 231 | } 232 | 233 | if (buildType.status) { 234 | buildTypeLink.classList.add(buildType.status) 235 | testStatisticsDiv.classList.add(buildType.status) 236 | buildListDiv.classList.add(buildType.status) 237 | finishTimeDiv.classList.add(buildType.status) 238 | } 239 | 240 | } 241 | 242 | // Add build to buildList. 243 | async function renderBuild(build) { 244 | // Add build to buildList. 245 | let buildDiv = document.createElement("div") 246 | let parentElement = document.getElementById(`${build.buildTypeId}_buildList${build.locationSuffix?build.locationSuffix:''}`) 247 | parentElement.prepend(buildDiv) 248 | 249 | // Create buildDiv. 250 | buildDiv.setAttribute('id', build.id) 251 | buildDiv.classList.add('build') 252 | buildDiv.classList.add(build.buildTypeId) 253 | buildDiv.classList.add(build.state) 254 | if (build.status){ 255 | buildDiv.classList.add(build.status) 256 | } 257 | else{ 258 | buildDiv.classList.add('NORESULT') 259 | } 260 | if (build.statusChanged || (build.problemOccurrences && build.problemOccurrences.newFailed > 0)) { 261 | buildDiv.classList.add('newFailed') 262 | } 263 | 264 | // Link to TeamCity build page. 265 | let buildLink = document.createElement("a") 266 | buildLink.setAttribute('onclick', `get_build_details(${build.id},${build.locationSuffix?'"'+build.buildTypeId+'_buildsteps'+build.locationSuffix+'"':'"'+build.buildTypeId+'_buildsteps'+'"'})`) 267 | buildLink.setAttribute('target', '_blank') 268 | 269 | let tags = '' 270 | if(build.tags.tag.length > 0){ 271 | tags = 'Tags: ' 272 | for (let element of build.tags.tag) { 273 | tags+=(element.name+' | ') 274 | } 275 | tags = tags.substring(0, tags.length - 3); 276 | } 277 | 278 | let buildDate = new Date(build.unixTime).toLocaleString() 279 | let buildFinishTime = (build.state=='finished' ? 'Finished: ' : 'Estimated finish: ') + buildDate=='Invalid Date'?'calculating':buildDate 280 | buildLink.setAttribute('title', `${tags}\nBranch: ${build.branchName?build.branchName:'unknown'}\nState: ${build.state}\nStatus: ${build.status}\nID: ${build.id}\nBuild Number: # ${build.number}\n${buildFinishTime}\nAgent: ${build.agent ? build.agent.name : 'pending'}\nStatus message: ${build.statusText}`) 281 | 282 | buildDiv.appendChild(buildLink) 283 | 284 | // Text for TeamCity build link. 285 | let buildText = document.createTextNode('⬤') 286 | buildLink.appendChild(buildText) 287 | 288 | } 289 | 290 | async function renderBuildTypeStats(buildStats, locationSuffix, parentProjectStats, parentProjectIds) { 291 | 292 | let newFailed = buildStats.testOccurrences.newFailed?buildStats.testOccurrences.newFailed:0 293 | let failedInvestigated = buildStats.testOccurrences.testOccurrence.filter((testOccurrence) => {return testOccurrence.status!='SUCCESS' && testOccurrence.currentlyInvestigated}).length 294 | let failedNotInvestigated = buildStats.testOccurrences.testOccurrence.filter((testOccurrence) => {return testOccurrence.status!='SUCCESS' && !testOccurrence.currentlyInvestigated}).length 295 | let ignored = buildStats.testOccurrences.ignored?buildStats.testOccurrences.ignored:0 296 | let muted = buildStats.testOccurrences.muted?buildStats.testOccurrences.muted:0 297 | let passed = buildStats.testOccurrences.passed?buildStats.testOccurrences.passed:0 298 | let count = buildStats.testOccurrences.count?buildStats.testOccurrences.count:0 299 | let percentage = Number((passed/count)*100).toFixed(2) 300 | 301 | 302 | Object.entries(parentProjectIds).forEach(([key,projectId]) => { 303 | parentProjectStats[projectId].newFailed += newFailed 304 | parentProjectStats[projectId].failedInvestigated += failedInvestigated 305 | parentProjectStats[projectId].failedNotInvestigated += failedNotInvestigated 306 | parentProjectStats[projectId].ignored += ignored 307 | parentProjectStats[projectId].muted += muted 308 | parentProjectStats[projectId].passed += passed 309 | parentProjectStats[projectId].count += count 310 | parentProjectStats[projectId].percentage = Number((parentProjectStats[projectId].passed/parentProjectStats[projectId].count)*100).toFixed(2) 311 | }, this) 312 | renderProjectStats(locationSuffix, parentProjectStats, parentProjectIds) 313 | 314 | let element = document.getElementById(`${buildStats.buildTypeId}_test_statistics${locationSuffix?locationSuffix:''}`) 315 | let testStatisticsText = document.createTextNode(` ${newFailed?'('+newFailed+'×🚩) ':''}${failedInvestigated?'('+failedInvestigated+'×🕵) ':''}${failedNotInvestigated?'('+failedNotInvestigated+'×🙈) ':''}${ignored?'('+ignored+'×🙉) ':''}${muted?'('+muted+'×🙊) ':''}[${passed?passed:0}/${count}] = ${percentage}%`) 316 | element.appendChild(testStatisticsText) 317 | } 318 | 319 | async function renderFinishTime(build) { 320 | if (build.state == 'finished') { 321 | return 322 | } 323 | let element = document.getElementById(`${build.buildTypeId}_finish${build.locationSuffix?build.locationSuffix:''}`) 324 | let buildDate = new Date(build.unixTime).toLocaleTimeString() 325 | let finishTimeText = document.createTextNode(`${build.unixTime ? '⏰' : ''}${buildDate=='Invalid Date'?'⏰calculating':buildDate}`) 326 | element.appendChild(finishTimeText) 327 | } 328 | 329 | async function renderTags(build) { 330 | if (build.tags.tag.length > 0) 331 | { 332 | let tagsContainer = document.createElement("div") 333 | let tagsTitle = '' 334 | for (let element of build.tags.tag) { 335 | tagsTitle+=(element.name+'\n') 336 | } 337 | tagsContainer.setAttribute('title', `${tagsTitle}`) 338 | let tagsText = document.createTextNode('📌') 339 | tagsContainer.appendChild(tagsText) 340 | document.getElementById(`${build.buildTypeId}_finish${build.locationSuffix?build.locationSuffix:''}`).appendChild(tagsContainer) 341 | } 342 | } 343 | 344 | async function renderProjectStats(locationSuffix, parentProjectStats, parentProjectIds) { 345 | Object.entries(parentProjectIds).forEach(([key,projectId]) => { 346 | //console.log(projectStats) 347 | let element = document.getElementById(`${projectId}_stats${locationSuffix?locationSuffix:''}`) 348 | //let testStatisticsText = document.createTextNode(` ${parentProjectStats[projectId].newFailed?'('+parentProjectStats[projectId].newFailed+'×🚩) ':''}${parentProjectStats[projectId].failedInvestigated?'('+parentProjectStats[projectId].failedInvestigated+'×🕵) ':''}${parentProjectStats[projectId].failedNotInvestigated?'('+parentProjectStats[projectId].failedNotInvestigated+'×🙈) ':''}${parentProjectStats[projectId].ignored?'('+parentProjectStats[projectId].ignored+'×🙉) ':''}${parentProjectStats[projectId].muted?'('+parentProjectStats[projectId].muted+'×🙊) ':''}[${parentProjectStats[projectId].passed?parentProjectStats[projectId].passed:0}/${parentProjectStats[projectId].count}] = ${parentProjectStats[projectId].percentage}%`) 349 | let testStatisticsText = document.createTextNode(` [${parentProjectStats[projectId].passed?parentProjectStats[projectId].passed:0}/${parentProjectStats[projectId].count}] = ${parentProjectStats[projectId].percentage}%`) 350 | element.replaceChildren(testStatisticsText) 351 | }, this) 352 | /* 353 | for ([projectId,projectStats] of parentProjectStats) { 354 | let element = document.getElementById(`${projectId}_stats`) 355 | let testStatisticsText = document.createTextNode(` ${projectStats.newFailed?'('+projectStats.newFailed+'×🚩) ':''}${projectStats.failedInvestigated?'('+projectStats.failedInvestigated+'×🕵) ':''}${projectStats.failedNotInvestigated?'('+projectStats.failedNotInvestigated+'×🙈) ':''}${projectStats.ignored?'('+projectStats.ignored+'×🙉) ':''}${projectStats.muted?'('+projectStats.muted+'×🙊) ':''}[${projectStats.passed?projectStats.passed:0}/${projectStats.count}] = ${projectStats.percentage}%`) 356 | element.replaceChildren(testStatisticsText) 357 | } 358 | */ 359 | } 360 | 361 | async function renderBuildDetails(buildId, buildStepsDivId, messages, tests, changes) { 362 | //let parentElementId = document.getElementById(buildId).parentElement.id 363 | let buildDetails = document.getElementById(`${buildStepsDivId}`) //document.querySelectorAll(`#${parentElementId}`)[0].nextSibling 364 | let parentElementId = buildDetails.parentElement.id 365 | buildDetails.innerHTML = "" 366 | buildDetails.classList.remove('hidden') 367 | 368 | // Build button-bar 369 | let buildButtonBar = document.createElement('div') 370 | buildButtonBar.classList.add('header') 371 | buildButtonBar.classList.add('buildButtonBar') 372 | buildDetails.appendChild(buildButtonBar) 373 | 374 | // Show logs 375 | let buildMessagesButton = document.createElement('button') 376 | buildMessagesButton.classList.add('toggle') 377 | buildMessagesButton.classList.add('active') 378 | buildMessagesButton.setAttribute('onclick', 379 | `this.parentElement.getElementsByClassName('active')[0].classList.remove('active') 380 | this.classList.add('active') 381 | this.parentElement.parentElement.getElementsByClassName('messages')[0].classList.remove('hidden') 382 | this.parentElement.parentElement.getElementsByClassName('tests')[0].classList.add('hidden') 383 | this.parentElement.parentElement.getElementsByClassName('changes')[0].classList.add('hidden')`) 384 | buildMessagesButton.appendChild(document.createTextNode('Important logs')) 385 | buildButtonBar.appendChild(buildMessagesButton) 386 | 387 | // Show tests 388 | let buildStepsButton = document.createElement('button') 389 | buildStepsButton.classList.add('toggle') 390 | buildStepsButton.setAttribute('onclick', 391 | `this.parentElement.getElementsByClassName('active')[0].classList.remove('active') 392 | this.classList.add('active') 393 | this.parentElement.parentElement.getElementsByClassName('messages')[0].classList.add('hidden') 394 | this.parentElement.parentElement.getElementsByClassName('tests')[0].classList.remove('hidden') 395 | this.parentElement.parentElement.getElementsByClassName('changes')[0].classList.add('hidden')`) 396 | buildStepsButton.appendChild(document.createTextNode('Tests')) 397 | buildButtonBar.appendChild(buildStepsButton) 398 | 399 | // Show changes 400 | let buildChangesButton = document.createElement('button') 401 | buildChangesButton.classList.add('toggle') 402 | buildChangesButton.setAttribute('onclick', 403 | `this.parentElement.getElementsByClassName('active')[0].classList.remove('active') 404 | this.classList.add('active') 405 | this.parentElement.parentElement.getElementsByClassName('messages')[0].classList.add('hidden') 406 | this.parentElement.parentElement.getElementsByClassName('tests')[0].classList.add('hidden') 407 | this.parentElement.parentElement.getElementsByClassName('changes')[0].classList.remove('hidden')`) 408 | buildChangesButton.appendChild(document.createTextNode('Blame')) 409 | buildButtonBar.appendChild(buildChangesButton) 410 | 411 | // Open build in TeamCity 412 | let buildLink = document.createElement('button') 413 | buildLink.setAttribute('onclick',`window.open('${teamcity_base_url}/viewLog.html?buildId=${buildId}&buildTypeId=${parentElementId};','build_${buildId}','fullscreen=yes')`) 414 | buildLink.appendChild(document.createTextNode(`Open in TeamCity ⧉`)) 415 | buildButtonBar.appendChild(buildLink) 416 | 417 | // Close build details 418 | let buildCloseButton = document.createElement('button') 419 | buildCloseButton.setAttribute('onclick',`this.parentElement.parentElement.classList.add('hidden')`) 420 | buildCloseButton.appendChild(document.createTextNode('Close')) 421 | buildButtonBar.appendChild(buildCloseButton) 422 | 423 | // Messages DIV 424 | let messagesDiv = document.createElement('div') 425 | messagesDiv.classList.add('messages') 426 | buildDetails.appendChild(messagesDiv) 427 | 428 | // Steps DIV 429 | let testsDiv = document.createElement('div') 430 | testsDiv.classList.add('tests') 431 | testsDiv.classList.add('hidden') 432 | buildDetails.appendChild(testsDiv) 433 | 434 | // Changes DIV 435 | let changesDiv = document.createElement('div') 436 | changesDiv.classList.add('changes') 437 | changesDiv.classList.add('hidden') 438 | buildDetails.appendChild(changesDiv) 439 | 440 | function addMessagesToElement(messages, element) { 441 | Object.entries(messages).forEach(async ([key, message]) => { 442 | 443 | let messageP = document.createElement('div') 444 | messageP.classList.add('message') 445 | if (message.status == 2) 446 | messageP.classList.add('warning') 447 | else if (message.status == 4) 448 | messageP.classList.add('error') 449 | else { 450 | messageP.classList.add('normal') 451 | } 452 | let messageSpan = document.createElement('span') 453 | messageSpan.innerText = message.text 454 | messageP.appendChild(messageSpan) 455 | element.appendChild(messageP) 456 | 457 | if (message.containsMessages && message.id != 0) { 458 | 459 | let moreMessages = get_more_messages(buildId,message.id) 460 | 461 | messageP.style.display = 'flex' 462 | messageP.style.flexDirection = 'column' 463 | 464 | let subMessagesCollapse = document.createElement('span') 465 | messageSpan.prepend(subMessagesCollapse) 466 | subMessagesCollapse.storeText = message.text 467 | subMessagesCollapse.innerText = `▶ ${subMessagesCollapse.storeText}` 468 | subMessagesCollapse.classList.add('collapse_message_button') 469 | subMessagesCollapse.style.display = 'inline-block' 470 | subMessagesCollapse.setAttribute('onclick',`this.innerText=this.innerText.startsWith('▼')?'▶ '+this.storeText:'▼ '+this.storeText;this.classList.toggle('active');this.nextSibling.classList.toggle("hidden")`) 471 | messageSpan.style.display = 'none' 472 | messageP.appendChild(subMessagesCollapse) 473 | 474 | let subMessages = document.createElement('div') 475 | messageP.appendChild(subMessages) 476 | subMessages.style.borderLeft = '2px solid black' 477 | subMessages.classList.add('hidden') 478 | 479 | addMessagesToElement(await moreMessages, subMessages) 480 | } 481 | 482 | }) 483 | } 484 | 485 | addMessagesToElement(messages, messagesDiv) 486 | 487 | if (changes.length == 0) { 488 | changesDiv.innerHTML = 'Nobody to blame... 😭' 489 | } 490 | 491 | Object.entries(tests).forEach(([key, test]) => { 492 | 493 | let testP = document.createElement('p') 494 | let testA = document.createElement('a') 495 | testA.classList.add('message') 496 | testA.setAttribute('target','_blank') 497 | testA.setAttribute('href',`${teamcity_base_url}/buildConfiguration/${test.build.buildTypeId}/${test.build.id}?showLog=${test.build.id}_${test.logAnchor}`) 498 | 499 | if (test.status == 'WARNING') 500 | testA.classList.add('warning') 501 | if (test.status == 'FAILURE') 502 | testA.classList.add('error') 503 | if (test.status == 'UNKNOWN') { 504 | testA.classList.add('unknown') 505 | } 506 | 507 | let tags = '' 508 | let investigation_names = '' 509 | 510 | if (test.test?.investigations?.investigation?.length == 0) 511 | tags += '🙈' 512 | else { 513 | investigation_names = test.test.investigations.investigation.map((investigation) => {return investigation.assignee.name}) 514 | tags += '🕵' 515 | testP.style.color = 'var(--deltares-blue)' 516 | } 517 | if (test.ignored) 518 | tags += '🙉' 519 | if (test.muted) 520 | tags += '🙊' 521 | 522 | testP.innerText = `${tags} ${investigation_names?'('+investigation_names+')':''} ${test.test.parsedTestName.testShortName}\n⇾ ${test.details}` 523 | testA.appendChild(testP) 524 | 525 | if (investigation_names) 526 | testsDiv.insertBefore(testA, testsDiv.firstChild) 527 | else 528 | testsDiv.appendChild(testA) 529 | 530 | }) 531 | 532 | if (tests.length == 0) { 533 | testsDiv.innerHTML = 'No failed tests!' 534 | } 535 | 536 | Object.entries(changes).forEach(([key, change]) => { 537 | 538 | let versionDiv = document.createElement('div') 539 | let linkDiv = document.createElement('div') 540 | let userDiv = document.createElement('div') 541 | let timeDiv = document.createElement('div') 542 | userDiv.classList.add('build_user') 543 | //let filesDiv = document.createElement('div') 544 | versionDiv.innerHTML = `#${change.version}` 545 | let fileList = change.files.file.map(file => file['relative-file']).join('\n') 546 | linkDiv.innerHTML = `#${change.comment}` 547 | userDiv.innerHTML = `${change.user?change.user.name:change.username}`//'🤖' 548 | timeDiv.innerHTML = `${new Date(tcTimeToUnix(change.date)).toLocaleString()}` 549 | changesDiv.appendChild(versionDiv) 550 | changesDiv.appendChild(linkDiv) 551 | changesDiv.appendChild(userDiv) 552 | changesDiv.appendChild(timeDiv) 553 | 554 | }) 555 | 556 | } 557 | 558 | // Show or hide all build types of which the last build was successful. 559 | function toggleGreen() { 560 | 561 | let greenBuildTypes = document.querySelectorAll('#_projects .buildTypePart.SUCCESS') 562 | 563 | for (item of greenBuildTypes) { 564 | item.classList.toggle('hidden') 565 | } 566 | 567 | } 568 | 569 | // Show or hide all build types of which the last build was successful. 570 | function toggleUnchangedBuildTypes() { 571 | 572 | let unchangedBuildTypes = document.querySelectorAll('#_projects .buildTypePart:not(.statusChanged)') 573 | 574 | for (item of unchangedBuildTypes) { 575 | item.classList.toggle('hidden_statusChanged') 576 | } 577 | 578 | } 579 | --------------------------------------------------------------------------------