diff options
author | Kirill Rysin <35688753+naspirato@users.noreply.github.com> | 2024-09-06 12:48:10 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-09-06 13:48:10 +0300 |
commit | f464fbb9acfab01e89a6ec707c5159fba71261d5 (patch) | |
tree | 94a0447cd77e915c73f909ee58fca5df21c78692 | |
parent | a54602e90a76dd54fb6d9594486dcbb45de1dfbd (diff) | |
download | ydb-f464fbb9acfab01e89a6ec707c5159fba71261d5.tar.gz |
Test report: links to history (#8638)
-rw-r--r-- | .github/config/mute_rules.md | 79 | ||||
-rwxr-xr-x | .github/scripts/tests/generate-summary.py | 26 | ||||
-rw-r--r-- | .github/scripts/tests/templates/summary.html | 364 |
3 files changed, 451 insertions, 18 deletions
diff --git a/.github/config/mute_rules.md b/.github/config/mute_rules.md new file mode 100644 index 0000000000..a9a1e4aa84 --- /dev/null +++ b/.github/config/mute_rules.md @@ -0,0 +1,79 @@ +## [How to Mute a test](#how-to-mute) + +- Through a PR Report + - Open report in PR ![screen](https://storage.yandexcloud.net/ydb-public-images/report_mute.png) + - In context menu of test select `Crete mute issue` + + - Through the [Test history](https://datalens.yandex/4un3zdm0zcnyr?tab=A4) dashboard + + - Enter the test name or path in the `full_name contain` field, click **Apply** - the search is done by the occurrence. ![image.png](https://storage.yandexcloud.net/ydb-public-images/mute_candidate.png) + + - Click the `Mute` link, which will create a draft issue in GitHub. + + +* Add the issue to the [Mute and Un-mute](https://github.com/orgs/ydb-platform/projects/45/views/6?visibleFields=%5B%22Title%22%2C%22Assignees%22%2C%22Status%22%2C126637100%5D) project. +* Set the `status` to `Mute` +* Set the `owner` field to the team name (see the issue for the owner's name). ![image.png](https://storage.yandexcloud.net/ydb-public-images/create_issue.png) +* Open [muted_ya.txt](https://github.com/ydb-platform/ydb/blob/main/.github/config/muted_ya.txt) in a new tab and edit it. +* Copy the line under `Add line to muted_ya.txt` (for example, like in the screenshot, `ydb/core/kqp/ut/query KqpStats.SysViewClientLost`) and add it to [muted_ya.txt](https://github.com/ydb-platform/ydb/blob/main/.github/config/muted_ya.txt). +* Edit the branch for merging, for example, replace `{username}-patch-1` with `{username}/mute`. +* Create a PR - copy the PR name from the issue name. +* Copy the issue description to the PR, keep the line `Not for changelog (changelog entry is not required)`. +* Take "OK" from member of test owner team in PR +* Merge. +* Link Issue and Pr (field "Development" in issue and PR) +* Inform test owner team about new mutes - dm or in public chat (with mention of maintainer of team) +* You are awesome! + +## [How to UnMute a test](#how-to-unmute) +--IN PROGRESS-- +* Open [muted_ya.txt](https://github.com/ydb-platform/ydb/blob/main/.github/config/muted_ya.txt) +* Press "Edit file" and delete line of test +* Commit changes (Edit the branch for merging, for example, replace `{username}-patch-1` with `mute/{username}`) +* Edit PR name like "UnMute {testname}" +* Take "OK" from member of test owner team in PR +* Merge +* If test have an issue in [Mute and Un-mute](https://github.com/orgs/ydb-platform/projects/45/views/6?visibleFields=%5B%22Title%22%2C%22Assignees%22%2C%22Status%22%2C126637100%5D) in status "Muted" - Move it to "Unmuted" +* Link Issue and Pr (field "Development" in issue and PR) +* You are awesome! + + +## Flaky Tests + +### Who and When Monitors Flaky Tests + +The CI duty engineer (in progress) checks flaky tests once a day (only working days). + +- Open the [Flaky](https://datalens.yandex/4un3zdm0zcnyr) dashboard. +- Perform the sections **[Mute Flaky Test](#mute-flaky)** and **[Test Flaps More - Need to Unmute](#unmute-flaky)** once a day or ondemand + +### [Mute Flaky Tests](#mute-flaky) + +Open the [Flaky](https://datalens.yandex/4un3zdm0zcnyr) dashboard. + +- Select today's date. +- Look at the tests in the Mute candidate table. + +![image.png](/kikimr/ydb-qa/mute-autotests/.files/image-1.png =800x) + +- Select today's date in the `date_window`. +- Select `days_ago_window = 5` (how many days back from the selected day to calculate statistics). Currently, there are calculations for 1 day and 5 days ago. + * If you want to understand how long ago and how often the test started failing, you can click the `history` link in the table (loading may take time) or select `days_ago_window = 1`. +- For `days_ago_window = 5`, set the values to filter out isolated failures and low run counts: + * `fail_count >= 3` + * `run_count >= 10` +- Click the `Mute` link, which will create a draft issue in GitHub. +- Perform steps from [How to mute](#how-to-mute) +- You are awesome! + +### [Test is no longer flaky - Time to Unmute](#unmute-flaky) + +- Open the [Flaky](https://datalens.yandex/4un3zdm0zcnyr) dashboard. +- Look at the tests in the UNMute candidate table. + +![image.png](https://storage.yandexcloud.net/ydb-public-images/unmute.png) + +- If the `summary:` column shows `mute <= 3` and `success rate >= 98%` - **it's time to enable the test**. +- Perform steps from [How to Unmute](#how-to-unmute) +- You are awesome! + diff --git a/.github/scripts/tests/generate-summary.py b/.github/scripts/tests/generate-summary.py index aec0989b51..1be461eb6b 100755 --- a/.github/scripts/tests/generate-summary.py +++ b/.github/scripts/tests/generate-summary.py @@ -4,6 +4,7 @@ import dataclasses import os import sys import traceback +from codeowners import CodeOwners from enum import Enum from operator import attrgetter from typing import List, Dict @@ -35,6 +36,7 @@ class TestResult: log_urls: Dict[str, str] elapsed: float count_of_passed: int + owners: str @property def status_display(self): @@ -94,7 +96,7 @@ class TestResult: elapsed = 0 print(f"Unable to cast elapsed time for {classname}::{name} value={elapsed!r}") - return cls(classname, name, status, log_urls, elapsed, 0) + return cls(classname, name, status, log_urls, elapsed, 0,'') class TestSummaryLine: @@ -241,6 +243,14 @@ def render_testlist_html(rows, fn, build_preset): # remove status group without tests status_order = [s for s in status_order if s in status_test] + # get testowners + all_tests = [test for status in status_order for test in status_test.get(status)] + + dir = os.path.dirname(__file__) + git_root = f"{dir}/../../.." + codeowners = f"{git_root}/.github/TESTOWNERS" + get_codeowners_for_tests(codeowners, all_tests) + # statuses for history status_for_history = [TestStatus.FAIL, TestStatus.MUTE] status_for_history = [s for s in status_for_history if s in status_test] @@ -301,6 +311,20 @@ def write_summary(summary: TestSummary): fp.close() +def get_codeowners_for_tests(codeowners_file_path, tests_data): + with open(codeowners_file_path, 'r') as file: + data = file.read() + owners_odj = CodeOwners(data) + + tests_data_with_owners = [] + for test in tests_data: + target_path = test.classname + owners = owners_odj.of(target_path) + test.owners = joined_owners = ";;".join( + [(":".join(x)) for x in owners]) + tests_data_with_owners.append(test) + + def gen_summary(public_dir, public_dir_url, paths, is_retry: bool, build_preset): summary = TestSummary(is_retry=is_retry) diff --git a/.github/scripts/tests/templates/summary.html b/.github/scripts/tests/templates/summary.html index 37705446c8..a2c297fb54 100644 --- a/.github/scripts/tests/templates/summary.html +++ b/.github/scripts/tests/templates/summary.html @@ -22,12 +22,12 @@ min-width: 80px; } td.test_name{ - min-width: 79vw; - max-width: 79vw; + min-width: 72.5vw; + max-width: 72.5vw; } td.test_name.with_history{ - min-width: 73vw; - max-width: 73vw; + min-width: 62vw; + max-width: 62vw; } table { @@ -78,7 +78,123 @@ position: relative; /* Essential for tooltips */ cursor: pointer; } - + .button { + display: inline-flex; + align-items: center; + justify-content: left; + padding: 4px; + border: none; + background: none; + cursor: pointer; + position: relative; + } + + .button svg { + fill: #4b535c; + fill-rule: evenodd; + } + + .button svg:hover { + fill: #0366d6; + fill-rule: evenodd; + } + + .button-group { + display: inline-flex; + float: right; + align-items: center; + position: relative; /* Added for positioning the dropdown */ + white-space: nowrap; + } + + .button-group > button { + margin-left: 5px; + padding: 4px 8px; + border: none; + background: none; + cursor: pointer; + white-space: nowrap; + } + + .button-group > button svg { + fill: #4b535c; + fill-rule: evenodd; + } + + .button-group > button svg:hover { + fill: #0366d6; + fill-rule: evenodd; + } + .button-group .icon-container .show_history_svg { + fill: #ffffff; + stroke: #000; + stroke-width: 2.5; + fill-rule: evenodd; + } + + .button-group .icon-container .show_history_svg:hover { + stroke: #0366d6; + } + + .button-group .button-text { + color: #4b535c; + } + .button-group .button-text:hover { + color: #0366d6; + } + + .button-group .dropdown { + display: none; /* Hide the dropdown by default */ + position: absolute; + top: 100%; + right: 0; + background-color: white; + border: 1px solid #ddd; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 8px; + z-index: 10; /* Ensure the dropdown is above other content */ + width: 160; + } + + .button-group .dropdown.show { + display: block; /* Show the dropdown when active */ + } + + .button-group .dropdown > button { + display: table; /* Make each button in the dropdown take up full width */ + margin-bottom: 3px; /* Add spacing between dropdown buttons */ + } + .button-group button .icon-container { + display: inline-block; /* Align SVG with text */ + vertical-align: middle; /* Center SVG vertically */ + margin-right: 3px; /* Add spacing between SVG and text */ + } + + .button-group button .button-text { + margin-left: 5px; /* Add spacing between icon and text */ + cursor: pointer; /* Make text clickable */ + + } + .button-group button::after { /* Target the "Copy" button specifically */ + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + background-color: #7d7d92; /* Same as your tooltip background */ + color: white; + padding: 2px 5px; + border-radius: 3px; + visibility: hidden; /* Hide by default */ + opacity: 0; + transition: visibility 0.2s, opacity 0.2s; + } + + .button-group button:hover::after { + visibility: visible; + opacity: 1; + } + .tooltip { visibility: hidden; max-width: 340px; @@ -117,10 +233,7 @@ opacity: 1; } - button.copy { - float: right; - position: relative; /* To hold the copied tooltip */ - } + table > tbody > tr > td:nth-child(2), table > tbody > tr > td:nth-child(3), table > tbody > tr > td:nth-child(4) { @@ -159,6 +272,22 @@ } </style> <script> + function findParentBySelector(elm, selector) { + var all = document.querySelectorAll(selector); + var cur = elm.parentNode; + while(cur && !collectionHas(all, cur)) { //keep going up until you find a match + cur = cur.parentNode; //go up + } + return cur; //will return null if not found + } + + function collectionHas(a, b) { //helper function (see below) + for(var i = 0, len = a.length; i < len; i ++) { + if(a[i] == b) return true; + } + return false; + } + function copyTestNameToClipboard(text) { const full_name = text.trim(); const pieces = /(.+)\/([^$]+)$/.exec(full_name); @@ -177,7 +306,32 @@ testName = namePieces[0] + '.' + namePieces[1] + '::' + namePieces.slice(2).join('::'); } - const cmdArg = `${path} -F '${testName}'`; + const cmdArg = `./ya make -ttt --build release -k -F '${testName}' ${path}`; + + console.log(cmdArg); + + navigator.clipboard.writeText(cmdArg).then( + () => { + console.log("Copied!"); + showCopiedTooltip(); + }, + () => { + console.error("Unable to copy %o to clipboard", cmdArg); + } + ); + } + function copyTestNameForMuteToClipboard(text) { + const full_name = text.trim(); + const pieces = /(.+)\/([^$]+)$/.exec(full_name); + + if (!pieces) { + console.error("Unable to split path/test name from %o", full_name); + return; + } + let [path, testName] = [pieces[1], pieces[2]]; + + + const cmdArg = `${path} ${testName}`; console.log(cmdArg); @@ -192,6 +346,34 @@ ); } + function createIssue(test,owner,success_count, fail_count) { + const full_name = test.trim(); + const pieces = /(.+)\/([^$]+)$/.exec(full_name); + + if (!pieces) { + console.error("Unable to split path/test name from %o", full_name); + return; + } + let [path, testName] = [pieces[1], pieces[2]]; + + if (success_count + fail_count != 0){ + let url = "https://github.com/ydb-platform/ydb/issues/new?title=Mute "+ encodeURIComponent(path)+"/"+ encodeURIComponent(testName) + "&body=" + encodeURIComponent(path)+"/"+ encodeURIComponent(testName) +"%0A%0A**Add%20line%20to%20[muted_ya.txt](https://github.com/ydb-platform/ydb/blob/main/.github/config/muted_ya.txt):**%0A%60" + encodeURIComponent(path)+"/"+ encodeURIComponent(testName)+"%60%0A%0A%20Owner:%20[TEAM:@ydb-platform/"+owner+"](https://github.com/orgs/ydb-platform/teams/"+owner+")%0A%0A**Read%20more%20in%20[mute_rules.md](https://github.com/ydb-platform/ydb/blob/main/.github/config/mute_rules.md)**%20%20%0A%0A**Summary%20history:**%0A%20Success%20rate%20**"+(success_count/(success_count+fail_count)*100)+"%25**%0APass:"+success_count+"%20Fail:"+fail_count+"%20%0A%0A**Test%20run%20history:**%20[link](https://datalens.yandex/34xnbsom67hcq?full_name=" +path+ "/"+ testName+ ")%0A%0AMore%20info%20in%20[dashboard](https://datalens.yandex/4un3zdm0zcnyr)&labels=mute" + window.open(url, '_blank'); + } + else { + let url = "https://github.com/ydb-platform/ydb/issues/new?title=Mute "+ encodeURIComponent(path)+"/"+ encodeURIComponent(testName) + "&body=" + encodeURIComponent(path)+"/"+ encodeURIComponent(testName) +"%0A%0A**Add%20line%20to%20[muted_ya.txt](https://github.com/ydb-platform/ydb/blob/main/.github/config/muted_ya.txt):**%0A%60" + encodeURIComponent(path)+"/"+ encodeURIComponent(testName)+"%60%0A%0A%20Owner:%20[TEAM:@ydb-platform/"+owner+"](https://github.com/orgs/ydb-platform/teams/"+owner+")%0A%0A**Read%20more%20in%20[mute_rules.md](https://github.com/ydb-platform/ydb/blob/main/.github/config/mute_rules.md)**%20%20%0A%0A**Summary%20history:**%0APass:"+success_count+"%20Fail:"+fail_count+"%20%0A%0A**Test%20run%20history:**%20[link](https://datalens.yandex/34xnbsom67hcq?full_name=" +path+ "/"+ testName+ ")%0A%0AMore%20info%20in%20[dashboard](https://datalens.yandex/4un3zdm0zcnyr)&labels=mute" + window.open(url, '_blank'); + } + + } + + function openHistory(test) { + const full_name = test.trim(); + let url = "https://datalens.yandex/34xnbsom67hcq?full_name="+full_name + window.open(url, '_blank'); + } + + let lastOpenedTooltip = null; function toggleTooltip(event) { @@ -224,8 +406,60 @@ contents.forEach(content => content.classList.remove('active')); } } + function ButtonIconsClick(button){ + button.addEventListener('click', function() { + const iconContainer = button.querySelector('.icon-container'); + const initialIcon = iconContainer.querySelector('svg:first-child'); + const doneIcon = iconContainer.querySelector('svg:last-child'); + + // Swap icon visibility + initialIcon.style.display = 'none'; + doneIcon.style.display = 'block'; + // You can add any additional actions here, like: + // - Disabling the button + // - Changing the button's title + // - Triggering other effects + }); + + } document.addEventListener("DOMContentLoaded", function() { + let openDropdown = null; // Track the currently open dropdown + + const buttonGroups = document.querySelectorAll(".button-group"); + buttonGroups.forEach(buttonGroup => { + const dropdown = buttonGroup.querySelector('.dropdown'); + const toggleButton = buttonGroup.querySelector('button:first-child'); // The "..." button + + toggleButton.addEventListener('click', function() { + // Close any previously open dropdown + if (openDropdown && openDropdown !== dropdown) { + openDropdown.classList.remove('show'); + } + dropdown.classList.toggle('show'); + openDropdown = dropdown.classList.contains('show') ? dropdown : null; + const iconContainer = dropdown.querySelectorAll('.icon-container'); + iconContainer.forEach(element=>{ + const initialIcon = element.querySelector('svg:first-child'); + const doneIcon = element.querySelector('svg:last-child'); + + // Swap icon visibility + initialIcon.style.display = 'block'; + doneIcon.style.display = 'none'; + }); + }); + + // Close the dropdown when clicking outside of it + document.addEventListener('click', function(event) { + if (!event.target.closest('.button-group')) { + dropdown.classList.remove('show'); + openDropdown = null; + } + }); + buttonGroup.querySelectorAll('.button-group .dropdown .button').forEach(button => { + ButtonIconsClick(button) + }); + }); document.addEventListener('keydown', function(event) { if ((event.ctrlKey && event.keyCode == 70) || (event.metaKey && event.keyCode == 70)) { @@ -246,9 +480,38 @@ copyButtons.forEach(button => { button.addEventListener('click', function(event) { event.preventDefault(); - copyTestNameToClipboard(event.target.closest('.copy').previousElementSibling.textContent); + copyTestNameToClipboard(findParentBySelector(event.target,'tr').querySelector('.test_name').querySelector('span').innerText); + }); + }); + const muteButtons = document.querySelectorAll(".mute"); + muteButtons.forEach(button => { + button.addEventListener('click', function(event) { + event.preventDefault(); + copyTestNameForMuteToClipboard(findParentBySelector(event.target,'tr').querySelector('.test_name').querySelector('span').innerText); + }); + }); + const historyButton = document.querySelectorAll(".open_history"); + historyButton.forEach(button => { + button.addEventListener('click', function(event) { + event.preventDefault(); + openHistory(findParentBySelector(event.target,'tr').querySelector('.test_name').querySelector('span').innerText); }); }); + const createIssueButton = document.querySelectorAll(".create_issue"); + createIssueButton.forEach(button => { + button.addEventListener('click', function(event) { + event.preventDefault(); + const success_count = findParentBySelector(event.target,'tr').querySelectorAll('.svg_passed').length + const fail_count = findParentBySelector(event.target,'tr').querySelectorAll('.svg_failure').length + findParentBySelector(event.target,'tr').querySelectorAll('.svg_mute').length + + createIssue( + findParentBySelector(event.target,'tr').querySelector('.test_name').querySelector('span').innerText, + findParentBySelector(event.target,'tr').querySelector('.test_owner').innerText, + success_count,fail_count + ); + }); + }); + const headers = document.querySelectorAll(".collapsible-header"); headers.forEach(header => { header.addEventListener('click', function() { @@ -280,6 +543,7 @@ <table class="collapsible-content active" border="1"> <thead> <tr> + <th>test owner</th> <th>test name</th> {% if status.is_error and history%} <th>history<br> @@ -296,6 +560,9 @@ <tbody> {% for t in tests[status] %} <tr> + <td class="test_owner"> + <a href="https://github.com/orgs/ydb-platform/teams/{{ t.owners.replace('TEAM:@ydb-platform/','')}}" target="_blank">{{ t.owners.replace('TEAM:@ydb-platform/','')}}</a> + </td> {% if status.is_error %} <td class="test_name with_history"> {% else %} @@ -303,13 +570,76 @@ {% endif %} <span>{{ t.full_name }}</span> {% if status.is_error %} - <button class="copy" title="Copy test filter to clipboard" > - <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="14" height="14" class="g-icon" fill="currentColor" stroke="none" aria-hidden="true"> - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"> - <path class="copy_svg" fill="currentColor" fill-rule="evenodd" d="M12 2.5H8A1.5 1.5 0 0 0 6.5 4v1H8a3 3 0 0 1 3 3v1.5h1A1.5 1.5 0 0 0 13.5 8V4A1.5 1.5 0 0 0 12 2.5M11 11h1a3 3 0 0 0 3-3V4a3 3 0 0 0-3-3H8a3 3 0 0 0-3 3v1H4a3 3 0 0 0-3 3v4a3 3 0 0 0 3 3h4a3 3 0 0 0 3-3zM4 6.5h4A1.5 1.5 0 0 1 9.5 8v4A1.5 1.5 0 0 1 8 13.5H4A1.5 1.5 0 0 1 2.5 12V8A1.5 1.5 0 0 1 4 6.5" clip-rule="evenodd"></path> + <div class="button-group"> + <button class="button" title="Show options"> + <svg class="more" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="14" height="14" stroke="none" aria-hidden="true" viewBox="0 0 448 512"> + <path d="M8 256a56 56 0 1 1 112 0A56 56 0 1 1 8 256zm160 0a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm216-56a56 56 0 1 1 0 112 56 56 0 1 1 0-112z" clip-rule="evenodd"></path> </svg> - </svg> - </button> + </button> + <div class="dropdown"> + <button class="button copy" title="Copy test filter to clipboard" > + <div class="icon-container"> + <svg class="copy_svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="14" height="14" stroke="none" aria-hidden="true" viewBox="0 0 16 16"> + <path d="M12 2.5H8A1.5 1.5 0 0 0 6.5 4v1H8a3 3 0 0 1 3 3v1.5h1A1.5 1.5 0 0 0 13.5 8V4A1.5 1.5 0 0 0 12 2.5M11 11h1a3 3 0 0 0 3-3V4a3 3 0 0 0-3-3H8a3 3 0 0 0-3 3v1H4a3 3 0 0 0-3 3v4a3 3 0 0 0 3 3h4a3 3 0 0 0 3-3zM4 6.5h4A1.5 1.5 0 0 1 9.5 8v4A1.5 1.5 0 0 1 8 13.5H4A1.5 1.5 0 0 1 2.5 12V8A1.5 1.5 0 0 1 4 6.5" clip-rule="evenodd"></path> + </svg> + <svg class="done-icon" width="16" height="16" viewBox="0 0 26 26" fill="green" display="none"> + <path class="cls-2" d="M12,2A10,10,0,1,0,22,12,10,10,0,0,0,12,2Zm0,18a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"/> + <path class="cls-2" d="M14.7,8.39l-3.78,5L9.29,11.28a1,1,0,0,0-1.58,1.23l2.43,3.11a1,1,0,0,0,.79.38h0a1,1,0,0,0,.79-.39l4.57-6a1,1,0,1,0-1.6-1.22Z"/> + </svg> + </div> + <span class="button-text">Copy run command</span> + </button> + {% if status.name == "FAIL" %} + <button class="button mute" title="Copy mute string to clipboard" > + <div class="icon-container"> + <svg class="mute_svg" xmlns="http://www.w3.org/2000/svg" width="32" height="14" position="left" stroke="none" viewBox="2 0 30 17"> + <!-- First icon: copied squares --> + <path transform="translate(20, 0)" d="M12 2.5H8A1.5 1.5 0 0 0 6.5 4v1H8a3 3 0 0 1 3 3v1.5h1A1.5 1.5 0 0 0 13.5 8V4A1.5 1.5 0 0 0 12 2.5M11 11h1a3 3 0 0 0 3-3V4a3 3 0 0 0-3-3H8a3 3 0 0 0-3 3v1H4a3 3 0 0 0-3 3v4a3 3 0 0 0 3 3h4a3 3 0 0 0 3-3zM4 6.5h4A1.5 1.5 0 0 1 9.5 8v4A1.5 1.5 0 0 1 8 13.5H4A1.5 1.5 0 0 1 2.5 12V8A1.5 1.5 0 0 1 4 6.5" /> + <!-- Second icon: microphone --> + <path transform="translate(0, 0)" d="M5.06 9.94A1.5 1.5 0 0 0 4 9.5H2a.5.5 0 0 1-.5-.5V7a.5.5 0 0 1 .5-.5h2a1.5 1.5 0 0 0 1.06-.44l2.483-2.482a.268.268 0 0 1 .457.19v8.464a.268.268 0 0 1-.457.19zM2 5h2l2.482-2.482A1.768 1.768 0 0 1 9.5 3.768v8.464a1.768 1.768 0 0 1-3.018 1.25L4 11H2a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2m10.28.72a.75.75 0 1 0-1.06 1.06L12.44 8l-1.22 1.22a.75.75 0 1 0 1.06 1.06l1.22-1.22 1.22 1.22a.75.75 0 1 0 1.06-1.06L14.56 8l1.22-1.22a.75.75 0 0 0-1.06-1.06L13.5 6.94z" /> + </svg> + <svg class="done-icon" width="16" height="16" viewBox="0 0 26 26" fill="green" display="none"> + <path class="cls-2" d="M12,2A10,10,0,1,0,22,12,10,10,0,0,0,12,2Zm0,18a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"/> + <path class="cls-2" d="M14.7,8.39l-3.78,5L9.29,11.28a1,1,0,0,0-1.58,1.23l2.43,3.11a1,1,0,0,0,.79.38h0a1,1,0,0,0,.79-.39l4.57-6a1,1,0,1,0-1.6-1.22Z"/> + </svg> + </div> + <span class="button-text">Copy mute string</span> + </button> + <button class="button create_issue" title="Create issue"> + <div class="icon-container"> + <svg class="issue_svg" xmlns="http://www.w3.org/2000/svg" width="32" height="14" position="left" stroke="none" viewBox="2 0 30 17"> + <!-- First icon: plus --> + <path transform="translate(20, 0)" d="M13.5 8a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0M15 8A7 7 0 1 1 1 8a7 7 0 0 1 14 0M8.75 5.5a.75.75 0 0 0-1.5 0v1.75H5.5a.75.75 0 0 0 0 1.5h1.75v1.75a.75.75 0 0 0 1.5 0V8.75h1.75a.75.75 0 0 0 0-1.5H8.75z" /> + <!-- Second icon: microphone --> + <path transform="translate(0, 0)" d="M5.06 9.94A1.5 1.5 0 0 0 4 9.5H2a.5.5 0 0 1-.5-.5V7a.5.5 0 0 1 .5-.5h2a1.5 1.5 0 0 0 1.06-.44l2.483-2.482a.268.268 0 0 1 .457.19v8.464a.268.268 0 0 1-.457.19zM2 5h2l2.482-2.482A1.768 1.768 0 0 1 9.5 3.768v8.464a1.768 1.768 0 0 1-3.018 1.25L4 11H2a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2m10.28.72a.75.75 0 1 0-1.06 1.06L12.44 8l-1.22 1.22a.75.75 0 1 0 1.06 1.06l1.22-1.22 1.22 1.22a.75.75 0 1 0 1.06-1.06L14.56 8l1.22-1.22a.75.75 0 0 0-1.06-1.06L13.5 6.94z" /> + </svg> + <svg class="done-icon" width="16" height="16" viewBox="0 0 26 26" fill="green" display="none"> + <path class="cls-2" d="M12,2A10,10,0,1,0,22,12,10,10,0,0,0,12,2Zm0,18a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"/> + <path class="cls-2" d="M14.7,8.39l-3.78,5L9.29,11.28a1,1,0,0,0-1.58,1.23l2.43,3.11a1,1,0,0,0,.79.38h0a1,1,0,0,0,.79-.39l4.57-6a1,1,0,1,0-1.6-1.22Z"/> + </svg> + </div> + <span class="button-text">Create mute issue</span> + </button> + {% endif %} + <button class="button open_history" title="Show history"> + <div class="icon-container"> + <svg class="show_history_svg" width="18" height="18" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M42 24V9C42 7.34315 40.6569 6 39 6H9C7.34315 6 6 7.34315 6 9V39C6 40.6569 7.34315 42 9 42H24" stroke-linecap="round" stroke-linejoin="round"/> + <circle cx="32" cy="32" r="6" /> + <path d="M37 36L42 40" stroke-linecap="round" stroke-linejoin="round"/> + <path d="M14 16H34" stroke-linecap="round" stroke-linejoin="round"/> + <path d="M14 24L22 24" stroke-linecap="round" stroke-linejoin="round"/> + </svg> + <svg class="done-icon" width="16" height="16" viewBox="0 0 26 26" fill="green" display="none"> + <path class="cls-2" d="M12,2A10,10,0,1,0,22,12,10,10,0,0,0,12,2Zm0,18a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"/> + <path class="cls-2" d="M14.7,8.39l-3.78,5L9.29,11.28a1,1,0,0,0-1.58,1.23l2.43,3.11a1,1,0,0,0,.79.38h0a1,1,0,0,0,.79-.39l4.57-6a1,1,0,1,0-1.6-1.22Z"/> + </svg> + </div> + <span class="button-text">Open test history</span> + </button> + + </div> + </div> {% endif %} </td> {% if (status.is_error and t.full_name in history) %} |