Copilot commented on code in PR #62261: URL: https://github.com/apache/airflow/pull/62261#discussion_r2835330045
########## registry/.eleventy.js: ########## @@ -0,0 +1,108 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +module.exports = function(eleventyConfig) { + // Copy static assets + eleventyConfig.addPassthroughCopy("src/assets"); + eleventyConfig.addPassthroughCopy("src/css"); + eleventyConfig.addPassthroughCopy("src/js"); + + // Copy public directory contents to root of output + eleventyConfig.addPassthroughCopy({ "public": "/" }); + + // Watch CSS and JS for changes + eleventyConfig.addWatchTarget("src/css/"); + eleventyConfig.addWatchTarget("src/js/"); + + // Add filters + eleventyConfig.addFilter("slice", (array, start, end) => { + return array.slice(start, end); + }); + + eleventyConfig.addFilter("formatDownloads", (num) => { + if (!num) return "0"; + + // For billions + if (num >= 1_000_000_000) { + const billions = num / 1_000_000_000; + return billions >= 10 ? `${Math.round(billions)}B` : `${billions.toFixed(1)}B`; + } + + // For millions + if (num >= 1_000_000) { + const millions = num / 1_000_000; + return millions >= 10 ? `${Math.round(millions)}M` : `${millions.toFixed(1)}M`; + } + + // For thousands + if (num >= 1_000) { + const thousands = num / 1_000; + return thousands >= 10 ? `${Math.round(thousands)}K` : `${thousands.toFixed(1)}K`; + } + + return num.toString(); + }); + + eleventyConfig.addFilter("formatLargeNumber", (num) => { + if (!num) return "0"; + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`; + if (num >= 1_000_000) return `${Math.round(num / 1_000_000)}M`; + if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`; + return num.toLocaleString(); + }); Review Comment: The `formatDownloads` and `formatLargeNumber` filters (lines 38-68) have slightly different rounding logic for the same thresholds. For example, `formatDownloads` rounds billions differently than `formatLargeNumber` for numbers >= 10B. This inconsistency could confuse users seeing different formatting in different contexts. Consider harmonizing the rounding logic. ########## registry/src/js/search.js: ########## @@ -0,0 +1,310 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +(function() { + let pagefind = null; + let currentFilter = 'all'; + let selectedIndex = 0; + let currentResults = []; + let searchId = 0; + + const typeLabels = { + operator: 'Operator', + hook: 'Hook', + sensor: 'Sensor', + trigger: 'Trigger', + transfer: 'Transfer', + bundle: 'Bundle', + notifier: 'Notifier', + secret: 'Secrets Backend', + logging: 'Log Handler', + executor: 'Executor', + decorator: 'Decorator', + }; + + function escapeHtml(str) { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + const modal = document.getElementById('search-modal'); + const input = document.getElementById('search-input'); + const resultsContainer = document.getElementById('search-results'); + const closeButton = document.getElementById('search-close'); + const filterTabs = document.querySelectorAll('#search-modal nav button'); + + async function initPagefind() { + if (pagefind === null) { + const base = window.__REGISTRY_BASE__ || '/'; + pagefind = await import(base + 'pagefind/pagefind.js'); + } + return pagefind; + } + + async function performSearch(query) { + const pf = await initPagefind(); + + if (!query.trim()) { + return []; + } + + let search; + if (currentFilter !== 'all') { + search = await pf.search(query, { filters: { type: [currentFilter] } }); + } else { + search = await pf.search(query); + } + + const results = await Promise.all(search.results.map(r => r.data())); + + return results; + } + + function renderResults(results, resetSelection) { + currentResults = results; + if (resetSelection) { + selectedIndex = 0; + } + + if (results.length === 0) { + resultsContainer.innerHTML = ` + <div class="search-empty"> + <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + <p>No results found</p> + </div> + `; + updateCountsFromResults([]); + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = 'No results found'; + } + return; + } + + const html = results.map((result, index) => { + const type = result.meta.type || 'provider'; + const name = result.meta.className || result.meta.name || result.meta.title || 'Unknown'; + const providerName = result.meta.providerName || ''; + const description = result.meta.description || result.excerpt; + const moduleType = result.meta.moduleType || ''; + const icon = type === 'provider' ? 'P' : (moduleType ? moduleType[0].toUpperCase() : 'M'); + const resultType = type === 'provider' ? 'provider' : moduleType; + + return ` + <a href="${escapeHtml(result.url)}" class="${escapeHtml(resultType)}${index === selectedIndex ? ' selected' : ''}" data-index="${index}"> + <span>${icon}</span> + <div> + <div> + ${escapeHtml(name)} + ${moduleType ? `<span class="badge ${escapeHtml(moduleType)}">${escapeHtml(typeLabels[moduleType] || moduleType)}</span>` : ''} + </div> + <div> + ${providerName ? `<span><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> ${escapeHtml(providerName)}</span>` : ''} + ${description ? `<span>${escapeHtml(description)}</span>` : ''} + </div> + </div> + ${index === selectedIndex ? '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>' : ''} + </a> + `; + }).join(''); + + resultsContainer.innerHTML = html; + updateCountsFromResults(results); + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = results.length + ' result' + (results.length !== 1 ? 's' : '') + ' found'; + } + + const selected = resultsContainer.querySelector('a.selected'); + if (selected) { + selected.scrollIntoView({ block: 'nearest' }); + } + } Review Comment: The search results in search.js lack proper ARIA announcements for keyboard navigation state changes. When navigating results with arrow keys, screen reader users should be notified which result is currently selected. Consider adding `aria-activedescendant` to the results container or updating a live region with the current selection. ########## registry/src/js/search.js: ########## @@ -0,0 +1,310 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +(function() { + let pagefind = null; + let currentFilter = 'all'; + let selectedIndex = 0; + let currentResults = []; + let searchId = 0; + + const typeLabels = { + operator: 'Operator', + hook: 'Hook', + sensor: 'Sensor', + trigger: 'Trigger', + transfer: 'Transfer', + bundle: 'Bundle', + notifier: 'Notifier', + secret: 'Secrets Backend', + logging: 'Log Handler', + executor: 'Executor', + decorator: 'Decorator', + }; + + function escapeHtml(str) { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + const modal = document.getElementById('search-modal'); + const input = document.getElementById('search-input'); + const resultsContainer = document.getElementById('search-results'); + const closeButton = document.getElementById('search-close'); + const filterTabs = document.querySelectorAll('#search-modal nav button'); + + async function initPagefind() { + if (pagefind === null) { + const base = window.__REGISTRY_BASE__ || '/'; + pagefind = await import(base + 'pagefind/pagefind.js'); + } + return pagefind; + } + + async function performSearch(query) { + const pf = await initPagefind(); + + if (!query.trim()) { + return []; + } + + let search; + if (currentFilter !== 'all') { + search = await pf.search(query, { filters: { type: [currentFilter] } }); + } else { + search = await pf.search(query); + } + + const results = await Promise.all(search.results.map(r => r.data())); + + return results; + } + + function renderResults(results, resetSelection) { + currentResults = results; + if (resetSelection) { + selectedIndex = 0; + } + + if (results.length === 0) { + resultsContainer.innerHTML = ` + <div class="search-empty"> + <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + <p>No results found</p> + </div> + `; + updateCountsFromResults([]); + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = 'No results found'; + } + return; + } + + const html = results.map((result, index) => { + const type = result.meta.type || 'provider'; + const name = result.meta.className || result.meta.name || result.meta.title || 'Unknown'; + const providerName = result.meta.providerName || ''; + const description = result.meta.description || result.excerpt; + const moduleType = result.meta.moduleType || ''; + const icon = type === 'provider' ? 'P' : (moduleType ? moduleType[0].toUpperCase() : 'M'); + const resultType = type === 'provider' ? 'provider' : moduleType; + + return ` + <a href="${escapeHtml(result.url)}" class="${escapeHtml(resultType)}${index === selectedIndex ? ' selected' : ''}" data-index="${index}"> + <span>${icon}</span> + <div> + <div> + ${escapeHtml(name)} + ${moduleType ? `<span class="badge ${escapeHtml(moduleType)}">${escapeHtml(typeLabels[moduleType] || moduleType)}</span>` : ''} + </div> + <div> + ${providerName ? `<span><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> ${escapeHtml(providerName)}</span>` : ''} + ${description ? `<span>${escapeHtml(description)}</span>` : ''} + </div> + </div> + ${index === selectedIndex ? '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>' : ''} + </a> + `; + }).join(''); + + resultsContainer.innerHTML = html; + updateCountsFromResults(results); + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = results.length + ' result' + (results.length !== 1 ? 's' : '') + ' found'; + } + + const selected = resultsContainer.querySelector('a.selected'); + if (selected) { + selected.scrollIntoView({ block: 'nearest' }); + } + } + + function updateCountsFromResults(results) { + let providerCount = 0; + let moduleCount = 0; + + results.forEach(result => { + const type = result.meta.type || 'provider'; + if (type === 'provider') { + providerCount++; + } else { + moduleCount++; + } + }); + + document.getElementById('provider-count').textContent = providerCount; + document.getElementById('module-count').textContent = moduleCount; + } + + async function initializeCounts() { + const pf = await initPagefind(); + const providerSearch = await pf.search('', { filters: { type: ['provider'] } }); + const moduleSearch = await pf.search('', { filters: { type: ['module'] } }); + document.getElementById('provider-count').textContent = providerSearch.results.length; + document.getElementById('module-count').textContent = moduleSearch.results.length; + } + + input.addEventListener('input', async (e) => { + const query = e.target.value; + const thisSearchId = ++searchId; + + if (!query.trim()) { + resultsContainer.innerHTML = ` + <div class="search-empty"> + <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + <p>Start typing to search...</p> + </div> + `; + currentResults = []; + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = ''; + } + return; + } + + const pf = await initPagefind(); + + let search; + if (currentFilter !== 'all') { + search = await pf.debouncedSearch(query, { filters: { type: [currentFilter] } }, 150); + } else { + search = await pf.debouncedSearch(query, {}, 150); + } + + if (search !== null && thisSearchId === searchId) { + const results = await Promise.all(search.results.map(r => r.data())); + + if (thisSearchId === searchId) { + renderResults(results, true); + } + } + }); Review Comment: The `debouncedSearch` method call with a debounce delay of 150ms and the separate `input` event listener with debounce are redundant. The Pagefind `debouncedSearch` already handles debouncing internally. Consider removing the manual debounce timer `searchId` increment and just rely on Pagefind's built-in debouncing for cleaner code. ########## registry/src/js/search.js: ########## @@ -0,0 +1,310 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +(function() { + let pagefind = null; + let currentFilter = 'all'; + let selectedIndex = 0; + let currentResults = []; + let searchId = 0; + + const typeLabels = { + operator: 'Operator', + hook: 'Hook', + sensor: 'Sensor', + trigger: 'Trigger', + transfer: 'Transfer', + bundle: 'Bundle', + notifier: 'Notifier', + secret: 'Secrets Backend', + logging: 'Log Handler', + executor: 'Executor', + decorator: 'Decorator', + }; + + function escapeHtml(str) { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + const modal = document.getElementById('search-modal'); + const input = document.getElementById('search-input'); + const resultsContainer = document.getElementById('search-results'); + const closeButton = document.getElementById('search-close'); + const filterTabs = document.querySelectorAll('#search-modal nav button'); + + async function initPagefind() { + if (pagefind === null) { + const base = window.__REGISTRY_BASE__ || '/'; + pagefind = await import(base + 'pagefind/pagefind.js'); + } + return pagefind; + } + + async function performSearch(query) { + const pf = await initPagefind(); + + if (!query.trim()) { + return []; + } + + let search; + if (currentFilter !== 'all') { + search = await pf.search(query, { filters: { type: [currentFilter] } }); + } else { + search = await pf.search(query); + } + + const results = await Promise.all(search.results.map(r => r.data())); + + return results; + } + + function renderResults(results, resetSelection) { + currentResults = results; + if (resetSelection) { + selectedIndex = 0; + } + + if (results.length === 0) { + resultsContainer.innerHTML = ` + <div class="search-empty"> + <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + <p>No results found</p> + </div> + `; + updateCountsFromResults([]); + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = 'No results found'; + } + return; + } + + const html = results.map((result, index) => { + const type = result.meta.type || 'provider'; + const name = result.meta.className || result.meta.name || result.meta.title || 'Unknown'; + const providerName = result.meta.providerName || ''; + const description = result.meta.description || result.excerpt; + const moduleType = result.meta.moduleType || ''; + const icon = type === 'provider' ? 'P' : (moduleType ? moduleType[0].toUpperCase() : 'M'); + const resultType = type === 'provider' ? 'provider' : moduleType; + + return ` + <a href="${escapeHtml(result.url)}" class="${escapeHtml(resultType)}${index === selectedIndex ? ' selected' : ''}" data-index="${index}"> + <span>${icon}</span> + <div> + <div> + ${escapeHtml(name)} + ${moduleType ? `<span class="badge ${escapeHtml(moduleType)}">${escapeHtml(typeLabels[moduleType] || moduleType)}</span>` : ''} + </div> + <div> + ${providerName ? `<span><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> ${escapeHtml(providerName)}</span>` : ''} + ${description ? `<span>${escapeHtml(description)}</span>` : ''} + </div> + </div> + ${index === selectedIndex ? '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>' : ''} + </a> + `; + }).join(''); + + resultsContainer.innerHTML = html; + updateCountsFromResults(results); + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = results.length + ' result' + (results.length !== 1 ? 's' : '') + ' found'; + } + + const selected = resultsContainer.querySelector('a.selected'); + if (selected) { + selected.scrollIntoView({ block: 'nearest' }); + } + } + + function updateCountsFromResults(results) { + let providerCount = 0; + let moduleCount = 0; + + results.forEach(result => { + const type = result.meta.type || 'provider'; + if (type === 'provider') { + providerCount++; + } else { + moduleCount++; + } + }); + + document.getElementById('provider-count').textContent = providerCount; + document.getElementById('module-count').textContent = moduleCount; + } + + async function initializeCounts() { + const pf = await initPagefind(); + const providerSearch = await pf.search('', { filters: { type: ['provider'] } }); + const moduleSearch = await pf.search('', { filters: { type: ['module'] } }); + document.getElementById('provider-count').textContent = providerSearch.results.length; + document.getElementById('module-count').textContent = moduleSearch.results.length; + } + + input.addEventListener('input', async (e) => { + const query = e.target.value; + const thisSearchId = ++searchId; + + if (!query.trim()) { + resultsContainer.innerHTML = ` + <div class="search-empty"> + <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + <p>Start typing to search...</p> + </div> + `; + currentResults = []; + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = ''; + } + return; + } + + const pf = await initPagefind(); + + let search; + if (currentFilter !== 'all') { + search = await pf.debouncedSearch(query, { filters: { type: [currentFilter] } }, 150); + } else { + search = await pf.debouncedSearch(query, {}, 150); + } + + if (search !== null && thisSearchId === searchId) { + const results = await Promise.all(search.results.map(r => r.data())); + + if (thisSearchId === searchId) { + renderResults(results, true); + } + } + }); + + filterTabs.forEach(tab => { + tab.addEventListener('click', async () => { + filterTabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + currentFilter = tab.dataset.filter; + + const query = input.value; + if (query.trim()) { + const results = await performSearch(query); + renderResults(results, true); + } + }); + }); + + function openModal() { + modal.classList.add('active'); + input.value = ''; + input.focus(); + currentResults = []; + selectedIndex = 0; + resultsContainer.innerHTML = ` + <div class="search-empty"> + <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + <p>Start typing to search...</p> + </div> + `; + + initializeCounts(); + } + + function closeModal() { + modal.classList.remove('active'); + input.value = ''; + currentResults = []; + selectedIndex = 0; + } + + document.addEventListener('keydown', (e) => { + if (!modal.classList.contains('active')) return; + + if (e.key === 'Escape') { + e.preventDefault(); + closeModal(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + selectedIndex = Math.min(selectedIndex + 1, currentResults.length - 1); + renderResults(currentResults); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + selectedIndex = Math.max(selectedIndex - 1, 0); + renderResults(currentResults); + } else if (e.key === 'Tab') { + const focusable = modal.querySelectorAll('input, button, a[href]'); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } else if (e.key === 'Enter' && currentResults.length > 0) { + e.preventDefault(); + const selected = currentResults[selectedIndex]; + if (selected) { + closeModal(); + window.location.href = selected.url; + } + } + }); Review Comment: The keyboard navigation in the search modal uses `ArrowDown` and `ArrowUp` to navigate results, but the code at line 246-283 handles these key events. However, the code doesn't prevent the default browser scroll behavior when navigating with arrow keys. Add `e.preventDefault()` when handling arrow keys to prevent the page from scrolling while navigating search results. ########## registry/src/js/connection-builder.js: ########## @@ -0,0 +1,417 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Connection Builder - chip grid with shared expansion panel +(function () { + var dataEl = document.getElementById("connections-data"); + if (!dataEl) return; + + var connectionTypes; + try { + connectionTypes = JSON.parse(dataEl.textContent || "[]"); + } catch (e) { + return; + } + if (!connectionTypes || !connectionTypes.length) return; + + // Index connection data by connection_type + var connDataMap = {}; + connectionTypes.forEach(function (ct) { + connDataMap[ct.connection_type] = ct; + }); + + var panel = document.getElementById("conn-builder-panel"); + var formContainer = document.getElementById("conn-builder-form"); + var titleEl = document.getElementById("conn-builder-title"); + var docsLink = document.getElementById("conn-builder-docs"); + var closeBtn = document.getElementById("conn-builder-close"); + var chips = document.querySelectorAll(".conn-chip"); + + if (!panel || !formContainer || !titleEl) return; + + var activeConnType = null; + var renderedForms = {}; // Cache rendered HTML per conn type + var debounceTimers = {}; + + // Chip click: select/deselect connection type + chips.forEach(function (chip) { + chip.addEventListener("click", function () { + var connType = chip.dataset.connType; + + // Toggle off if already active + if (activeConnType === connType) { + closePanel(); + return; + } + + // Deactivate previous chip + chips.forEach(function (c) { c.classList.remove("active"); }); + chip.classList.add("active"); + activeConnType = connType; + + // Set title + titleEl.textContent = connType; + + // Show/hide docs link (derive per-connection-type URL) + if (docsLink) { + var baseDocsUrl = chip.dataset.docsUrl; + if (baseDocsUrl) { + var perTypeUrl = baseDocsUrl.replace(/index\.html$/, connType + ".html"); + docsLink.href = perTypeUrl; + docsLink.hidden = false; + } else { + docsLink.hidden = true; + } + } + + // Render form (or restore cached) + var data = connDataMap[connType]; + if (!data) return; + + if (renderedForms[connType]) { + formContainer.innerHTML = renderedForms[connType]; + attachFormListeners(formContainer, connType, data); + } else { + renderConnectionForm(formContainer, connType, data); + renderedForms[connType] = formContainer.innerHTML; + } + + // Show panel + panel.hidden = false; + }); + }); + + // Close button + if (closeBtn) { + closeBtn.addEventListener("click", closePanel); + } + + function closePanel() { + // Save current form state before closing + if (activeConnType) { + renderedForms[activeConnType] = formContainer.innerHTML; + } + chips.forEach(function (c) { c.classList.remove("active"); }); + panel.hidden = true; + activeConnType = null; + } + + function renderConnectionForm(container, connType, data) { + var connIdDefault = connType.replace(/[^a-zA-Z0-9]/g, "_"); + var html = '<div class="conn-field">'; + html += '<label for="conn-id-' + connType + '">Connection ID</label>'; + html += + '<input type="text" id="conn-id-' + connType + + '" data-field="conn_id" value="' + escapeAttr(connIdDefault) + + '" placeholder="my_conn_id">'; + html += "</div>"; + + // Standard fields + var standardOrder = ["host", "port", "login", "password", "schema", "extra", "description"]; + var stdFields = data.standard_fields || {}; + standardOrder.forEach(function (key) { + var field = stdFields[key]; + if (!field || !field.visible) return; + html += renderField(connType, key, field, false); + }); + + // Custom fields + var customFields = data.custom_fields || {}; + Object.keys(customFields).forEach(function (key) { + html += renderField(connType, key, customFields[key], true); + }); + + // Export panel + html += renderExportPanel(connType); + + container.innerHTML = html; + attachFormListeners(container, connType, data); + updateExports(connType, container, data); + } + + function attachFormListeners(container, connType, data) { + // Input listeners for live export updates + container.querySelectorAll("input, textarea, select").forEach(function (input) { + input.addEventListener("input", function () { + debouncedUpdate(connType, container, data); + }); + input.addEventListener("change", function () { + debouncedUpdate(connType, container, data); + }); + }); + + // Tab switching + container.querySelectorAll(".conn-export-tab").forEach(function (tab) { + tab.addEventListener("click", function () { + container.querySelectorAll(".conn-export-tab").forEach(function (t) { + t.classList.remove("active"); + }); + tab.classList.add("active"); + var format = tab.dataset.format; + container.querySelectorAll(".conn-export-content").forEach(function (c) { + c.hidden = c.dataset.format !== format; + }); + }); + }); + + // Copy buttons + container.querySelectorAll(".conn-copy-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + var targetId = btn.dataset.copyTarget; + var target = document.getElementById(targetId); + if (!target || !navigator.clipboard) return; + navigator.clipboard.writeText(target.textContent || "").then( + function () { + var origHTML = btn.innerHTML; + btn.innerHTML = + '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" style="color:var(--color-green-400)"></path></svg>'; + setTimeout(function () { btn.innerHTML = origHTML; }, 2000); + }, + function () {} + ); + }); + }); + + // Update exports on attach (handles restored forms) + updateExports(connType, container, data); + } + + function renderField(connType, key, field, isCustom) { + var label = field.label || key; + var id = "conn-" + connType + "-" + key; + var isSensitive = + key === "password" || field.is_sensitive || field.format === "password"; + var fieldType = field.type || "string"; + var html = '<div class="conn-field">'; + html += '<label for="' + id + '">' + escapeHTML(label); + if (isSensitive) { + html += ' <span class="field-sensitive">sensitive</span>'; + } + html += "</label>"; + + if (field.enum && field.enum.length) { + html += '<select id="' + id + '" data-field="' + key + '"'; + if (isCustom) html += ' data-custom="true"'; + html += ">"; + html += '<option value="">-- Select --</option>'; + field.enum.forEach(function (opt) { + var selected = field.default === opt ? " selected" : ""; + html += '<option value="' + escapeAttr(opt) + '"' + selected + ">" + escapeHTML(opt) + "</option>"; + }); + html += "</select>"; + } else if (fieldType === "boolean") { + var checked = field.default === true || field.default === "true" ? " checked" : ""; + html += '<label class="conn-checkbox"><input type="checkbox" id="' + id + '" data-field="' + key + '"'; + if (isCustom) html += ' data-custom="true"'; + html += ' data-type="boolean"' + checked + ">"; + html += " " + escapeHTML(label) + "</label>"; + } else if (key === "extra" || fieldType === "object") { + html += '<textarea id="' + id + '" data-field="' + key + '" rows="3"'; + if (isCustom) html += ' data-custom="true"'; + if (field.placeholder) html += ' placeholder="' + escapeAttr(field.placeholder) + '"'; + html += "></textarea>"; + } else if (fieldType === "integer" || fieldType === "number") { + html += '<input type="number" id="' + id + '" data-field="' + key + '"'; + if (isCustom) html += ' data-custom="true"'; + if (field.default != null) html += ' value="' + escapeAttr(String(field.default)) + '"'; + if (field.minimum != null) html += ' min="' + escapeAttr(String(field.minimum)) + '"'; + if (field.placeholder) html += ' placeholder="' + escapeAttr(field.placeholder) + '"'; + html += ">"; + } else { + var inputType = isSensitive ? "password" : "text"; + html += '<input type="' + inputType + '" id="' + id + '" data-field="' + key + '"'; + if (isCustom) html += ' data-custom="true"'; + if (field.default != null && fieldType !== "boolean") html += ' value="' + escapeAttr(String(field.default)) + '"'; + if (field.placeholder) html += ' placeholder="' + escapeAttr(field.placeholder) + '"'; + html += ">"; + } + + if (field.description) { + html += '<span class="field-help">' + escapeHTML(field.description) + "</span>"; + } + html += "</div>"; + return html; + } + + function renderExportPanel(connType) { + var uid = connType.replace(/[^a-zA-Z0-9]/g, "_"); + var html = '<div class="conn-export">'; + html += '<div class="conn-export-tabs">'; + html += '<button class="conn-export-tab active" data-format="uri" type="button">URI</button>'; + html += '<button class="conn-export-tab" data-format="json" type="button">JSON</button>'; + html += '<button class="conn-export-tab" data-format="env" type="button">Env Var</button>'; + html += "</div>"; + + ["uri", "json", "env"].forEach(function (fmt) { + var hidden = fmt !== "uri" ? " hidden" : ""; + html += '<div class="conn-export-content" data-format="' + fmt + '"' + hidden + ">"; + html += '<div class="conn-export-output">'; + html += '<pre id="conn-export-' + uid + "-" + fmt + '"></pre>'; + html += '<button class="conn-copy-btn" data-copy-target="conn-export-' + uid + "-" + fmt + '" type="button" title="Copy">'; + html += '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>'; + html += "</button></div></div>"; + }); + + html += "</div>"; + return html; + } + + function debouncedUpdate(connType, container, data) { + clearTimeout(debounceTimers[connType]); + debounceTimers[connType] = setTimeout(function () { + updateExports(connType, container, data); + }, 100); + } + + function collectFieldValues(container) { + var values = { standard: {}, custom: {}, conn_id: "" }; + container.querySelectorAll("[data-field]").forEach(function (el) { + var key = el.dataset.field; + var val = el.type === "checkbox" ? el.checked : el.value; + if (key === "conn_id") { + values.conn_id = val; + } else if (el.dataset.custom === "true") { + values.custom[key] = val; + } else { + values.standard[key] = val; + } + }); + return values; + } + + function updateExports(connType, container, data) { + var values = collectFieldValues(container); + var uid = connType.replace(/[^a-zA-Z0-9]/g, "_"); + + var uriEl = document.getElementById("conn-export-" + uid + "-uri"); + var jsonEl = document.getElementById("conn-export-" + uid + "-json"); + var envEl = document.getElementById("conn-export-" + uid + "-env"); + + if (uriEl) uriEl.textContent = generateURI(connType, values); + if (jsonEl) jsonEl.textContent = generateJSON(connType, values); + if (envEl) envEl.textContent = generateEnvVar(connType, values); + } + + function generateURI(connType, values) { + var s = values.standard; + var login = s.login || ""; + var password = s.password || ""; + var host = s.host || ""; + var port = s.port || ""; + var schema = s.schema || ""; + var extra = s.extra || ""; + + var extraObj = {}; + if (extra) { + try { extraObj = JSON.parse(extra); } + catch (e) { extraObj = null; } + } + + var customEntries = Object.keys(values.custom).filter(function (k) { + var v = values.custom[k]; + return v !== "" && v !== false && v != null; + }); + + var uri = connType + "://"; + + if (login || password) { + if (login) uri += encodeURIComponent(login); + if (password) uri += ":" + encodeURIComponent(password); + uri += "@"; + } + + if (host) uri += host; + if (port) uri += ":" + port; + + if (schema) uri += "/" + encodeURIComponent(schema); + else if (extra || customEntries.length) uri += "/"; + + var params = []; + if (extraObj && typeof extraObj === "object") { + Object.keys(extraObj).forEach(function (k) { + var v = extraObj[k]; + if (typeof v === "object") { + params.push(encodeURIComponent(k) + "=" + encodeURIComponent(JSON.stringify(v))); + } else { + params.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(v))); + } + }); + } else if (extra) { + params.push("__extra__=" + encodeURIComponent(extra)); + } + + customEntries.forEach(function (k) { + params.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(values.custom[k]))); + }); + + if (params.length) uri += "?" + params.join("&"); + return uri; + } + + function generateJSON(connType, values) { + var obj = { conn_type: connType }; + var s = values.standard; + + if (s.host) obj.host = s.host; + if (s.port) obj.port = isNaN(Number(s.port)) ? s.port : Number(s.port); + if (s.login) obj.login = s.login; + if (s.password) obj.password = s.password; + if (s.schema) obj.schema = s.schema; + if (s.description) obj.description = s.description; + + var extraObj = {}; + var hasExtra = false; + if (s.extra) { + try { extraObj = JSON.parse(s.extra); hasExtra = true; } + catch (e) { obj.extra = s.extra; hasExtra = true; } + } + + Object.keys(values.custom).forEach(function (k) { + var v = values.custom[k]; + if (v === "" || v == null) return; + if (v === false) { extraObj[k] = false; } + else if (v === true) { extraObj[k] = true; } + else if (!isNaN(Number(v)) && v !== "") { extraObj[k] = Number(v); } + else { extraObj[k] = v; } + hasExtra = true; + }); + + if (hasExtra && typeof obj.extra !== "string") { + if (Object.keys(extraObj).length) obj.extra = extraObj; + } + + return JSON.stringify(obj, null, 2); + } Review Comment: In the `generateJSON` function around lines 367-400 (not fully shown), ensure that all user-provided field values are properly sanitized when constructing the JSON output. While JSON.stringify provides some protection, verify that custom field values don't introduce injection risks if the JSON is later evaluated unsafely. ########## .github/workflows/registry-build.yml: ########## @@ -0,0 +1,176 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +--- +name: Build & Publish Registry +on: # yamllint disable-line rule:truthy + workflow_dispatch: + inputs: + destination: + description: "Publish to live or staging S3 bucket" + required: true + type: choice + options: + - staging + - live + default: staging + workflow_call: + inputs: + destination: + description: "Publish to live or staging S3 bucket" + required: false + type: string + default: staging + secrets: + DOCS_AWS_ACCESS_KEY_ID: + required: true + DOCS_AWS_SECRET_ACCESS_KEY: + required: true + +permissions: + contents: read + +jobs: + build-and-publish-registry: + timeout-minutes: 30 + name: "Build & Publish Registry" + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + if: > + github.event_name == 'workflow_call' || + contains(fromJSON('[ + "ashb", + "bugraoz93", + "eladkal", + "ephraimbuddy", + "jedcunningham", + "jscheffl", + "kaxil", + "pierrejeambrun", + "shahar1", + "potiuk", + "utkarsharma2", + "vincbeck" + ]'), github.event.sender.login) Review Comment: The workflow allows manual triggers only by specific committers (lines 56-70), which is good. However, the `workflow_call` input (line 31-42) doesn't have the same committer restriction, which means any workflow that calls this one could potentially bypass the committer check. Verify that all workflows calling `registry-build.yml` have appropriate permissions. ########## dev/breeze/src/airflow_breeze/commands/registry_commands.py: ########## @@ -0,0 +1,72 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import sys +import uuid + +import click + +from airflow_breeze.commands.ci_image_commands import rebuild_or_pull_ci_image_if_needed +from airflow_breeze.commands.common_options import option_dry_run, option_python, option_verbose +from airflow_breeze.params.shell_params import ShellParams +from airflow_breeze.utils.ci_group import ci_group +from airflow_breeze.utils.click_utils import BreezeGroup +from airflow_breeze.utils.docker_command_utils import execute_command_in_shell, fix_ownership_using_docker + + [email protected](cls=BreezeGroup, name="registry", help="Tools for the Airflow Provider Registry") +def registry_group(): + pass + + +@registry_group.command( + name="extract-data", + help="Extract provider metadata, parameters, and connection types for the registry.", +) +@option_python +@option_verbose +@option_dry_run +def extract_data(python: str): + unique_project_name = f"breeze-registry-{uuid.uuid4().hex[:8]}" + + shell_params = ShellParams( + python=python, + project_name=unique_project_name, + quiet=True, + skip_environment_initialization=True, + extra_args=(), + ) + + rebuild_or_pull_ci_image_if_needed(command_params=shell_params) + + command = ( + "python dev/registry/extract_metadata.py && " + "python dev/registry/extract_parameters.py && " + "python dev/registry/extract_connections.py" + ) + + with ci_group("Extracting registry data"): + result = execute_command_in_shell( + shell_params=shell_params, + project_name=unique_project_name, + command=command, + preserve_backend=True, + ) + + fix_ownership_using_docker() + sys.exit(result.returncode) Review Comment: The `extract_data` function doesn't use the `option_dry_run` decorator's functionality. The decorator is applied (line 43) but the dry-run logic isn't implemented in the function body. Either implement dry-run support or remove the unused decorator to avoid confusion. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
