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
+80
View File
@@ -0,0 +1,80 @@
import test from 'brittle'
import {
normalizeTemplateListUrls,
templateDedupeKey,
extractTemplatesArray,
mergeAndDedupeTemplates,
DEFAULT_TEMPLATE_LIST_URLS,
} from '../client/templateLists.js'
test('normalizeTemplateListUrls unique https only', (t) => {
const urls = normalizeTemplateListUrls([
'https://example.com/a.json',
'https://example.com/a.json',
' https://example.com/b.json ',
'ftp://bad.example/c.json',
'not-a-url',
'',
])
t.is(urls.length, 2)
t.ok(urls[0].includes('a.json'))
t.ok(urls[1].includes('b.json'))
})
test('templateDedupeKey prefers image', (t) => {
t.is(
templateDedupeKey({ title: 'App', image: 'nginx:latest' }),
templateDedupeKey({ title: 'Other', image: 'nginx' })
)
t.is(templateDedupeKey({ image: 'nginx:latest' }), 'image:nginx')
})
test('templateDedupeKey for stacks uses repo', (t) => {
const k = templateDedupeKey({
title: 'Stack A',
repository: { url: 'https://github.com/x/y', stackfile: 'docker-compose.yml' },
})
t.ok(k.startsWith('stack:'))
t.ok(k.includes('github.com/x/y'))
})
test('extractTemplatesArray handles shapes', (t) => {
t.is(extractTemplatesArray({ templates: [{ title: 'a' }] }).length, 1)
t.is(extractTemplatesArray([{ title: 'b' }]).length, 1)
t.is(extractTemplatesArray(null).length, 0)
})
test('mergeAndDedupeTemplates removes duplicates across sources', (t) => {
const result = mergeAndDedupeTemplates([
{
url: 'https://a.example/t.json',
templates: [
{ title: 'Nginx', image: 'nginx:latest' },
{ title: 'Redis', image: 'redis:7' },
],
},
{
url: 'https://b.example/t.json',
templates: [
{ title: 'Nginx Official', image: 'nginx' }, // dupe of first
{ title: 'Postgres', image: 'postgres:16' },
],
},
{
url: 'https://bad.example/t.json',
templates: [],
error: 'HTTP 404',
},
])
t.is(result.templates.length, 3)
t.is(result.stats.duplicates, 1)
t.is(result.stats.fetched, 4)
t.is(result.stats.unique, 3)
t.is(result.stats.errors.length, 1)
t.is(result.templates[0]._sourceUrl, 'https://a.example/t.json')
})
test('default catalog is non-empty', (t) => {
t.ok(DEFAULT_TEMPLATE_LIST_URLS.length >= 1)
t.ok(DEFAULT_TEMPLATE_LIST_URLS[0].startsWith('https://'))
})