Proper Template Support for Stacks
Release rolling / release (push) Has been cancelled

This commit is contained in:
Raven Scott
2026-07-16 04:09:50 -04:00
parent fb8f1c4fa4
commit 4bbbd45f42
4 changed files with 430 additions and 69 deletions
+67 -10
View File
@@ -4,6 +4,7 @@ import { fetchMergedTemplates, getTemplateListUrls } from '../client/templateLis
import {
isStackTemplate,
resolveTemplateForDeploy,
buildStackDeployPayload,
} from '../client/templateResolve.js';
// DOM Elements - Lazy loaded (initialized when modal opens)
@@ -801,11 +802,25 @@ function createEnvVarInput(envVar, id) {
const presetIcon = isPreset ? '<i class="fas fa-lock text-muted ms-2" title="Preset value"></i>' : '';
switch (inputType) {
case 'select':
case 'select': {
// Portainer: option may set default:true when env.default is absent
let effectiveDefault = defaultStr;
if (effectiveDefault === '' || effectiveDefault == null) {
const marked = selectOptions.find(
(o) => o && typeof o === 'object' && (o.default === true || o.default === 'true')
);
if (marked) effectiveDefault = String(marked.value ?? '');
}
const options = selectOptions.map(opt => {
const optValue = typeof opt === 'object' ? opt.value : opt;
const optLabel = typeof opt === 'object' ? (opt.text || opt.label || opt.value) : opt;
const selected = String(optValue) === defaultStr ? 'selected' : '';
const isDefaultOpt =
opt && typeof opt === 'object' && (opt.default === true || opt.default === 'true');
const selected =
String(optValue) === String(effectiveDefault) ||
(effectiveDefault === '' && isDefaultOpt)
? 'selected'
: '';
return `<option value="${escapeAttrValue(optValue)}" ${selected}>${escapeAttrValue(optLabel)}</option>`;
}).join('');
valueInput = `
@@ -818,8 +833,9 @@ function createEnvVarInput(envVar, id) {
</select>
`;
break;
}
case 'checkbox':
case 'checkbox': {
const checked = (defaultValue === true || defaultValue === 'true' || String(defaultValue).toLowerCase() === 'true') ? 'checked' : '';
valueInput = `
<div class="form-check form-switch">
@@ -832,6 +848,7 @@ function createEnvVarInput(envVar, id) {
</div>
`;
break;
}
case 'password':
valueInput = `
@@ -2409,13 +2426,40 @@ function populateDeployFormFromTemplate(template, opts = {}) {
}
}
// Template note (setup instructions from the catalog — HTML from maintainers)
renderTemplateNote(template.note || template.Note || '');
try {
updatePreview();
} catch {
// ignore
}
return { ok: true, isStack, warnings };
return { ok: true, isStack, warnings, catalogIsStack, hasImage };
}
/**
* Show or hide the Portainer template note panel above the form.
* @param {string} noteHtml
*/
function renderTemplateNote(noteHtml) {
const text = String(noteHtml || '').trim();
for (const form of [formScope, document.getElementById('deploy-view-form'), document.getElementById('deploy-form')].filter(Boolean)) {
if (!form || !form.querySelector) continue;
let panel = form.querySelector('.deploy-template-note');
if (!text) {
if (panel) panel.remove();
continue;
}
if (!panel) {
panel = document.createElement('div');
panel.className = 'deploy-template-note alert alert-info py-2 px-3 mb-3 small';
panel.setAttribute('role', 'note');
form.insertBefore(panel, form.firstChild);
}
// Catalog notes are trusted HTML from the template list (same as Portainer)
panel.innerHTML = `<div class="fw-semibold mb-1"><i class="fas fa-info-circle me-1"></i>Template notes</div><div class="deploy-template-note-body">${text}</div>`;
}
}
// Open deploy modal and populate the form dynamically
@@ -2444,9 +2488,12 @@ async function openDeployModal(template) {
if (form) form.reset();
// Resolve stack compose → primary service image/ports/volumes/env when catalog omits image
let resolved = template;
if (!String(template.image || template.Image || '').trim() && isStackTemplate(template)) {
const typeNum = Number(template.type);
const isCatalogStack =
typeNum === 2 || typeNum === 3 || isStackTemplate(template);
if (isCatalogStack || !String(template.image || template.Image || '').trim()) {
showStatusIndicator(`Loading compose for ${template.title || template.name || 'template'}`);
try {
resolved = await resolveTemplateForDeploy(template);
@@ -2455,6 +2502,13 @@ async function openDeployModal(template) {
}
}
// Stack templates with compose → Stacks modal (full multi-service support)
if (isCatalogStack && resolved._composeText && typeof window.openStackDeployFromTemplate === 'function') {
if (window.openStackDeployFromTemplate(resolved)) {
return;
}
}
currentTemplate = resolved;
const result = populateDeployFormFromTemplate(resolved);
if (result.isStack) {
@@ -2515,13 +2569,15 @@ function validateFormData(data) {
errors.push('Container name must be 63 characters or less.');
}
// Image validation
// Image validation — allow registry hosts, ports, tags, and digests
if (!data.image || !data.image.trim()) {
errors.push('Image name is required.');
} else {
// Basic image name validation
const imagePattern = /^([a-z0-9._-]+\/)*[a-z0-9._-]+(:[a-zA-Z0-9._-]+)?$/;
if (!imagePattern.test(data.image.trim())) {
const img = data.image.trim();
// e.g. nginx, nginx:latest, ghcr.io/org/app:1.2, host:5000/ns/img@sha256:…
const imagePattern =
/^(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*(?::[0-9]+)?\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127})?(?:@sha256:[a-f0-9]{64})?$/i;
if (!imagePattern.test(img) || img.length > 256) {
errors.push('Invalid Docker image name format.');
}
}
@@ -4140,3 +4196,4 @@ window.populateDuplicateForm = populateDuplicateForm;
window.populateDeployFormFromTemplate = populateDeployFormFromTemplate;
window.setDeployFormScope = setDeployFormScope;
window.resolveTemplateForDeploy = resolveTemplateForDeploy;
window.buildStackDeployPayload = buildStackDeployPayload;