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
+131
View File
@@ -25,6 +25,14 @@ import {
import { openCommandPalette, confirmDialog, statusBadge } from './components.js'
import { showAlert } from '../libs/uiUtils.js'
import notificationManager from '../libs/notifications.js'
import {
DEFAULT_TEMPLATE_LIST_URLS,
getTemplateListUrls,
setTemplateListUrls,
normalizeTemplateListUrls,
fetchMergedTemplates,
clearMergedTemplateCache,
} from '../client/templateLists.js'
const SETTINGS_KEY = 'peardock.settings.v1'
@@ -446,6 +454,9 @@ export function appendLiveEvent(evt) {
host.prepend(row)
}
/** @type {string[]} in-memory editor state for template list URLs */
let templateUrlsDraft = []
export function loadSettingsView() {
const s = loadSettings()
const dens = document.getElementById('settings-density')
@@ -464,6 +475,99 @@ export function loadSettingsView() {
<div>Docker: ${c.dockerHealth?.ok ? 'ok' : '—'}</div>`
: 'Not connected'
}
templateUrlsDraft = getTemplateListUrls()
renderTemplateUrlsEditor()
}
function renderTemplateUrlsEditor() {
const host = document.getElementById('settings-template-urls')
if (!host) return
if (!templateUrlsDraft.length) {
host.innerHTML =
'<li class="list-group-item text-muted">No lists configured — default will be used on save.</li>'
return
}
host.innerHTML = templateUrlsDraft
.map(
(url, idx) => `
<li class="list-group-item" data-idx="${idx}">
<span class="url-text" title="${escape(url)}">${escape(url)}</span>
<button type="button" class="btn btn-sm btn-outline-danger btn-remove-url" data-idx="${idx}" title="Remove">
<i class="fas fa-xmark"></i>
</button>
</li>`
)
.join('')
host.querySelectorAll('.btn-remove-url').forEach((btn) => {
btn.addEventListener('click', () => {
const i = Number(btn.dataset.idx)
if (!Number.isFinite(i)) return
templateUrlsDraft = templateUrlsDraft.filter((_, j) => j !== i)
renderTemplateUrlsEditor()
})
})
}
function addTemplateUrlFromInput() {
const input = document.getElementById('settings-template-url-input')
const raw = input?.value?.trim() || ''
if (!raw) {
showAlert('warning', 'Enter a template list URL')
return
}
let href
try {
const u = new URL(raw)
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('http(s) only')
href = u.href
} catch {
showAlert('danger', 'Invalid URL — use http(s)://…/templates.json')
return
}
if (templateUrlsDraft.includes(href)) {
showAlert('info', 'That list is already configured')
return
}
templateUrlsDraft = normalizeTemplateListUrls([...templateUrlsDraft, href])
if (input) input.value = ''
renderTemplateUrlsEditor()
}
/**
* Persist draft URLs and optionally force template re-fetch.
* @param {{ reload?: boolean }} [opts]
*/
export async function saveTemplateListSettings(opts = {}) {
const urls = setTemplateListUrls(templateUrlsDraft)
templateUrlsDraft = urls
renderTemplateUrlsEditor()
// Clear deploy-view cache so next open refetches
clearMergedTemplateCache()
if (typeof window !== 'undefined') {
window.__peardockClearDeployTemplateCache?.()
}
if (opts.reload) {
const status = document.getElementById('settings-template-lists-status')
if (status) status.textContent = 'Fetching catalogs…'
try {
const result = await fetchMergedTemplates(urls, { force: true })
if (typeof window !== 'undefined') {
window.__peardockSetDeployTemplates?.(result.templates, result.stats)
}
const st = result.stats
const msg = `Loaded ${st.unique} templates from ${st.sources} list(s)` +
(st.duplicates ? ` · removed ${st.duplicates} duplicate(s)` : '') +
(st.errors?.length ? ` · ${st.errors.length} list(s) failed` : '')
if (status) status.textContent = msg
showAlert(st.errors?.length && !st.unique ? 'danger' : 'success', msg)
return result
} catch (err) {
if (status) status.textContent = err.message || 'Reload failed'
showAlert('danger', err.message || 'Failed to reload templates')
return null
}
}
return urls
}
export function openPalette(navigateToView) {
@@ -555,17 +659,40 @@ export function initOpsApp({ navigateToView, sendCommand }) {
document.getElementById('events-filter')?.addEventListener('input', () => loadEventsView())
document.getElementById('settings-save-btn')?.addEventListener('click', () => {
// Include multi template list URLs
setTemplateListUrls(templateUrlsDraft)
const next = saveSettings({
density: document.getElementById('settings-density')?.value,
confirmDestructive: document.getElementById('settings-confirm')?.value !== '0',
refreshSeconds: Number(document.getElementById('settings-refresh')?.value) || 0,
templateListUrls: getTemplateListUrls(),
})
startListAutoRefresh(next.refreshSeconds)
if (typeof window !== 'undefined') window.__peardockClearDeployTemplateCache?.()
showAlert('success', 'Preferences saved')
})
document.getElementById('settings-template-url-add')?.addEventListener('click', () => {
addTemplateUrlFromInput()
})
document.getElementById('settings-template-url-input')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
addTemplateUrlFromInput()
}
})
document.getElementById('settings-template-url-reset')?.addEventListener('click', () => {
templateUrlsDraft = [...DEFAULT_TEMPLATE_LIST_URLS]
renderTemplateUrlsEditor()
showAlert('info', 'Default template list restored (save preferences to apply)')
})
document.getElementById('settings-template-reload')?.addEventListener('click', () => {
saveTemplateListSettings({ reload: true })
})
// Apply density + refresh on boot
applySettings(loadSettings())
templateUrlsDraft = getTemplateListUrls()
subscribeJobs((job) => showJob(job))
@@ -641,6 +768,10 @@ export function initOpsApp({ navigateToView, sendCommand }) {
loadSettings,
applySettings,
startListAutoRefresh,
getTemplateListUrls,
setTemplateListUrls,
saveTemplateListSettings,
fetchMergedTemplates,
loadHostView,
loadEventsView,
loadSettingsView,