Files
stackpilot/src/main.js

599 lines
21 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { initAuth } from './utils/auth.js';
import { initGallery } from './utils/gallery.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,
searchQuery: '',
};
// Presets
const presets = {
media: ['jellyfin', 'sonarr', 'radarr', 'prowlarr', 'qbittorrent', 'bazarr', 'jellyseerr', 'flaresolverr'],
arr: ['sonarr', 'radarr', 'lidarr', 'readarr', 'prowlarr', 'bazarr', 'qbittorrent', 'flaresolverr', 'recyclarr'],
ai: ['ollama', 'open-webui', 'comfyui', 'stable-diffusion', 'flowise', 'anything-llm', 'whisper'],
dev: ['gitea', 'code-server', 'drone', 'registry', 'portainer', 'postgres', 'redis'],
homelab: ['homepage', 'portainer', 'uptime-kuma', 'watchtower', 'tailscale', 'vaultwarden', 'ntfy', 'adguard-home'],
gaming: ['minecraft', 'valheim', 'palworld', 'satisfactory', 'crafty', 'pterodactyl'],
};
// 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);
const query = state.searchQuery.toLowerCase();
for (const cat of categories) {
let catApps = apps.filter(a => a.category === cat.id);
// Filter by search
if (query) {
catApps = catApps.filter(a =>
a.name.toLowerCase().includes(query) ||
a.description.toLowerCase().includes(query) ||
a.id.includes(query)
);
}
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();
checkDependencies();
checkPortConflicts();
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();
checkDependencies();
checkPortConflicts();
updatePreview();
updateUrlState();
}
// Dependency warnings
function checkDependencies() {
const warnings = [];
const ids = state.selected;
if (ids.has('jellyseerr') && !ids.has('jellyfin')) warnings.push('Jellyseerr requires Jellyfin to function');
if (ids.has('overseerr') && !ids.has('plex')) warnings.push('Overseerr requires Plex to function');
if (ids.has('bazarr') && !ids.has('sonarr') && !ids.has('radarr')) warnings.push('Bazarr needs Sonarr or Radarr to manage subtitles for');
if (ids.has('prowlarr') && !ids.has('sonarr') && !ids.has('radarr') && !ids.has('lidarr') && !ids.has('readarr')) warnings.push('Prowlarr has no Arr apps to sync indexers to');
if ((ids.has('sonarr') || ids.has('radarr')) && !ids.has('qbittorrent') && !ids.has('transmission') && !ids.has('deluge') && !ids.has('sabnzbd') && !ids.has('nzbget')) warnings.push('Sonarr/Radarr need a download client (qBittorrent, Transmission, etc.)');
if (ids.has('open-webui') && !ids.has('ollama')) warnings.push('Open WebUI needs Ollama for local LLM inference');
if (ids.has('immich') && !ids.has('postgres') && !ids.has('redis')) warnings.push('Immich requires PostgreSQL and Redis');
if (ids.has('outline') && !ids.has('postgres') && !ids.has('redis')) warnings.push('Outline requires PostgreSQL and Redis');
if (ids.has('bookstack') && !ids.has('mariadb')) warnings.push('BookStack requires MariaDB');
if (ids.has('rocketchat') && !ids.has('mongodb')) warnings.push('Rocket.Chat requires MongoDB');
const el = document.getElementById('dependency-warnings');
el.innerHTML = warnings.map(w => `<div class="dep-warning">⚠️ ${w}</div>`).join('');
}
// Port conflict detection
function checkPortConflicts() {
const selectedApps = apps.filter(a => state.selected.has(a.id));
const portMap = {};
const conflicts = [];
for (const app of selectedApps) {
if (!app.ports) continue;
for (const port of app.ports) {
const hostPort = `${port.host}/${port.protocol}`;
if (portMap[hostPort]) {
conflicts.push(`Port ${port.host} conflict: ${portMap[hostPort]} and ${app.name}`);
} else {
portMap[hostPort] = app.name;
}
}
}
const el = document.getElementById('port-conflicts');
el.innerHTML = conflicts.map(c => `<div class="port-conflict">❌ ${c}</div>`).join('');
}
// URL state (shareable selections)
function updateUrlState() {
const selected = [...state.selected];
if (selected.length > 0) {
const url = new URL(window.location);
url.searchParams.set('apps', selected.join(','));
window.history.replaceState({}, '', url);
} else {
const url = new URL(window.location);
url.searchParams.delete('apps');
window.history.replaceState({}, '', url);
}
}
function loadUrlState() {
const url = new URL(window.location);
const appsParam = url.searchParams.get('apps');
if (appsParam) {
const appIds = appsParam.split(',');
for (const id of appIds) {
if (apps.some(a => a.id === id)) {
state.selected.add(id);
}
}
}
}
// 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
loadUrlState();
renderAppGrid();
initTabs();
initConfigListeners();
initTailscale();
initOsSelector();
initModeNav();
initAnalyser();
initSearch();
initPresets();
initAuth();
initGallery(() => [...state.selected], getConfig);
document.getElementById('download-btn').addEventListener('click', downloadBundle);
// Listen for "Use This Stack" from gallery
window.addEventListener('use-stack', (e) => {
const { apps: stackApps } = e.detail;
state.selected = new Set(stackApps);
renderAppGrid();
checkDependencies();
checkPortConflicts();
updatePreview();
updateUrlState();
});
// Load popularity data (async, non-blocking)
fetchPopularity().then(data => {
if (data) {
state.popularity = data;
renderAppGrid();
}
});
// Search
function initSearch() {
const input = document.getElementById('app-search');
input.addEventListener('input', (e) => {
state.searchQuery = e.target.value;
renderAppGrid();
});
}
// Presets
function initPresets() {
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
const preset = presets[btn.dataset.preset];
if (!preset) return;
for (const id of preset) {
state.selected.add(id);
}
renderAppGrid();
checkDependencies();
checkPortConflicts();
updatePreview();
updateUrlState();
});
});
}
// Mode switching (builder vs analyser vs gallery)
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';
document.getElementById('step-gallery').style.display = mode === 'gallery' ? 'block' : 'none';
});
});
}
// Analyser
function initAnalyser() {
const dropZone = document.getElementById('drop-zone');
const textarea = document.getElementById('analyser-input');
// Drag and drop
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragging'); });
dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('dragging'); });
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragging');
const file = e.dataTransfer.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (ev) => { textarea.value = ev.target.result; };
reader.readAsText(file);
}
});
// Analyse button
document.getElementById('analyse-btn').addEventListener('click', () => {
const input = textarea.value.trim();
if (!input) return;
const results = analyseCompose(input);
renderAnalyserResults(results);
const fixBtn = document.getElementById('show-fixed-btn');
const hasFixable = results.warnings.length > 0 || results.suggestions.length > 0;
fixBtn.classList.toggle('hidden', !hasFixable);
// Hide previous fixed/diff views
document.getElementById('fixed-preview').classList.add('hidden');
document.getElementById('diff-preview').classList.add('hidden');
document.getElementById('view-toggle').classList.add('hidden');
});
// Show fixed button
document.getElementById('show-fixed-btn').addEventListener('click', () => {
const input = textarea.value.trim();
if (!input) return;
const fixed = fixCompose(input);
document.getElementById('fixed-content').textContent = fixed;
document.getElementById('fixed-preview').classList.remove('hidden');
document.getElementById('diff-preview').classList.add('hidden');
document.getElementById('view-toggle').classList.remove('hidden');
// Generate diff
const diffHtml = generateDiff(input, fixed);
document.getElementById('diff-content').innerHTML = diffHtml;
// Set active view
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
document.querySelector('.view-btn[data-view="fixed"]').classList.add('active');
});
// View toggle (fixed vs diff)
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const view = btn.dataset.view;
document.getElementById('fixed-preview').classList.toggle('hidden', view !== 'fixed');
document.getElementById('diff-preview').classList.toggle('hidden', view !== 'diff');
});
});
// Download fixed
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);
});
}
// Simple line-by-line diff
function generateDiff(before, after) {
const beforeLines = before.split('\n');
const afterLines = after.split('\n');
const output = [];
// Simple LCS-based approach for readable diff
const beforeSet = new Set(beforeLines.map((l, i) => `${i}:${l}`));
const afterSet = new Set(afterLines);
let bi = 0, ai = 0;
while (bi < beforeLines.length || ai < afterLines.length) {
if (bi < beforeLines.length && ai < afterLines.length && beforeLines[bi] === afterLines[ai]) {
output.push(`<span class="diff-context"> ${escHtml(beforeLines[bi])}</span>`);
bi++; ai++;
} else if (ai < afterLines.length && !beforeLines.includes(afterLines[ai])) {
output.push(`<span class="diff-add">+ ${escHtml(afterLines[ai])}</span>`);
ai++;
} else if (bi < beforeLines.length && !afterLines.includes(beforeLines[bi])) {
output.push(`<span class="diff-remove">- ${escHtml(beforeLines[bi])}</span>`);
bi++;
} else {
// Changed line
if (bi < beforeLines.length) {
output.push(`<span class="diff-remove">- ${escHtml(beforeLines[bi])}</span>`);
bi++;
}
if (ai < afterLines.length) {
output.push(`<span class="diff-add">+ ${escHtml(afterLines[ai])}</span>`);
ai++;
}
}
}
return output.join('\n');
}
function escHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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);
}
}