diff --git a/index.html b/index.html index decf62c..be89f71 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,9 @@ + + +
@@ -16,8 +19,9 @@

Build your self-hosted Docker stack in minutes

@@ -125,6 +129,7 @@ +
Select apps above to see preview...
@@ -181,6 +186,36 @@ + + + + diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..c56d6f5 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..0a0fffd Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..7059ed8 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "StackPilot", + "short_name": "StackPilot", + "description": "Build your self-hosted Docker stack in minutes", + "start_url": "/", + "display": "standalone", + "background_color": "#09090b", + "theme_color": "#6366f1", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..5a1eaaa --- /dev/null +++ b/public/sw.js @@ -0,0 +1,40 @@ +const CACHE_NAME = 'stackpilot-v1'; +const STATIC_ASSETS = ['/', '/index.html']; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + // Don't cache API calls + if (request.url.includes('/api/')) { + event.respondWith(fetch(request)); + return; + } + // Cache-first for static assets + event.respondWith( + caches.match(request).then((cached) => { + const fetched = fetch(request).then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + } + return response; + }).catch(() => cached); + return cached || fetched; + }) + ); +}); diff --git a/src/generators/oracle-cloud.js b/src/generators/oracle-cloud.js new file mode 100644 index 0000000..8bf7ef0 --- /dev/null +++ b/src/generators/oracle-cloud.js @@ -0,0 +1,98 @@ +/** + * Generate a cloud-init script for Oracle Cloud Always Free tier. + * Oracle free tier: 4 ARM Ampere A1 cores, 24GB RAM, 200GB storage. + * This produces a user-data script that installs Docker + Compose, + * sets up the stack, and runs the auto-linker. + */ +export function generateOracleCloudInit(selectedApps, config, composYaml, envContent, setupScript) { + const lines = [ + '#!/bin/bash', + '# StackPilot — Oracle Cloud Free Tier Deployment Script', + '# Usage: Paste this as "Cloud-Init Script" when creating your free instance.', + '# Instance shape: VM.Standard.A1.Flex (4 OCPUs, 24GB RAM)', + `# Generated: ${new Date().toISOString()}`, + '', + 'set -euo pipefail', + 'exec > >(tee /var/log/stackpilot-init.log) 2>&1', + '', + 'echo "=== StackPilot Cloud-Init Starting ==="', + '', + '# Update system', + 'apt-get update && apt-get upgrade -y', + '', + '# Install Docker', + 'curl -fsSL https://get.docker.com | sh', + 'systemctl enable docker && systemctl start docker', + '', + '# Install Docker Compose plugin', + 'apt-get install -y docker-compose-plugin', + '', + '# Create stackpilot user', + 'useradd -m -s /bin/bash -G docker stackpilot || true', + '', + '# Create directories', + `mkdir -p ${config.configPath}`, + `mkdir -p ${config.mediaPath}`, + `mkdir -p ${config.downloadsPath}`, + `chown -R stackpilot:stackpilot ${config.configPath} ${config.mediaPath} ${config.downloadsPath}`, + '', + '# Write docker-compose.yml', + 'mkdir -p /home/stackpilot/stack', + 'cat > /home/stackpilot/stack/docker-compose.yml << \'COMPOSE_EOF\'', + composYaml, + 'COMPOSE_EOF', + '', + '# Write .env', + 'cat > /home/stackpilot/stack/.env << \'ENV_EOF\'', + envContent, + 'ENV_EOF', + '', + '# Write setup.sh', + 'cat > /home/stackpilot/stack/setup.sh << \'SETUP_EOF\'', + setupScript, + 'SETUP_EOF', + 'chmod +x /home/stackpilot/stack/setup.sh', + '', + '# Set ownership', + 'chown -R stackpilot:stackpilot /home/stackpilot/stack', + '', + '# Open firewall ports (Oracle Linux / Ubuntu iptables)', + 'iptables -I INPUT -p tcp --match multiport --dports 80,443,8080,8096,8989,7878,9696,8112,32400 -j ACCEPT || true', + 'iptables-save > /etc/iptables/rules.v4 2>/dev/null || true', + '', + '# Start the stack', + 'cd /home/stackpilot/stack', + 'docker compose up -d', + '', + '# Wait and run auto-linker', + 'echo "Waiting 60s for services to start..."', + 'sleep 60', + 'su - stackpilot -c "cd /home/stackpilot/stack && ./setup.sh" || true', + '', + '# Setup auto-start on reboot', + 'cat > /etc/systemd/system/stackpilot.service << \'SERVICE_EOF\'', + '[Unit]', + 'Description=StackPilot Docker Stack', + 'After=docker.service', + 'Requires=docker.service', + '', + '[Service]', + 'Type=oneshot', + 'RemainAfterExit=yes', + 'WorkingDirectory=/home/stackpilot/stack', + 'ExecStart=/usr/bin/docker compose up -d', + 'ExecStop=/usr/bin/docker compose down', + 'User=stackpilot', + '', + '[Install]', + 'WantedBy=multi-user.target', + 'SERVICE_EOF', + 'systemctl enable stackpilot.service', + '', + 'echo "=== StackPilot Cloud-Init Complete ==="', + `echo "Stack is running! Services: ${selectedApps.filter(a => a.ports?.length > 0).map(a => a.name).join(', ')}"`, + 'echo "Check logs: /var/log/stackpilot-init.log"', + ]; + + return lines.join('\n'); +} diff --git a/src/main.js b/src/main.js index d3ad181..d3d2621 100644 --- a/src/main.js +++ b/src/main.js @@ -2,6 +2,7 @@ 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 { generateOracleCloudInit } from './generators/oracle-cloud.js'; import { trackDownload, fetchPopularity } from './utils/analytics.js'; import { initAuth } from './utils/auth.js'; import { initGallery } from './utils/gallery.js'; @@ -254,6 +255,14 @@ function updatePreview() { case 'setup': content.textContent = generateSetupScript(selectedApps, config); break; + case 'oracle': + content.textContent = generateOracleCloudInit( + selectedApps, config, + generateCompose(selectedApps, config), + generateEnv(selectedApps, config), + generateSetupScript(selectedApps, config) + ); + break; } } @@ -387,6 +396,7 @@ initSearch(); initPresets(); initAuth(); initGallery(() => [...state.selected], getConfig); +initWizard(); document.getElementById('download-btn').addEventListener('click', downloadBundle); // Listen for "Use This Stack" from gallery @@ -408,6 +418,72 @@ fetchPopularity().then(data => { } }); +// Wizard +const wizardGoalMap = { + movies: ['jellyfin', 'sonarr', 'radarr', 'prowlarr', 'bazarr', 'jellyseerr', 'qbittorrent', 'flaresolverr'], + music: ['navidrome', 'lidarr', 'prowlarr', 'qbittorrent'], + books: ['calibre-web', 'readarr', 'audiobookshelf', 'kavita', 'prowlarr', 'qbittorrent'], + photos: ['immich', 'postgres', 'redis'], + downloads: ['qbittorrent', 'prowlarr', 'sonarr', 'radarr', 'flaresolverr', 'gluetun'], + ai: ['ollama', 'open-webui', 'comfyui', 'flowise'], + gaming: ['minecraft', 'crafty', 'pterodactyl'], + 'smart-home': ['homeassistant', 'mosquitto', 'zigbee2mqtt', 'nodered', 'esphome'], + development: ['gitea', 'code-server', 'drone', 'registry', 'postgres', 'redis'], + 'cloud-storage': ['nextcloud', 'syncthing', 'filebrowser'], + privacy: ['adguard-home', 'gluetun', 'searxng', 'vaultwarden', 'tailscale'], + monitoring: ['portainer', 'uptime-kuma', 'grafana', 'prometheus', 'netdata', 'watchtower', 'dozzle'], + 'remote-access': ['tailscale', 'nginx-proxy-manager', 'authelia'], + 'team-chat': ['matrix-synapse', 'element', 'ntfy'], + notes: ['trilium', 'outline', 'hedgedoc', 'memos', 'postgres'], + finance: ['actual-budget', 'firefly-iii', 'ghostfolio'], +}; + +function initWizard() { + document.getElementById('wizard-build-btn').addEventListener('click', () => { + const checked = [...document.querySelectorAll('#wizard-goals input:checked')].map(el => el.value); + if (checked.length === 0) return; + + const recommended = new Set(); + for (const goal of checked) { + const goalApps = wizardGoalMap[goal] || []; + goalApps.forEach(id => recommended.add(id)); + } + // Always add essentials if multiple goals + if (checked.length >= 2) { + recommended.add('homepage'); + recommended.add('watchtower'); + } + + const appsList = [...recommended].filter(id => apps.some(a => a.id === id)); + const resultEl = document.getElementById('wizard-result'); + const appsEl = document.getElementById('wizard-apps'); + + appsEl.innerHTML = appsList.map(id => { + const app = apps.find(a => a.id === id); + return `${app ? app.name : id}`; + }).join(''); + + resultEl.classList.remove('hidden'); + resultEl.dataset.apps = JSON.stringify(appsList); + }); + + document.getElementById('wizard-use-btn').addEventListener('click', () => { + const resultEl = document.getElementById('wizard-result'); + const appsList = JSON.parse(resultEl.dataset.apps || '[]'); + state.selected = new Set(appsList); + renderAppGrid(); + checkDependencies(); + checkPortConflicts(); + updatePreview(); + updateUrlState(); + // Switch to builder + document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); + document.querySelector('.mode-btn[data-mode="builder"]').classList.add('active'); + document.querySelector('.container').style.display = 'block'; + document.getElementById('step-wizard').style.display = 'none'; + }); +} + // Search function initSearch() { const input = document.getElementById('app-search'); @@ -444,7 +520,7 @@ function initPresets() { }); } -// Mode switching (builder vs analyser vs gallery) +// Mode switching (builder vs wizard vs analyser vs gallery) function initModeNav() { document.querySelectorAll('.mode-btn').forEach(btn => { btn.addEventListener('click', () => { @@ -452,6 +528,7 @@ function initModeNav() { btn.classList.add('active'); const mode = btn.dataset.mode; document.querySelector('.container').style.display = mode === 'builder' ? 'block' : 'none'; + document.getElementById('step-wizard').style.display = mode === 'wizard' ? 'block' : 'none'; document.getElementById('step-analyser').style.display = mode === 'analyser' ? 'block' : 'none'; document.getElementById('step-gallery').style.display = mode === 'gallery' ? 'block' : 'none'; }); diff --git a/src/style.css b/src/style.css index c10c7c4..ec8ff71 100644 --- a/src/style.css +++ b/src/style.css @@ -648,6 +648,42 @@ body { /* Gallery */ .gallery-section { max-width: 1280px; margin: 0 auto; padding: 2.5rem 1.5rem; } + +/* Wizard */ +.wizard-section { max-width: 1280px; margin: 0 auto; padding: 2.5rem 1.5rem; } +.wizard-desc { color: var(--text-dim); margin-bottom: 1.5rem; font-size: 0.95rem; } +.wizard-goals { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.6rem; + margin-bottom: 1.5rem; +} +.wizard-goal { + display: flex; + align-items: center; + gap: 0.75rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.8rem 1rem; + cursor: pointer; + transition: all 0.15s ease; + font-size: 0.9rem; +} +.wizard-goal:hover { border-color: var(--accent); background: var(--surface-hover); } +.wizard-goal input:checked + span { color: var(--accent-hover); } +.wizard-goal input { width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer; } +.wizard-result { margin-top: 1.5rem; } +.wizard-result h3 { margin-bottom: 1rem; font-size: 1rem; color: var(--success); } +.wizard-apps { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.wizard-app-chip { + background: var(--accent-glow); + color: var(--accent-hover); + padding: 0.35rem 0.75rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 500; +} .gallery-controls { display: flex; gap: 0.75rem;