Merge pull request #4 from AnsoCode/feature/dynamic-templates

feat: Dynamic Templates & Sanitizer
This commit is contained in:
Anso
2026-03-05 10:34:05 -05:00
committed by GitHub
2 changed files with 37 additions and 7 deletions
+2
View File
@@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Added:** Dynamic Template Registry URL support via global settings, defaulting to LinuxServer.io templates.
- **Fixed:** Smart Volume Sanitizer to automatically rewrite messy Portainer bind mounts into clean, relative paths (Sencho 1:1 path rule).
- Git Flow branching strategy and branch protection.
- GitHub Actions CI pipeline for automated TypeScript build verification.
- **Added:** Automated Docker Hub CI/CD pipeline for the `dev` and `latest` tags.
+35 -7
View File
@@ -1,4 +1,6 @@
import axios from 'axios';
import { DatabaseService } from './DatabaseService';
export interface TemplateEnv {
name: string;
@@ -38,7 +40,6 @@ export class TemplateService {
private cachedTemplates: Template[] = [];
private lastFetchTime: number = 0;
private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
private readonly TEMPLATES_URL = 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json';
public async getTemplates(): Promise<Template[]> {
const now = Date.now();
@@ -47,7 +48,11 @@ export class TemplateService {
}
try {
const response = await axios.get<TemplatesResponse>(this.TEMPLATES_URL);
const settings = DatabaseService.getInstance().getGlobalSettings();
// Default to a reliable LSIO Portainer v2 template registry if not set
const registryUrl = settings.template_registry_url || 'https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates-2.0.json';
const response = await axios.get<TemplatesResponse>(registryUrl);
// Filter out templates without images as we are generating compose files from image, ports, etc.
this.cachedTemplates = (response.data.templates || []).filter((t: Template) => !!t.image && t.type === 1);
this.lastFetchTime = now;
@@ -80,14 +85,37 @@ export class TemplateService {
if (template.volumes && template.volumes.length > 0) {
yaml += ` volumes:\n`;
for (const vol of template.volumes) {
let hostPath = '';
let containerPath = '';
let options = '';
if (typeof vol === 'string') {
yaml += ` - ${vol}\n`;
const parts = vol.split(':');
if (parts.length === 1) {
yaml += ` - ${vol}\n`;
continue;
}
hostPath = parts[0];
containerPath = parts[1];
options = parts.slice(2).join(':');
if (options) options = `:${options}`;
} else if (vol.container) {
// If bind is not provided, we extract the folder name from the container path
const containerFolder = vol.container.split('/').pop() || 'data';
const localBind = vol.bind ? vol.bind.replace(/^\//, './') : `./${containerFolder}`;
yaml += ` - ${localBind}:${vol.container}${vol.readonly ? ':ro' : ''}\n`;
containerPath = vol.container;
const containerFolder = containerPath.split('/').filter(Boolean).pop() || 'data';
hostPath = vol.bind ? vol.bind : `./${containerFolder}`;
options = vol.readonly ? ':ro' : '';
} else {
continue;
}
if (hostPath.includes('portainer/Files') || hostPath.includes('/your/') || hostPath.includes('/path/to/')) {
const containerFolder = containerPath.split('/').filter(Boolean).pop() || 'data';
hostPath = `./${containerFolder}`;
} else if (hostPath.startsWith('/')) {
hostPath = hostPath.replace(/^\//, './');
}
yaml += ` - ${hostPath}:${containerPath}${options}\n`;
}
}