diff --git a/layouts/partials/menu-ci.html b/layouts/partials/menu-ci.html index 2dbbe936c0..c733e6bf2f 100644 --- a/layouts/partials/menu-ci.html +++ b/layouts/partials/menu-ci.html @@ -2,7 +2,7 @@
diff --git a/static/css/custom.css b/static/css/custom.css index a245bbc5bb..e1812366dc 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -1249,6 +1249,29 @@ h6 .anchor::before { font-weight: 600; } +.ci-dashboard-filters { + display: flex; + justify-content: flex-end; + margin-bottom: 12px; +} + +.ci-dashboard-filters-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 24px; +} + +.ci-dashboard-filters .ci-toolbar { + margin-bottom: 0; +} + +.ci-dashboard-filters .ci-toolbar-range, +.ci-dashboard-filters .ci-toolbar-sort { + justify-content: flex-end; +} + .ci-toolbar { display: flex; justify-content: flex-end; @@ -1345,6 +1368,34 @@ h6 .anchor::before { color: var(--pf-global--danger-color--100, #c9190b); } +.ci-status-dot.unavailable { + background-color: var(--pf-global--BorderColor--100, #d2d2d2); +} + +.ci-status-text.unavailable { + color: var(--pf-global--Color--200, #6a6e73); +} + +a.ci-status-unavailable-link { + color: var(--pf-global--primary-color--100, #06c); + text-decoration: underline; + font-weight: 500; +} + +a.ci-status-unavailable-link:hover { + color: var(--pf-global--primary-color--200, #004080); +} + +.ci-label-load-error { + font-size: 0.85rem; + color: var(--pf-global--primary-color--100, #06c); + text-decoration: underline; +} + +.ci-label-load-error:hover { + color: var(--pf-global--primary-color--200, #004080); +} + .ci-pattern-name { font-weight: 600; color: var(--pf-global--Color--100, #151515); @@ -1514,6 +1565,26 @@ h6 .anchor::before { transition: width 0.4s ease; } +.ci-overview-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.ci-overview-toolbar .ci-overview-legend { + flex: 1 1 220px; + margin-bottom: 0; + min-width: 0; +} + +.ci-overview-toolbar .ci-toolbar-range { + flex-shrink: 0; + margin-bottom: 0; +} + .ci-overview-legend { font-size: 0.82rem; color: var(--pf-global--Color--200, #6a6e73); @@ -1659,6 +1730,10 @@ h6 .anchor::before { background-color: var(--pf-global--danger-color--100, #c9190b); } +.ci-card-platform-dot.unavailable { + background-color: var(--pf-global--BorderColor--100, #d2d2d2); +} + .ci-card-platform-label { white-space: nowrap; } @@ -1693,6 +1768,10 @@ h6 .anchor::before { background-color: var(--pf-global--danger-color--100, #c9190b); } +.ci-card-block.unavailable { + background-color: var(--pf-global--BorderColor--100, #d2d2d2); +} + .ci-card-footer { font-size: 0.8rem; color: var(--pf-global--Color--200, #6a6e73); diff --git a/static/js/dashboard.v2.js b/static/js/dashboard.v2.js index b842be3650..6da5d5c7fd 100644 --- a/static/js/dashboard.v2.js +++ b/static/js/dashboard.v2.js @@ -88,32 +88,59 @@ function jira_component (pattern) { return pattern } +// First segment of badge filenames (before the first `-`) → Hugo section under /patterns/, or absolute path. +// Sync new keys with `ci:` in content/patterns/**/_index.* (hyphenated CI IDs often appear only as a shortened prefix in keys). +var CI_PATTERN_DOC_SLUG = { + aegitops: 'ansible-edge-gitops', + agof: 'ansible-gitops-framework', + coco: 'coco-pattern', + connvehicle: 'connected-vehicle-architecture', + devsecops: 'devsecops', + emergingdd: 'emerging-disease-detection', + federatedobservability: 'federated-edge-observability', + hypershift: 'hypershift', + imageclass: 'emerging-disease-detection', + industrialedge: 'industrial-edge', + ingressmeshbgp: 'ingress-mesh-bgp', + layeredzerotrust: 'layered-zero-trust', + manuela: 'industrial-edge', + mcgitops: 'multicloud-gitops', + mcgitopshcp: 'multicloud-gitops', + mcgitopsstandalone: 'multicloud-gitops', + mcgitopsamx: 'multicloud-gitops-amx', + mcgitopsqat: 'multicloud-gitops-qat', + mcgitopssgx: 'multicloud-gitops-sgx', + mcgitopsrhoai: 'multicloud-gitops-amx-rhoai', + medicaldiag: 'medical-diagnosis', + netapp: 'netapp-dr-starter-kit', + openshiftai: 'openshift-ai', + omnicloud: 'omnicloud', + patternsoperator: '/learn/using-validated-pattern-operator', + portworx: 'portworx-dr', + ragllm: 'rag-llm-gitops', + ramendr: 'ramendr-starter-kit', + retail: 'retail', + telco: 'telco-hub', + telcohub: 'telco-hub', + travelops: 'travelops', + vsk: 'virtualization-starter-kit' +} + function pattern_url (key) { - if (key == 'aegitops') { - return '/patterns/ansible-edge-gitops/' + if (key == null || key === '') { + return '/patterns/' } - if (key == 'devsecops') { - return '/patterns/devsecops/' - } - if (key == 'industrialedge') { - return '/patterns/industrial-edge/' - } - if (key == 'mcgitops') { - return '/patterns/multicloud-gitops/' - } - if (key == 'medicaldiag') { - return '/patterns/medical-diagnosis/' - } - if (key == 'ragllm') { - return '/patterns/rag-llm-gitops/' - } - if (key == 'openshiftai') { - return '/patterns/openshift-ai/' + var slug = CI_PATTERN_DOC_SLUG[key] + if (slug != null) { + if (slug.charAt(0) === '/') { + return slug.endsWith('/') ? slug : (slug + '/') + } + return '/patterns/' + slug + '/' } - if (key == 'agof') { - return '/patterns/ansible-gitops-framework/' + // Already matches section slug (e.g. telco-hub, cockroachdb) + if (/^[a-z0-9]+(-[a-z0-9]+)*$/.test(key)) { + return '/patterns/' + key + '/' } - return '/patterns/' + key + '/' } @@ -242,13 +269,29 @@ function toTitleCase (str) { // ============================================ function jsonSuccess() { - this.callback.apply(this, this.arguments); + if (this.status < 200 || this.status >= 300) { + console.warn('getJSON HTTP', this.status, this.responseURL) + } + this.callback.apply(this, this.arguments) } function jsonError() { console.error(this.statusText); } +function parseBadgeJsonResponseText (text) { + if (text == null) return null + var t = String(text).replace(/^\uFEFF/, '').trim() + if (t.length === 0) return null + var first = t.charCodeAt(0) + if (first === 0x3C) return null // '<' — XML/HTML/S3 error bodies + try { + return JSON.parse(t) + } catch (e) { + return null + } +} + function getJSON(url, callback, ...args) { const jsonRequest = new XMLHttpRequest(); jsonRequest.callback = callback; @@ -288,6 +331,12 @@ function rowTitle (field, value) { function renderSetButtons(sets){ var currentURL = new URL(window.location.href) + if (currentURL.searchParams.get('view') === 'classic') { + ;['pattern', 'platform', 'version', 'date', 'sort'].forEach(function (k) { + currentURL.searchParams.delete(k) + }) + currentURL.searchParams.set('view', 'classic') + } const queryString = window.location.search const urlParams = new URLSearchParams(queryString) var setList = {'GA': 'GA', 'early': 'Pre-release', 'all': 'All'} @@ -311,7 +360,22 @@ function renderSetButtons(sets){ } function renderSingleBadge (key, field, linkType, badge_url) { - var json_obj = JSON.parse(this.responseText) + var json_obj = parseBadgeJsonResponseText(this.responseText) + if (json_obj == null) { + var failedEl = document.getElementById(key + '-' + field) + if (failedEl) { + var fallbackLink = document.createElement('a') + fallbackLink.href = badge_url + fallbackLink.className = 'ci-label ci-label-load-error' + fallbackLink.target = '_blank' + fallbackLink.rel = 'noopener noreferrer' + fallbackLink.textContent = 'Unavailable' + fallbackLink.setAttribute('aria-label', 'Open badge URL in a new tab') + failedEl.replaceWith(fallbackLink) + } + console.warn('Badge JSON parse failed:', badge_url) + return + } var branchLabel = json_obj.patternBranch var color = json_obj.color @@ -370,6 +434,19 @@ function renderBadges (badges, field, value, links) { return badgeText } +function legacyFilteredHref (field, rowValue) { + var cur = new URLSearchParams(window.location.search) + if (cur.get('view') !== 'classic') { + return '?' + field + '=' + encodeURIComponent(rowValue) + } + var next = new URLSearchParams() + next.set('view', 'classic') + var setsVal = cur.get('sets') + if (setsVal != null && setsVal !== '') next.set('sets', setsVal) + next.set(field, rowValue) + return '?' + next.toString() +} + function createFilteredHorizontalTable (badges, field, value, titles, links) { tableText = "
" if (titles) { @@ -385,7 +462,7 @@ function createFilteredHorizontalTable (badges, field, value, titles, links) { if (value == null && field == 'pattern') { tableText += "" + rowTitle(field, r) + '' } else if (value == null) { - tableText += "" + rowTitle(field, r) + '' + tableText += "" + rowTitle(field, r) + '' } tableText += '' @@ -404,6 +481,9 @@ function processBadgesLegacy (badges, options) { const links = options.get("links") var htmlText = "" + if (options.get('show_dashboard_tabs') === true) { + htmlText += renderTabs('classic') + } htmlText += renderSetButtons(options.get('sets')) if (filter_field === 'date') { @@ -424,9 +504,17 @@ function processBadgesLegacy (badges, options) { htmlText += createFilteredHorizontalTable(badges, filter_field, null, true, links) } } else { + // Classic home: same long-scroll layout as pre-redesign CI page — all groupings at once badges.sort(function (a, b) { return -1 * a.date.localeCompare(b.date) }) badges.sort(patternVertSort) + htmlText += createFilteredHorizontalTable(badges, 'date', null, true, links) htmlText += createFilteredHorizontalTable(badges, 'pattern', null, true, links) + htmlText += createFilteredHorizontalTable(badges, 'platform', null, true, links) + htmlText += createFilteredHorizontalTable(badges, 'version', null, true, links) + var setsVal = options.get('sets') || 'GA' + if (String(setsVal).includes('all') || String(setsVal).includes('early')) { + htmlText += createFilteredHorizontalTable(badges, 'operator', null, true, links) + } } document.getElementById(options.get('target')).innerHTML = htmlText @@ -509,6 +597,7 @@ function getLatestPerPatternPlatform (badges) { function getCurrentTab () { var params = new URLSearchParams(window.location.search) + if (params.get('view') === 'classic') return 'classic' if (params.get('date') != null) return 'history' if (params.get('pattern') != null) { var val = params.get('pattern') @@ -521,9 +610,33 @@ function getCurrentTab () { } function buildTabUrl (paramKey) { - var base = window.location.pathname - if (!paramKey) return base - return base + '?' + paramKey + '=all' + var u = new URL(window.location.href) + ;['pattern', 'platform', 'version', 'date', 'view'].forEach(function (k) { + u.searchParams.delete(k) + }) + if (paramKey != null) { + u.searchParams.set(paramKey, 'all') + } + var qs = u.searchParams.toString() + return u.pathname + (qs ? '?' + qs : '') +} + +function buildPatternDetailHref (pattern) { + var u = new URL(window.location.href) + u.searchParams.delete('view') + u.searchParams.set('pattern', pattern) + var qs = u.searchParams.toString() + return u.pathname + (qs ? '?' + qs : '') +} + +function buildClassicTabUrl () { + var u = new URL(window.location.href) + ;['pattern', 'platform', 'version', 'date', 'sort'].forEach(function (k) { + u.searchParams.delete(k) + }) + u.searchParams.set('view', 'classic') + var qs = u.searchParams.toString() + return u.pathname + (qs ? '?' + qs : '') } function renderTabs (activeTab) { @@ -531,7 +644,8 @@ function renderTabs (activeTab) { { id: 'overview', label: 'Overview', href: buildTabUrl(null) }, { id: 'infrastructure', label: 'By Platform', href: buildTabUrl('platform') }, { id: 'version', label: 'By Version', href: buildTabUrl('version') }, - { id: 'history', label: 'History', href: buildTabUrl('date') } + { id: 'history', label: 'History', href: buildTabUrl('date') }, + { id: 'classic', label: 'Classic', href: buildClassicTabUrl() } ] var html = '
' @@ -545,7 +659,7 @@ function renderTabs (activeTab) { } function renderSortControl (currentSort) { - var html = '
' + var html = '
' html += '' html += '' + CI_TIME_RANGE_OPTIONS.forEach(function (opt) { + html += '' + }) + html += '' + html += '
' + return html +} + +function renderDashboardFiltersRow (rangeKey, currentSort, showSort) { + var html = '
' + html += '
' + if (showSort) { + html += renderSortControl(currentSort) + } + html += renderTimeRangeControl(rangeKey) + html += '
' + return html +} + +function renderOverviewLegendRow (rangeKey) { + var legendWindow = timeRangeLegendPhrase(rangeKey) + var legendBody = + 'Cards show the latest test per platform for the selected time range. Status bars show ' + + legendWindow + + ', oldest to newest. ' + + ' Passed ' + + ' Infra issue ' + + ' Test failure' + var html = '
' + html += '
' + legendBody + '
' + html += renderTimeRangeControl(rangeKey) + html += '
' + return html +} + function renderDashboardTableHeader () { return '' + '' + @@ -594,7 +747,35 @@ function renderDashboardTableRow (badge) { } function updateRowStatus (badgeKey) { - var json_obj = JSON.parse(this.responseText) + var json_obj = parseBadgeJsonResponseText(this.responseText) + if (json_obj == null) { + var rowIdFail = sanitizeId(badgeKey) + var statusElFail = document.getElementById('ci-status-' + rowIdFail) + if (statusElFail) { + statusElFail.textContent = '' + var dotEl = document.createElement('span') + dotEl.className = 'ci-status-dot unavailable' + statusElFail.appendChild(dotEl) + var failUrl = typeof this.responseURL === 'string' && this.responseURL.length > 0 ? this.responseURL : '' + if (failUrl) { + var linkEl = document.createElement('a') + linkEl.href = failUrl + linkEl.className = 'ci-status-text unavailable ci-status-unavailable-link' + linkEl.target = '_blank' + linkEl.rel = 'noopener noreferrer' + linkEl.textContent = 'Unavailable' + linkEl.setAttribute('aria-label', 'Open badge URL in a new tab') + statusElFail.appendChild(linkEl) + } else { + var spanEl = document.createElement('span') + spanEl.className = 'ci-status-text unavailable' + spanEl.textContent = 'Unavailable' + statusElFail.appendChild(spanEl) + } + } + console.warn('Row badge JSON parse failed:', badgeKey) + return + } var color = json_obj.color || 'green' var branch = json_obj.patternBranch || '' var rowId = sanitizeId(badgeKey) @@ -726,6 +907,37 @@ function filterRecentBadges (badges, months) { return badges.filter(function (b) { return b.date >= cutoffStr }) } +var CI_TIME_RANGE_OPTIONS = [ + { id: '3m', label: 'Last 3 months' }, + { id: '6m', label: 'Last 6 months' }, + { id: '1y', label: 'Last year' }, + { id: 'all', label: 'All' } +] + +function getTimeRangeFromParams (params) { + var r = params.get('range') + if (r === '6m' || r === '1y' || r === 'all') return r + return '3m' +} + +function monthsForTimeRange (rangeKey) { + if (rangeKey === '6m') return 6 + if (rangeKey === '1y') return 12 + return 3 +} + +function applyTimeRangeToBadges (badges, rangeKey) { + if (rangeKey === 'all') return badges.slice() + return filterRecentBadges(badges, monthsForTimeRange(rangeKey)) +} + +function timeRangeLegendPhrase (rangeKey) { + if (rangeKey === 'all') return 'all recorded tests in this view' + if (rangeKey === '6m') return 'tests from the last 6 months' + if (rangeKey === '1y') return 'tests from the last year' + return 'tests from the last 3 months' +} + function getLatestBadgePerCombo (badges) { var seen = {} var result = [] @@ -744,10 +956,12 @@ function computeCardHealth (tracker) { var colors = Object.values(tracker.platforms) var hasRed = colors.indexOf('red') !== -1 var hasYellow = colors.indexOf('yellow') !== -1 + var hasUnavailable = colors.indexOf('unavailable') !== -1 - if (!hasRed && !hasYellow) return 'green' + if (hasRed) return 'has-failures' + if (hasUnavailable) return 'unknown-status' if (!hasRed && hasYellow) return 'green-with-infra' - return 'has-failures' + return 'green' } function cardHealthLabel (tracker) { @@ -756,6 +970,10 @@ function cardHealthLabel (tracker) { var greenCount = colors.filter(function (c) { return c === 'green' }).length var yellowCount = colors.filter(function (c) { return c === 'yellow' }).length var redCount = colors.filter(function (c) { return c === 'red' }).length + var unavailableCount = colors.filter(function (c) { return c === 'unavailable' }).length + + if (unavailableCount === total) return 'Status unavailable' + if (unavailableCount > 0) return 'Some platform status unavailable' if (greenCount === total) return 'Passing' if (redCount === 0 && yellowCount > 0) { @@ -775,6 +993,9 @@ function cardHealthIcon (health) { if (health === 'has-failures') { return '' } + if (health === 'unknown-status') { + return '' + } return '' } @@ -851,8 +1072,8 @@ function updateOverallStats () { } function cardStatusCallback (badgeKey, pattern, platform, comboKey) { - var json_obj = JSON.parse(this.responseText) - var color = json_obj.color || 'green' + var json_obj = parseBadgeJsonResponseText(this.responseText) + var color = json_obj != null ? (json_obj.color || 'green') : 'unavailable' if (_cardTracker[pattern]) { _cardTracker[pattern].tests[comboKey] = color @@ -873,7 +1094,7 @@ function renderPatternCard (pattern, platformBadges, comboBadges) { var patternName = stringForKey(pattern) var latestDate = platformBadges.length > 0 ? platformBadges[0].date : '' - var html = '' + var html = '' html += '
' html += '' + cardHealthIcon('loading') + '' @@ -914,8 +1135,7 @@ function renderPatternCards (badges) { _cardTracker = {} - var html = '
Cards show the latest test per platform. Status bars show all tests from the last 3 months, oldest to newest. Passed Infra issue Test failure
' - html += '
Loading test results...
' + var html = '
Loading test results...
' html += '
' @@ -925,7 +1145,6 @@ function renderPatternCards (badges) { var patternBadges = groups[pattern] var latestPerPlatform = getLatestBadgePerPlatform(patternBadges) var latestPerCombo = getLatestBadgePerCombo(patternBadges) - var recentCombos = getLatestBadgePerCombo(filterRecentBadges(patternBadges, 3)) _cardTracker[pattern] = { total: latestPerPlatform.length, @@ -937,11 +1156,11 @@ function renderPatternCards (badges) { recentComboKeys: {} } - recentCombos.forEach(function (b) { + latestPerCombo.forEach(function (b) { _cardTracker[pattern].recentComboKeys[sanitizeId(b.platform) + '-' + sanitizeId(b.version)] = true }) - html += renderPatternCard(pattern, latestPerPlatform, recentCombos) + html += renderPatternCard(pattern, latestPerPlatform, latestPerCombo) latestPerCombo.forEach(function (b) { var comboKey = sanitizeId(b.platform) + '-' + sanitizeId(b.version) @@ -990,6 +1209,37 @@ function handleSort (value) { window.location.href = url.toString() } +function handleTimeRange (value) { + var url = new URL(window.location.href) + if (value === '3m') { + url.searchParams.delete('range') + } else { + url.searchParams.set('range', value) + } + window.location.href = url.toString() +} + +function syncCiSidebarNavWithUrl () { + var list = document.querySelector('.ci-sidebar-nav') + if (!list) return + var cur = new URL(window.location.href) + list.querySelectorAll('a[href]').forEach(function (a) { + var linkUrl = new URL(a.getAttribute('href'), window.location.origin) + ;['range', 'sort'].forEach(function (k) { + var v = cur.searchParams.get(k) + if (k === 'range') { + if (v == null || v === '' || v === '3m') linkUrl.searchParams.delete('range') + else linkUrl.searchParams.set('range', v) + } else if (k === 'sort') { + if (v == null || v === '' || v === 'latest') linkUrl.searchParams.delete('sort') + else linkUrl.searchParams.set('sort', v) + } + }) + var qs = linkUrl.searchParams.toString() + a.setAttribute('href', linkUrl.pathname + (qs ? '?' + qs : '')) + }) +} + function renderDashboard (badges, options) { var filter_field = options.get('filter_field') var filter_value = options.get('filter_value') @@ -998,6 +1248,9 @@ function renderDashboard (badges, options) { var currentTab = getCurrentTab() var params = new URLSearchParams(window.location.search) var currentSort = params.get('sort') || 'latest' + var rangeKey = getTimeRangeFromParams(params) + + badges = applyTimeRangeToBadges(badges, rangeKey) if (filter_value != null && filter_value !== 'all') { badges = filterBadges(badges, filter_field, filter_value) @@ -1021,22 +1274,34 @@ function renderDashboard (badges, options) { var html = '' - // Show tabs for top-level views (not for pattern detail drill-down) html += renderTabs(currentTab) + var showSortInFilters = + currentTab === 'pattern-detail' || + currentTab === 'infrastructure' || + currentTab === 'version' || + currentTab === 'history' || + currentTab === 'patterns' + + // Time range (+ sort where applicable) directly under tabs so drill-down pages match overview visibility if (currentTab === 'overview') { - // Card-based overview — no sort control - html += renderPatternCards(badges) - } else if (currentTab === 'pattern-detail') { - // Drill-down from a card: show back link + detail table + status key + html += renderOverviewLegendRow(rangeKey) + } else { + html += renderDashboardFiltersRow(rangeKey, currentSort, showSortInFilters) + } + + if (currentTab === 'pattern-detail') { html += '← Back to overview' html += '

' + stringForKey(filter_value) + '

' + } + + if (currentTab === 'overview') { + html += renderPatternCards(badges) + } else if (currentTab === 'pattern-detail') { html += renderStatusKey() - html += renderSortControl(currentSort) html += renderDashboardTableWithBadges(badges) } else if (currentTab === 'infrastructure') { html += renderStatusKey() - html += renderSortControl(currentSort) if (filter_value != null && filter_value !== 'all') { html += renderDashboardTableWithBadges(badges) } else { @@ -1044,7 +1309,6 @@ function renderDashboard (badges, options) { } } else if (currentTab === 'version') { html += renderStatusKey() - html += renderSortControl(currentSort) if (filter_value != null && filter_value !== 'all') { html += renderDashboardTableWithBadges(badges) } else { @@ -1052,7 +1316,6 @@ function renderDashboard (badges, options) { } } else if (currentTab === 'history') { html += renderStatusKey() - html += renderSortControl(currentSort) if (filter_value != null && filter_value !== 'all') { var dateBadges = filterBadges(badges, 'date', filter_value) html += renderDashboardTableWithBadges(dateBadges) @@ -1060,8 +1323,6 @@ function renderDashboard (badges, options) { html += renderDashboardTableWithBadges(badges) } } else if (currentTab === 'patterns') { - // Legacy route: ?pattern=all - html += renderSortControl(currentSort) html += renderGroupedTables(badges, 'pattern') } @@ -1070,6 +1331,7 @@ function renderDashboard (badges, options) { } document.getElementById(target).innerHTML = html + syncCiSidebarNavWithUrl() } // ============================================ @@ -1166,6 +1428,18 @@ function getBucketOptions (input) { } } } + + if (urlParams.get('view') === 'classic') { + options.set('disable_buttons', true) + options.set('show_dashboard_tabs', true) + var classicPf = options.get('filter_field') + var classicPv = options.get('filter_value') + if (classicPf === 'pattern' && classicPv != null && classicPv !== 'all') { + options.delete('filter_field') + options.delete('filter_value') + } + } + return options }
Status