Support multiple deploy template list URLs with cross-list dedupe
CI / test (push) Successful in 9m55s

Add Settings UI to configure many Portainer-format template catalogs (not just one), merge them on fetch, drop duplicates by image/stack/title, and show source counts on the Deploy view.
This commit is contained in:
2026-07-10 22:19:46 -04:00
parent 60816ed78b
commit 2d8b24ec0e
7 changed files with 603 additions and 25 deletions
+62 -18
View File
@@ -22,6 +22,11 @@ import notificationManager from './libs/notifications.js';
import { initOpsApp } from './ui/ops-app.js';
import { presentError, formatResponseError, isBackgroundMethod } from './client/errors.js';
import { warmSnapshot } from './client/snapshot.js';
import {
fetchMergedTemplates,
getTemplateListUrls,
clearMergedTemplateCache,
} from './client/templateLists.js';
import {
getTerminalCtor,
getFitAddonCtor,
@@ -4324,9 +4329,57 @@ async function bulkRemoveImages() {
// Deploy View Functions
let deployViewTemplates = [];
/** @type {{ sources?: number, unique?: number, duplicates?: number, fetched?: number }|null} */
let deployViewTemplateStats = null;
let deployTemplateSearchSetup = false;
const DEPLOY_SEARCH_DEBOUNCE_MS = 280;
function clearDeployTemplateCache() {
deployViewTemplates = [];
deployViewTemplateStats = null;
clearMergedTemplateCache();
}
window.__peardockClearDeployTemplateCache = clearDeployTemplateCache;
/**
* @param {object[]} templates
* @param {object} [stats]
*/
function setDeployTemplates(templates, stats) {
deployViewTemplates = Array.isArray(templates) ? templates : [];
deployViewTemplateStats = stats || null;
// Keep modal catalog in sync if open path uses fetchTemplates separately
try {
fetchTemplates();
} catch {
// ignore
}
if (typeof currentView !== 'undefined' && currentView === 'deploy') {
const searchInput = document.getElementById('deploy-template-search-input');
const query = searchInput?.value || '';
displayDeployTemplateList(filterTemplatesByQuery(deployViewTemplates, query));
updateDeployTemplateCount(query);
}
}
window.__peardockSetDeployTemplates = setDeployTemplates;
function updateDeployTemplateCount(query = '') {
const countEl = document.getElementById('deploy-template-count');
if (!countEl) return;
const total = deployViewTemplates.length;
const q = String(query || '').trim();
const filtered = q ? filterTemplatesByQuery(deployViewTemplates, q).length : total;
const st = deployViewTemplateStats;
let base = q ? `${filtered} of ${total} templates` : `${total} templates`;
if (st?.sources != null) {
base += ` · ${st.sources} list${st.sources === 1 ? '' : 's'}`;
}
if (st?.duplicates) {
base += ` · ${st.duplicates} dupes removed`;
}
countEl.textContent = base;
}
function setupDeployTemplateSearch() {
const searchInput = document.getElementById('deploy-template-search-input');
if (!searchInput || deployTemplateSearchSetup) return;
@@ -4336,13 +4389,7 @@ function setupDeployTemplateSearch() {
const query = searchInput.value;
const filtered = filterTemplatesByQuery(deployViewTemplates, query);
displayDeployTemplateList(filtered);
const countEl = document.getElementById('deploy-template-count');
if (countEl) {
const q = query.trim();
countEl.textContent = q
? `${filtered.length} of ${deployViewTemplates.length} templates`
: `${deployViewTemplates.length} templates`;
}
updateDeployTemplateCount(query);
}, DEPLOY_SEARCH_DEBOUNCE_MS);
searchInput.addEventListener('input', runFilter);
@@ -4386,19 +4433,17 @@ async function loadDeployView() {
// Show loading state only when we don't have cache
if (!deployViewTemplates.length) {
const n = getTemplateListUrls().length;
templateListContainer.innerHTML =
'<div class="text-center text-muted py-4"><i class="fas fa-spinner fa-spin me-2"></i>Loading templates...</div>';
`<div class="text-center text-muted py-4"><i class="fas fa-spinner fa-spin me-2"></i>Loading templates from ${n} list${n === 1 ? '' : 's'}</div>`;
}
try {
if (!deployViewTemplates.length) {
const response = await fetch(
'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json'
);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
deployViewTemplates = Array.isArray(data.templates) ? data.templates : [];
// Also keep modal list in sync when possible
const result = await fetchMergedTemplates(getTemplateListUrls());
deployViewTemplates = result.templates || [];
deployViewTemplateStats = result.stats || null;
// Sync modal list from the same merged set (avoid second multi-fetch when possible)
try {
await fetchTemplates();
} catch {
@@ -4408,12 +4453,11 @@ async function loadDeployView() {
const searchInput = document.getElementById('deploy-template-search-input');
const query = searchInput?.value || '';
displayDeployTemplateList(filterTemplatesByQuery(deployViewTemplates, query));
const countEl = document.getElementById('deploy-template-count');
if (countEl) countEl.textContent = `${deployViewTemplates.length} templates`;
updateDeployTemplateCount(query);
} catch (error) {
console.error('[ERROR] Failed to fetch templates:', error.message);
templateListContainer.innerHTML =
'<div class="text-center text-danger py-4"><i class="fas fa-exclamation-triangle me-2"></i>Failed to load templates. Check network access.</div>';
'<div class="text-center text-danger py-4"><i class="fas fa-exclamation-triangle me-2"></i>Failed to load templates. Check network access and Settings → Deploy template lists.</div>';
}
}