feat: onboarding wizard, PWA support, Oracle Cloud free tier deploy, mode nav update
This commit is contained in:
98
src/generators/oracle-cloud.js
Normal file
98
src/generators/oracle-cloud.js
Normal file
@@ -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');
|
||||
}
|
||||
79
src/main.js
79
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 `<span class="wizard-app-chip">${app ? app.name : id}</span>`;
|
||||
}).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';
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user