Initial commit: StackPilot - Docker stack builder with 186 apps, compose analyser, analytics

This commit is contained in:
2026-08-03 23:28:15 +01:00
commit 341700ddac
18 changed files with 5042 additions and 0 deletions

370
src/main.js Normal file
View File

@@ -0,0 +1,370 @@
import { apps, categories } from './catalog/apps.js';
import { generateCompose, generateEnv } from './generators/compose.js';
import { generateSetupScript } from './generators/setup.js';
import { analyseCompose, fixCompose } from './generators/analyser.js';
import { trackDownload, fetchPopularity } from './utils/analytics.js';
import JSZip from 'jszip';
import './style.css';
// OS-specific default paths
const osDefaults = {
linux: {
configPath: '/opt/stackpilot/config',
mediaPath: '/mnt/media',
downloadsPath: '/mnt/downloads',
timezone: 'Europe/Dublin',
},
macos: {
configPath: '~/docker/stackpilot/config',
mediaPath: '~/Media',
downloadsPath: '~/Downloads/stackpilot',
timezone: 'Europe/Dublin',
},
windows: {
configPath: 'C:\\stackpilot\\config',
mediaPath: 'D:\\Media',
downloadsPath: 'D:\\Downloads',
timezone: 'Europe/Dublin',
},
};
// State
const state = {
selected: new Set(),
activeTab: 'compose',
os: 'linux',
popularity: null,
};
// Get config from inputs
function getConfig() {
return {
configPath: document.getElementById('config-path').value || '/opt/stackpilot/config',
mediaPath: document.getElementById('media-path').value || '/mnt/media',
downloadsPath: document.getElementById('downloads-path').value || '/mnt/downloads',
timezone: document.getElementById('timezone').value || 'Europe/Dublin',
tailscale: {
enabled: document.getElementById('tailscale-enable').checked,
authKey: document.getElementById('ts-authkey').value,
hostname: document.getElementById('ts-hostname').value || 'stackpilot',
funnel: document.getElementById('ts-funnel').checked,
subnetRouter: document.getElementById('ts-subnet').checked,
},
};
}
// Render app grid
function renderAppGrid() {
const grid = document.getElementById('app-grid');
grid.innerHTML = '';
// Selection badge
const badge = document.createElement('div');
badge.className = `selection-badge${state.selected.size === 0 ? ' hidden' : ''}`;
badge.textContent = `${state.selected.size} app${state.selected.size !== 1 ? 's' : ''} selected`;
grid.appendChild(badge);
for (const cat of categories) {
const catApps = apps.filter(a => a.category === cat.id);
if (catApps.length === 0) continue;
const group = document.createElement('div');
group.className = 'category-group';
const header = document.createElement('div');
header.className = 'category-header';
const title = document.createElement('h3');
title.className = 'category-title';
title.textContent = `${cat.icon} ${cat.name}`;
const selectAllBtn = document.createElement('button');
const allSelected = catApps.every(a => state.selected.has(a.id));
selectAllBtn.className = 'category-select-all';
selectAllBtn.textContent = allSelected ? 'Deselect all' : 'Select all';
selectAllBtn.addEventListener('click', () => {
if (allSelected) {
catApps.forEach(a => state.selected.delete(a.id));
} else {
catApps.forEach(a => state.selected.add(a.id));
}
renderAppGrid();
updatePreview();
});
header.appendChild(title);
header.appendChild(selectAllBtn);
group.appendChild(header);
const appsDiv = document.createElement('div');
appsDiv.className = 'category-apps';
for (const app of catApps) {
const card = document.createElement('div');
card.className = `app-card${state.selected.has(app.id) ? ' selected' : ''}`;
const popCount = state.popularity?.[app.id] || 0;
const popBadge = popCount > 0 ? `<span class="pop-badge" title="${popCount} downloads">🔥 ${popCount}</span>` : '';
card.innerHTML = `
<div class="app-check">${state.selected.has(app.id) ? '✓' : ''}</div>
<div class="app-name">${app.name} ${popBadge}</div>
<div class="app-desc">${app.description}</div>
${app.url ? `<a class="app-link" href="${app.url}" target="_blank" rel="noopener" onclick="event.stopPropagation()">GitHub →</a>` : ''}
`;
card.addEventListener('click', () => toggleApp(app.id));
appsDiv.appendChild(card);
}
group.appendChild(appsDiv);
grid.appendChild(group);
}
}
// Toggle app selection
function toggleApp(id) {
if (state.selected.has(id)) {
state.selected.delete(id);
} else {
state.selected.add(id);
}
renderAppGrid();
updatePreview();
}
// Update preview
function updatePreview() {
const selectedApps = apps.filter(a => state.selected.has(a.id));
const config = getConfig();
const content = document.getElementById('preview-content');
const downloadBtn = document.getElementById('download-btn');
if (selectedApps.length === 0) {
content.textContent = 'Select apps above to see preview...';
downloadBtn.disabled = true;
return;
}
downloadBtn.disabled = false;
switch (state.activeTab) {
case 'compose':
content.textContent = generateCompose(selectedApps, config);
break;
case 'env':
content.textContent = generateEnv(selectedApps, config);
break;
case 'setup':
content.textContent = generateSetupScript(selectedApps, config);
break;
}
}
// Tab switching
function initTabs() {
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
state.activeTab = tab.dataset.tab;
updatePreview();
});
});
}
// Download bundle
async function downloadBundle() {
const selectedApps = apps.filter(a => state.selected.has(a.id));
const config = getConfig();
const zip = new JSZip();
zip.file('docker-compose.yml', generateCompose(selectedApps, config));
zip.file('.env', generateEnv(selectedApps, config));
zip.file('setup.sh', generateSetupScript(selectedApps, config));
zip.file('README.md', generateReadme(selectedApps, config));
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'stackpilot-bundle.zip';
a.click();
URL.revokeObjectURL(url);
// Track popularity
trackDownload([...state.selected]);
}
function generateReadme(selectedApps, config) {
const lines = [
'# StackPilot Bundle',
'',
`Generated: ${new Date().toISOString()}`,
'',
'## Quick Start',
'',
'```bash',
'# 1. Review and edit .env file',
'nano .env',
'',
'# 2. Create directories',
`mkdir -p ${config.configPath} ${config.mediaPath} ${config.downloadsPath}`,
'',
'# 3. Start the stack',
'docker compose up -d',
'',
'# 4. Run auto-linker (after all services are healthy)',
'chmod +x setup.sh',
'./setup.sh',
'```',
'',
'## Services',
'',
'| Service | URL | Port |',
'|---------|-----|------|',
];
for (const app of selectedApps) {
if (app.ports?.length > 0) {
lines.push(`| ${app.name} | http://localhost:${app.ports[0].host} | ${app.ports[0].host} |`);
} else {
lines.push(`| ${app.name} | (no web UI) | — |`);
}
}
lines.push('', '## Notes', '');
lines.push('- Default qBittorrent credentials: admin / adminadmin (change immediately)');
lines.push('- Arr apps generate their API keys on first run — setup.sh reads them from config.xml');
lines.push('- Run `docker compose logs -f` to monitor startup');
lines.push('- Run `docker compose down` to stop all services');
return lines.join('\n');
}
// Config change listeners
function initConfigListeners() {
document.querySelectorAll('.config-grid input').forEach(input => {
input.addEventListener('input', updatePreview);
});
}
// Tailscale toggle
function initTailscale() {
const enable = document.getElementById('tailscale-enable');
const options = document.getElementById('tailscale-options');
enable.addEventListener('change', () => {
options.classList.toggle('hidden', !enable.checked);
updatePreview();
});
document.getElementById('ts-funnel').addEventListener('change', updatePreview);
document.getElementById('ts-subnet').addEventListener('change', updatePreview);
document.getElementById('ts-authkey').addEventListener('input', updatePreview);
document.getElementById('ts-hostname').addEventListener('input', updatePreview);
}
// OS selector
function initOsSelector() {
document.querySelectorAll('input[name="os"]').forEach(radio => {
radio.addEventListener('change', (e) => {
state.os = e.target.value;
const defaults = osDefaults[state.os];
document.getElementById('config-path').value = defaults.configPath;
document.getElementById('media-path').value = defaults.mediaPath;
document.getElementById('downloads-path').value = defaults.downloadsPath;
document.getElementById('timezone').value = defaults.timezone;
updatePreview();
});
});
}
// Init
renderAppGrid();
initTabs();
initConfigListeners();
initTailscale();
initOsSelector();
initModeNav();
initAnalyser();
document.getElementById('download-btn').addEventListener('click', downloadBundle);
// Load popularity data (async, non-blocking)
fetchPopularity().then(data => {
if (data) {
state.popularity = data;
renderAppGrid(); // Re-render with badges
}
});
// Mode switching (builder vs analyser)
function initModeNav() {
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const mode = btn.dataset.mode;
document.querySelector('.container').style.display = mode === 'builder' ? 'block' : 'none';
document.getElementById('step-analyser').style.display = mode === 'analyser' ? 'block' : 'none';
});
});
}
// Analyser
function initAnalyser() {
document.getElementById('analyse-btn').addEventListener('click', () => {
const input = document.getElementById('analyser-input').value.trim();
if (!input) return;
const results = analyseCompose(input);
renderAnalyserResults(results);
// Show "Show Fixed Compose" button if there are fixable issues
const fixBtn = document.getElementById('show-fixed-btn');
const hasFixable = results.warnings.length > 0 || results.suggestions.length > 0;
fixBtn.classList.toggle('hidden', !hasFixable);
});
document.getElementById('show-fixed-btn').addEventListener('click', () => {
const input = document.getElementById('analyser-input').value.trim();
if (!input) return;
const fixed = fixCompose(input);
document.getElementById('fixed-content').textContent = fixed;
document.getElementById('fixed-preview').classList.remove('hidden');
});
document.getElementById('download-fixed-btn').addEventListener('click', () => {
const fixed = document.getElementById('fixed-content').textContent;
const blob = new Blob([fixed], { type: 'text/yaml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'docker-compose-fixed.yml';
a.click();
URL.revokeObjectURL(url);
});
}
function renderAnalyserResults(results) {
const container = document.getElementById('analyser-results');
container.innerHTML = '';
// Summary badges
const summary = document.createElement('div');
summary.className = 'result-summary';
if (results.errors.length) summary.innerHTML += `<span class="result-badge error">❌ ${results.errors.length} Error${results.errors.length > 1 ? 's' : ''}</span>`;
if (results.warnings.length) summary.innerHTML += `<span class="result-badge warning">⚠️ ${results.warnings.length} Warning${results.warnings.length > 1 ? 's' : ''}</span>`;
if (results.suggestions.length) summary.innerHTML += `<span class="result-badge suggestion">💡 ${results.suggestions.length} Suggestion${results.suggestions.length > 1 ? 's' : ''}</span>`;
if (results.info.length) summary.innerHTML += `<span class="result-badge info">ℹ️ ${results.info.length} Info</span>`;
container.appendChild(summary);
// Cards
const allResults = [
...results.errors.map(r => ({ ...r, type: 'error' })),
...results.warnings.map(r => ({ ...r, type: 'warning' })),
...results.suggestions.map(r => ({ ...r, type: 'suggestion' })),
...results.info.map(r => ({ ...r, type: 'info' })),
];
for (const item of allResults) {
const card = document.createElement('div');
card.className = `result-card ${item.type}`;
card.innerHTML = `<div class="result-title">${item.title}</div><div class="result-detail">${item.detail}</div>`;
container.appendChild(card);
}
}