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

1193
src/catalog/apps.js Normal file

File diff suppressed because it is too large Load Diff

316
src/generators/analyser.js Normal file
View File

@@ -0,0 +1,316 @@
import jsYaml from 'js-yaml';
/**
* Analyse a docker-compose.yml string and return issues + suggestions.
*/
export function analyseCompose(yamlStr) {
const results = { errors: [], warnings: [], suggestions: [], info: [] };
let compose;
try {
compose = jsYaml.load(yamlStr);
} catch (e) {
results.errors.push({ title: 'Invalid YAML', detail: e.message });
return results;
}
if (!compose || typeof compose !== 'object') {
results.errors.push({ title: 'Empty or invalid compose file', detail: 'The file does not contain a valid Docker Compose structure.' });
return results;
}
const services = compose.services || {};
const serviceNames = Object.keys(services);
if (serviceNames.length === 0) {
results.errors.push({ title: 'No services defined', detail: 'The compose file has no services section.' });
return results;
}
results.info.push({ title: 'Services found', detail: `${serviceNames.length} services: ${serviceNames.join(', ')}` });
// Check compose version
if (compose.version) {
results.suggestions.push({ title: 'Remove "version" field', detail: 'The "version" key is obsolete in modern Docker Compose (v2+). It\'s ignored and can be safely removed.' });
}
for (const [name, svc] of Object.entries(services)) {
// No image specified
if (!svc.image && !svc.build) {
results.errors.push({ title: `${name}: No image or build`, detail: 'Service has neither an "image" nor a "build" directive.' });
}
// Using :latest tag
if (svc.image && svc.image.endsWith(':latest')) {
results.warnings.push({ title: `${name}: Using :latest tag`, detail: `Image "${svc.image}" uses :latest which is unpredictable. Pin to a specific version for reproducibility.` });
}
// No image tag at all
if (svc.image && !svc.image.includes(':')) {
results.warnings.push({ title: `${name}: No image tag`, detail: `Image "${svc.image}" has no tag — Docker will pull :latest implicitly. Specify a version tag.` });
}
// No restart policy
if (!svc.restart) {
results.warnings.push({ title: `${name}: No restart policy`, detail: 'Add "restart: unless-stopped" so the container recovers from crashes and host reboots.' });
}
// restart: always vs unless-stopped
if (svc.restart === 'always') {
results.suggestions.push({ title: `${name}: Consider "unless-stopped"`, detail: '"restart: unless-stopped" is usually better than "always" — it respects manual docker stop commands.' });
}
// Privileged mode
if (svc.privileged) {
results.warnings.push({ title: `${name}: Privileged mode`, detail: 'Running privileged gives the container full host access. Use specific cap_add instead if possible.' });
}
// No healthcheck
if (!svc.healthcheck) {
results.suggestions.push({ title: `${name}: No healthcheck`, detail: 'Adding a healthcheck helps Docker (and depends_on) know when the service is actually ready.' });
}
// Volumes — host path binds without absolute paths
if (svc.volumes) {
for (const vol of svc.volumes) {
const volStr = typeof vol === 'string' ? vol : vol.source || '';
if (volStr && !volStr.startsWith('/') && !volStr.startsWith('$') && !volStr.startsWith('.') && volStr.includes(':')) {
const hostPart = volStr.split(':')[0];
if (!hostPart.match(/^[a-zA-Z_][a-zA-Z0-9_-]*$/)) {
// Not a named volume, not absolute — suspicious
results.warnings.push({ title: `${name}: Ambiguous volume path`, detail: `"${volStr}" — use absolute paths or named volumes for clarity.` });
}
}
}
}
// Docker socket mount
if (svc.volumes) {
const hasSock = svc.volumes.some(v => {
const s = typeof v === 'string' ? v : `${v.source}:${v.target}`;
return s.includes('/var/run/docker.sock');
});
if (hasSock && !svc.read_only) {
results.warnings.push({ title: `${name}: Docker socket mounted`, detail: 'Mounting docker.sock gives the container full control over Docker. Consider ":ro" (read-only) if the service only needs read access.' });
}
}
// Port conflicts
if (svc.ports) {
for (const port of svc.ports) {
const portStr = typeof port === 'string' ? port : `${port.published}:${port.target}`;
if (portStr.startsWith('0.0.0.0:') || (!portStr.includes('127.0.0.1') && !portStr.includes('localhost'))) {
// Binding to all interfaces is fine but worth noting for security-sensitive ports
const hostPort = portStr.split(':')[0] === '0.0.0.0' ? portStr.split(':')[1] : portStr.split(':')[0];
if (['3306', '5432', '6379', '27017'].includes(hostPort)) {
results.warnings.push({ title: `${name}: Database port exposed`, detail: `Port ${hostPort} is exposed to all interfaces. Bind to 127.0.0.1:${hostPort} to restrict to local access only.` });
}
}
}
}
// Environment with plaintext secrets
if (svc.environment) {
const envEntries = Array.isArray(svc.environment) ? svc.environment : Object.entries(svc.environment).map(([k, v]) => `${k}=${v}`);
for (const entry of envEntries) {
const key = typeof entry === 'string' ? entry.split('=')[0] : entry;
const lower = key.toLowerCase();
if ((lower.includes('password') || lower.includes('secret') || lower.includes('token') || lower.includes('api_key')) && !entry.includes('${')) {
results.suggestions.push({ title: `${name}: Hardcoded secret`, detail: `"${key}" appears to contain a secret. Move it to a .env file or Docker secrets for better security.` });
break; // One warning per service is enough
}
}
}
// network_mode: host
if (svc.network_mode === 'host') {
results.warnings.push({ title: `${name}: Host networking`, detail: 'network_mode: host bypasses Docker network isolation. Only use this when absolutely necessary.' });
}
// No resource limits
if (!svc.deploy?.resources && !svc.mem_limit && !svc.cpus) {
results.suggestions.push({ title: `${name}: No resource limits`, detail: 'Consider adding memory/CPU limits (deploy.resources or mem_limit) to prevent one container from starving others.' });
}
// Logging
if (!svc.logging) {
results.suggestions.push({ title: `${name}: No log rotation`, detail: 'Without logging config, container logs grow unbounded. Add "logging: { driver: json-file, options: { max-size: 10m, max-file: 3 } }".' });
}
}
// Network analysis
if (!compose.networks) {
results.suggestions.push({ title: 'No custom networks defined', detail: 'All services share the default bridge network. Define custom networks to isolate groups of services (e.g., frontend, backend, database).' });
}
// Port conflict detection across services
const portMap = {};
for (const [name, svc] of Object.entries(services)) {
if (svc.ports) {
for (const port of svc.ports) {
const portStr = typeof port === 'string' ? port : `${port.published}`;
const hostPort = portStr.split(':')[0] === '0.0.0.0' ? portStr.split(':')[1] : portStr.split(':')[0];
if (portMap[hostPort]) {
results.errors.push({ title: `Port conflict: ${hostPort}`, detail: `Both "${portMap[hostPort]}" and "${name}" bind to host port ${hostPort}.` });
} else {
portMap[hostPort] = name;
}
}
}
}
// depends_on without condition
for (const [name, svc] of Object.entries(services)) {
if (svc.depends_on) {
if (Array.isArray(svc.depends_on)) {
results.suggestions.push({ title: `${name}: depends_on without condition`, detail: 'Use the long form with "condition: service_healthy" for more reliable startup ordering.' });
}
}
}
return results;
}
/**
* Apply automatic fixes to a docker-compose.yml based on analysis results.
* Returns the improved YAML string.
*/
export function fixCompose(yamlStr) {
let compose;
try {
compose = jsYaml.load(yamlStr);
} catch (e) {
return yamlStr; // Can't fix invalid YAML
}
if (!compose || typeof compose !== 'object') return yamlStr;
// Remove obsolete version field
delete compose.version;
const services = compose.services || {};
for (const [name, svc] of Object.entries(services)) {
// Add restart policy
if (!svc.restart) {
svc.restart = 'unless-stopped';
}
// Fix restart: always → unless-stopped
if (svc.restart === 'always') {
svc.restart = 'unless-stopped';
}
// Add healthcheck if missing and service has ports
if (!svc.healthcheck && svc.ports && svc.ports.length > 0) {
const portStr = typeof svc.ports[0] === 'string' ? svc.ports[0] : `${svc.ports[0].target || svc.ports[0]}`;
const containerPort = portStr.includes(':') ? portStr.split(':').pop().replace(/\/.*/, '') : portStr.replace(/\/.*/, '');
svc.healthcheck = {
test: ['CMD-SHELL', `curl -f http://localhost:${containerPort} || exit 1`],
interval: '30s',
timeout: '10s',
retries: 3,
};
}
// Add logging config
if (!svc.logging) {
svc.logging = {
driver: 'json-file',
options: { 'max-size': '10m', 'max-file': '3' },
};
}
// Add resource limits
if (!svc.deploy) {
svc.deploy = {};
}
if (!svc.deploy.resources) {
svc.deploy.resources = {
limits: { memory: '512M' },
};
}
// Fix docker socket to read-only if not already
if (svc.volumes) {
svc.volumes = svc.volumes.map(v => {
const volStr = typeof v === 'string' ? v : `${v.source}:${v.target}`;
if (volStr.includes('/var/run/docker.sock') && !volStr.includes(':ro')) {
return typeof v === 'string' ? v + ':ro' : { ...v, read_only: true };
}
return v;
});
}
// Bind database ports to localhost only
if (svc.ports) {
svc.ports = svc.ports.map(p => {
const portStr = typeof p === 'string' ? p : `${p.published}:${p.target}`;
const parts = portStr.split(':');
const hostPort = parts.length === 2 ? parts[0] : parts[1];
const containerPort = parts.length === 2 ? parts[1] : parts[2];
if (['3306', '5432', '6379', '27017'].includes(hostPort)) {
return `127.0.0.1:${hostPort}:${containerPort}`;
}
return p;
});
}
// Convert depends_on array to long form with condition
if (svc.depends_on && Array.isArray(svc.depends_on)) {
const deps = {};
for (const dep of svc.depends_on) {
// Only use service_healthy if the dependency has a healthcheck
const depSvc = services[dep];
if (depSvc && (depSvc.healthcheck || svc.healthcheck)) {
deps[dep] = { condition: 'service_healthy' };
} else {
deps[dep] = { condition: 'service_started' };
}
}
svc.depends_on = deps;
}
// Move hardcoded secrets to env_file reference pattern (add comment)
// We can't actually create a .env file from here, but we restructure
if (svc.environment) {
const envEntries = Array.isArray(svc.environment) ? svc.environment : Object.entries(svc.environment).map(([k, v]) => `${k}=${v}`);
const newEnv = {};
for (const entry of envEntries) {
const [key, ...valueParts] = (typeof entry === 'string' ? entry : `${entry}`).split('=');
const value = valueParts.join('=');
const lower = key.toLowerCase();
if (lower.includes('password') || lower.includes('secret') || lower.includes('token') || lower.includes('api_key')) {
newEnv[key] = '${' + key + '}'; // Reference .env variable
} else {
newEnv[key] = value || '';
}
}
svc.environment = newEnv;
// Add env_file if secrets were found
if (!svc.env_file) {
const hasSecrets = Object.values(newEnv).some(v => v.startsWith('${'));
if (hasSecrets) {
svc.env_file = ['.env'];
}
}
}
services[name] = svc;
}
compose.services = services;
// Add custom network if none exists
if (!compose.networks) {
compose.networks = { stackpilot: { driver: 'bridge' } };
// Add network to all services
for (const svc of Object.values(compose.services)) {
if (!svc.network_mode) {
svc.networks = ['stackpilot'];
}
}
}
return jsYaml.dump(compose, { lineWidth: 120, noRefs: true });
}

156
src/generators/compose.js Normal file
View File

@@ -0,0 +1,156 @@
import jsYaml from 'js-yaml';
export function generateCompose(selectedApps, config) {
const compose = {
version: '3.8',
services: {},
networks: { stackpilot: { driver: 'bridge' } },
};
for (const app of selectedApps) {
const service = {
image: app.image,
container_name: app.id,
restart: 'unless-stopped',
networks: ['stackpilot'],
environment: {
PUID: '1000',
PGID: '1000',
TZ: config.timezone,
...app.env,
},
};
// Ports
if (app.ports && app.ports.length > 0) {
service.ports = app.ports.map(p =>
p.protocol === 'udp'
? `${p.host}:${p.container}/udp`
: `${p.host}:${p.container}`
);
}
// Volumes
if (app.volumes && app.volumes.length > 0) {
service.volumes = app.volumes.map(v => {
const hostPath = v.host
.replace('${CONFIG_PATH}', config.configPath)
.replace('${MEDIA_PATH}', config.mediaPath)
.replace('${DOWNLOADS_PATH}', config.downloadsPath);
return v.readonly ? `${hostPath}:${v.container}:ro` : `${hostPath}:${v.container}`;
});
}
// Cap add
if (app.capAdd) {
service.cap_add = app.capAdd;
}
// Healthcheck
if (app.healthcheck) {
service.healthcheck = {
test: ['CMD-SHELL', app.healthcheck],
interval: '30s',
timeout: '10s',
retries: 3,
};
}
// VPN routing — if gluetun is selected and this is a download client
const gluetunSelected = selectedApps.some(a => a.id === 'gluetun');
if (gluetunSelected && app.links?.providesDownloadClient) {
service.network_mode = `service:gluetun`;
delete service.networks;
delete service.ports;
}
// If this IS gluetun, expose download client ports through it
if (app.id === 'gluetun' && gluetunSelected) {
const dlClients = selectedApps.filter(a => a.links?.providesDownloadClient);
const extraPorts = dlClients.flatMap(a => a.ports || []);
if (extraPorts.length > 0) {
service.ports = [
...(service.ports || []),
...extraPorts.map(p =>
p.protocol === 'udp' ? `${p.host}:${p.container}/udp` : `${p.host}:${p.container}`
),
];
}
}
compose.services[app.id] = service;
}
// Tailscale sidecar
if (config.tailscale?.enabled) {
const tsArgs = ['--hostname=' + (config.tailscale.hostname || 'stackpilot')];
if (config.tailscale.subnetRouter) {
tsArgs.push('--advertise-routes=172.20.0.0/16');
}
if (config.tailscale.funnel) {
tsArgs.push('--advertise-exit-node');
}
compose.services['tailscale-sidecar'] = {
image: 'tailscale/tailscale:latest',
container_name: 'tailscale-sidecar',
restart: 'unless-stopped',
hostname: config.tailscale.hostname || 'stackpilot',
networks: ['stackpilot'],
cap_add: ['NET_ADMIN', 'SYS_MODULE'],
environment: {
TS_AUTHKEY: config.tailscale.authKey || '${TS_AUTHKEY}',
TS_EXTRA_ARGS: tsArgs.join(' '),
TS_STATE_DIR: '/var/lib/tailscale',
},
volumes: [
`${config.configPath}/tailscale:/var/lib/tailscale`,
'/dev/net/tun:/dev/net/tun',
],
};
}
return jsYaml.dump(compose, { lineWidth: 120, noRefs: true });
}
export function generateEnv(selectedApps, config) {
const lines = [
'# StackPilot — Generated Environment File',
`# Generated: ${new Date().toISOString()}`,
'',
'# Paths',
`CONFIG_PATH=${config.configPath}`,
`MEDIA_PATH=${config.mediaPath}`,
`DOWNLOADS_PATH=${config.downloadsPath}`,
`TZ=${config.timezone}`,
'',
'# User/Group IDs',
'PUID=1000',
'PGID=1000',
'',
];
for (const app of selectedApps) {
if (app.env && Object.keys(app.env).length > 0) {
const envVarsWithValues = Object.entries(app.env).filter(([, v]) => v !== '');
if (envVarsWithValues.length > 0) {
lines.push(`# ${app.name}`);
for (const [key, value] of envVarsWithValues) {
lines.push(`${app.id.toUpperCase()}_${key}=${value}`);
}
lines.push('');
}
// Prompt-style placeholders for empty values
const envVarsEmpty = Object.entries(app.env).filter(([, v]) => v === '');
if (envVarsEmpty.length > 0) {
lines.push(`# ${app.name} — FILL THESE IN`);
for (const [key] of envVarsEmpty) {
lines.push(`${app.id.toUpperCase()}_${key}=CHANGE_ME`);
}
lines.push('');
}
}
}
return lines.join('\n');
}

217
src/generators/setup.js Normal file
View File

@@ -0,0 +1,217 @@
export function generateSetupScript(selectedApps, config) {
const ids = selectedApps.map(a => a.id);
// Determine if we need linking functionality
const hasArrApps = selectedApps.some(a => a.links?.needsIndexer || a.links?.needsDownloadClient);
const hasProwlarr = ids.includes('prowlarr');
const hasDlClient = selectedApps.some(a => a.links?.providesDownloadClient);
const hasMediaProviders = selectedApps.some(a => a.links?.providesMedia);
const needsLinking = (hasProwlarr && hasArrApps) || (hasDlClient && hasArrApps) || hasMediaProviders;
const lines = [
'#!/usr/bin/env bash',
'# StackPilot — Post-Deploy Auto-Linker',
'# This script waits for services to be healthy, then wires them together via their APIs.',
`# Generated: ${new Date().toISOString()}`,
'',
'set -euo pipefail',
'',
'GREEN="\\033[0;32m"',
'YELLOW="\\033[1;33m"',
'RED="\\033[0;31m"',
'NC="\\033[0m"',
'',
'log() { echo -e "${GREEN}[StackPilot]${NC} $1"; }',
'warn() { echo -e "${YELLOW}[StackPilot]${NC} $1"; }',
'err() { echo -e "${RED}[StackPilot]${NC} $1"; }',
'',
'# Wait for a service to respond',
'wait_for() {',
' local name="$1" url="$2" max_attempts="${3:-30}"',
' log "Waiting for $name..."',
' for i in $(seq 1 $max_attempts); do',
' if curl -sf "$url" > /dev/null 2>&1; then',
' log "$name is ready!"',
' return 0',
' fi',
' sleep 2',
' done',
' err "$name did not start within $((max_attempts * 2))s"',
' return 1',
'}',
'',
];
// Only include get_api_key if Arr apps are selected
if (needsLinking) {
lines.push('# Get API key from Arr app config.xml');
lines.push('get_api_key() {');
lines.push(' local app="$1"');
lines.push(` local config_file="${config.configPath}/\${app}/config.xml"`);
lines.push(' if [ -f "$config_file" ]; then');
lines.push(' grep -oP \'<ApiKey>\\K[^<]+\' "$config_file"');
lines.push(' else');
lines.push(' err "Config file not found for $app: $config_file"');
lines.push(' return 1');
lines.push(' fi');
lines.push('}');
lines.push('');
}
lines.push('log "Starting StackPilot auto-linking..."');
lines.push('echo ""');
lines.push('');
// Wait for all services
lines.push('# === Wait for services ===');
for (const app of selectedApps) {
if (app.healthcheck && app.ports?.length > 0) {
const port = app.ports[0].host;
lines.push(`wait_for "${app.name}" "http://localhost:${port}"`);
}
}
lines.push('', 'echo ""', '');
// Link Prowlarr to Sonarr/Radarr/Lidarr/Readarr
if (ids.includes('prowlarr')) {
const arrApps = selectedApps.filter(a =>
a.links?.needsIndexer && ids.includes(a.id)
);
if (arrApps.length > 0) {
lines.push('# === Link Prowlarr → Arr Apps ===');
lines.push('PROWLARR_KEY=$(get_api_key prowlarr)');
lines.push('');
for (const arr of arrApps) {
const port = arr.ports[0].host;
lines.push(`log "Linking Prowlarr → ${arr.name}..."`);
lines.push(`${arr.id.toUpperCase()}_KEY=$(get_api_key ${arr.id})`);
lines.push(`curl -sf -X POST "http://localhost:9696/api/v1/applications" \\`);
lines.push(` -H "X-Api-Key: $PROWLARR_KEY" \\`);
lines.push(` -H "Content-Type: application/json" \\`);
lines.push(` -d '{`);
lines.push(` "syncLevel": "fullSync",`);
lines.push(` "name": "${arr.name}",`);
lines.push(` "fields": [`);
lines.push(` {"name": "prowlarrUrl", "value": "http://prowlarr:9696"},`);
lines.push(` {"name": "baseUrl", "value": "http://${arr.id}:${arr.ports[0].container}"},`);
lines.push(` {"name": "apiKey", "value": "'"\$${arr.id.toUpperCase()}_KEY"'"}`);
lines.push(` ],`);
lines.push(` "implementationName": "${arr.name}",`);
lines.push(` "implementation": "${capitalize(arr.id)}",`);
lines.push(` "configContract": "${capitalize(arr.id)}Settings"`);
lines.push(` }' && log " ✓ ${arr.name} linked" || warn " ⚠ ${arr.name} link failed (may need manual setup)"`);
lines.push('');
}
}
}
// Link download client to Sonarr/Radarr/Lidarr
const dlClient = selectedApps.find(a => a.links?.providesDownloadClient);
if (dlClient) {
const arrApps = selectedApps.filter(a => a.links?.needsDownloadClient);
if (arrApps.length > 0) {
lines.push('# === Add Download Client to Arr Apps ===');
const dlPort = dlClient.ports[0].container;
const dlHost = ids.includes('gluetun') ? 'gluetun' : dlClient.id;
for (const arr of arrApps) {
const arrPort = arr.ports[0].host;
lines.push(`log "Adding ${dlClient.name} as download client in ${arr.name}..."`);
lines.push(`${arr.id.toUpperCase()}_KEY=$(get_api_key ${arr.id})`);
if (dlClient.id === 'qbittorrent') {
lines.push(`curl -sf -X POST "http://localhost:${arrPort}/api/v3/downloadclient" \\`);
lines.push(` -H "X-Api-Key: \$${arr.id.toUpperCase()}_KEY" \\`);
lines.push(` -H "Content-Type: application/json" \\`);
lines.push(` -d '{`);
lines.push(` "name": "qBittorrent",`);
lines.push(` "implementation": "QBittorrent",`);
lines.push(` "configContract": "QBittorrentSettings",`);
lines.push(` "protocol": "torrent",`);
lines.push(` "fields": [`);
lines.push(` {"name": "host", "value": "${dlHost}"},`);
lines.push(` {"name": "port", "value": ${dlPort}},`);
lines.push(` {"name": "username", "value": "admin"},`);
lines.push(` {"name": "password", "value": "adminadmin"}`);
lines.push(` ]`);
lines.push(` }' && log " ✓ ${dlClient.name} added to ${arr.name}" || warn " ⚠ Failed (may need manual setup)"`);
} else if (dlClient.id === 'transmission') {
lines.push(`curl -sf -X POST "http://localhost:${arrPort}/api/v3/downloadclient" \\`);
lines.push(` -H "X-Api-Key: \$${arr.id.toUpperCase()}_KEY" \\`);
lines.push(` -H "Content-Type: application/json" \\`);
lines.push(` -d '{`);
lines.push(` "name": "Transmission",`);
lines.push(` "implementation": "Transmission",`);
lines.push(` "configContract": "TransmissionSettings",`);
lines.push(` "protocol": "torrent",`);
lines.push(` "fields": [`);
lines.push(` {"name": "host", "value": "${dlHost}"},`);
lines.push(` {"name": "port", "value": ${dlPort}}`);
lines.push(` ]`);
lines.push(` }' && log " ✓ ${dlClient.name} added to ${arr.name}" || warn " ⚠ Failed (may need manual setup)"`);
} else if (dlClient.id === 'sabnzbd' || dlClient.id === 'nzbget') {
lines.push(`warn " ⚠ ${dlClient.name} linking requires API key — add manually in ${arr.name} UI"`);
}
lines.push('');
}
}
}
// Root folders for Sonarr/Radarr
const arrWithMedia = selectedApps.filter(a => a.links?.providesMedia);
if (arrWithMedia.length > 0) {
lines.push('# === Set Root Folders ===');
for (const arr of arrWithMedia) {
const arrPort = arr.ports[0].host;
const mediaType = arr.links.providesMedia;
const containerPath = `/${mediaType}`;
lines.push(`log "Setting root folder for ${arr.name}..."`);
lines.push(`${arr.id.toUpperCase()}_KEY=$(get_api_key ${arr.id})`);
const apiVersion = arr.id === 'lidarr' || arr.id === 'readarr' ? 'v1' : 'v3';
lines.push(`curl -sf -X POST "http://localhost:${arrPort}/api/${apiVersion}/rootfolder" \\`);
lines.push(` -H "X-Api-Key: \$${arr.id.toUpperCase()}_KEY" \\`);
lines.push(` -H "Content-Type: application/json" \\`);
lines.push(` -d '{"path": "${containerPath}"}' && log " ✓ Root folder set" || warn " ⚠ Root folder may already exist"`);
lines.push('');
}
}
// Summary
lines.push('# === Summary ===');
lines.push('echo ""');
lines.push('log "========================================="');
lines.push('log " StackPilot Setup Complete!"');
lines.push('log "========================================="');
lines.push('echo ""');
lines.push('log "Your services:"');
for (const app of selectedApps) {
if (app.ports?.length > 0) {
lines.push(`log " ${app.name}: http://localhost:${app.ports[0].host}"`);
}
}
lines.push('echo ""');
// Manual steps
const manualSteps = [];
if (ids.includes('tailscale')) manualSteps.push('Set your Tailscale auth key in .env (TS_AUTHKEY)');
if (ids.includes('gluetun')) manualSteps.push('Configure your VPN provider details in .env');
if (ids.includes('plex')) manualSteps.push('Get a Plex claim token from https://plex.tv/claim');
if (ids.includes('jellyfin')) manualSteps.push('Complete Jellyfin setup wizard at http://localhost:8096');
if (ids.includes('prowlarr')) manualSteps.push('Add indexers in Prowlarr at http://localhost:9696');
if (manualSteps.length > 0) {
lines.push('warn "Manual steps remaining:"');
for (const step of manualSteps) {
lines.push(`warn " → ${step}"`);
}
lines.push('echo ""');
}
return lines.join('\n');
}
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}

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);
}
}

505
src/style.css Normal file
View File

@@ -0,0 +1,505 @@
:root {
--bg: #09090b;
--surface: #131318;
--surface-hover: #1c1c24;
--surface-raised: #18181f;
--border: #23232d;
--border-light: #2d2d3a;
--text: #f4f4f5;
--text-dim: #a1a1aa;
--text-muted: #71717a;
--accent: #6366f1;
--accent-hover: #818cf8;
--accent-glow: rgba(99, 102, 241, 0.15);
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--radius: 12px;
--radius-sm: 8px;
--radius-lg: 16px;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
--shadow-md: 0 4px 12px rgba(0,0,0,0.4);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
--gradient-accent: linear-gradient(135deg, #6366f1, #8b5cf6);
--gradient-surface: linear-gradient(180deg, rgba(99,102,241,0.03), transparent);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* Header */
.header {
text-align: center;
padding: 4rem 1rem 2.5rem;
position: relative;
overflow: hidden;
}
.header::before {
content: '';
position: absolute;
top: -50%;
left: 50%;
transform: translateX(-50%);
width: 800px;
height: 600px;
background: radial-gradient(ellipse, rgba(99,102,241,0.08) 0%, transparent 70%);
pointer-events: none;
}
.header h1 {
font-size: 3rem;
font-weight: 800;
letter-spacing: -0.02em;
background: var(--gradient-accent);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.subtitle { color: var(--text-dim); font-size: 1.15rem; font-weight: 400; }
/* Mode Nav */
.mode-nav {
display: flex;
justify-content: center;
gap: 0.25rem;
margin-top: 1.75rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.25rem;
display: inline-flex;
}
.mode-btn {
background: transparent;
border: none;
color: var(--text-dim);
padding: 0.6rem 1.5rem;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.mode-btn:hover { color: var(--text); }
.mode-btn.active {
background: var(--gradient-accent);
color: white;
box-shadow: 0 2px 8px rgba(99,102,241,0.3);
}
/* Container */
.container { max-width: 1280px; margin: 0 auto; padding: 2.5rem 1.5rem; }
/* Steps */
.step { margin-bottom: 3.5rem; }
.step h2 {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 1.25rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
color: var(--text);
letter-spacing: -0.01em;
}
/* OS Selector */
.os-selector {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.os-option {
display: flex;
align-items: center;
cursor: pointer;
}
.os-option input[type="radio"] { display: none; }
.os-label {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.6rem 1.25rem;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.2s ease;
user-select: none;
}
.os-label:hover { border-color: var(--accent); color: var(--accent-hover); }
.os-option input[type="radio"]:checked + .os-label {
border-color: var(--accent);
background: var(--accent-glow);
color: var(--accent-hover);
box-shadow: 0 0 0 1px var(--accent);
}
/* Config Grid */
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
.config-grid label { display: flex; flex-direction: column; gap: 0.4rem; }
.config-grid label span {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.config-grid input {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 0.7rem 1rem;
border-radius: var(--radius-sm);
font-size: 0.9rem;
transition: all 0.2s ease;
}
.config-grid input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
/* App Grid */
.category-group { margin-bottom: 2.5rem; }
.category-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.category-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-dim);
margin: 0;
}
.category-select-all {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 0.3rem 0.75rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.category-select-all:hover { border-color: var(--accent); color: var(--accent); }
.selection-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: var(--gradient-accent);
color: white;
padding: 0.5rem 1.25rem;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 1.5rem;
box-shadow: 0 2px 8px rgba(99,102,241,0.3);
transition: all 0.25s ease;
}
.selection-badge.hidden { opacity: 0; transform: scale(0.9); pointer-events: none; }
.category-apps {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.75rem;
}
/* App Card */
.app-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.1rem;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
position: relative;
overflow: hidden;
}
.app-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: var(--gradient-accent);
opacity: 0;
transition: opacity 0.2s ease;
}
.app-card:hover {
border-color: var(--border-light);
background: var(--surface-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.app-card:hover::before { opacity: 0.5; }
.app-card.selected {
border-color: var(--accent);
background: var(--accent-glow);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(99,102,241,0.15);
}
.app-card.selected::before { opacity: 1; }
.app-card .app-name { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.3rem; }
.app-card .app-desc { font-size: 0.78rem; color: var(--text-muted); line-height: 1.4; }
.app-card .app-link {
display: inline-block;
margin-top: 0.5rem;
font-size: 0.7rem;
font-weight: 500;
color: var(--accent);
text-decoration: none;
opacity: 0.6;
transition: opacity 0.15s ease;
}
.app-card .app-link:hover { opacity: 1; text-decoration: underline; }
.app-card .app-check {
float: right;
width: 22px; height: 22px;
border: 2px solid var(--border-light);
border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 0.7rem;
transition: all 0.2s ease;
}
.app-card.selected .app-check {
background: var(--gradient-accent);
border-color: var(--accent);
color: white;
box-shadow: 0 2px 4px rgba(99,102,241,0.3);
}
.pop-badge {
display: inline-block;
font-size: 0.6rem;
font-weight: 600;
background: rgba(245, 158, 11, 0.12);
color: var(--warning);
padding: 0.15rem 0.45rem;
border-radius: 999px;
margin-left: 0.4rem;
vertical-align: middle;
}
/* Tailscale Config */
.tailscale-config {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
}
.toggle-row {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
margin-bottom: 0.75rem;
font-size: 0.9rem;
}
.toggle-row.sub { margin-left: 1.5rem; font-size: 0.85rem; color: var(--text-dim); }
.toggle-row input[type="checkbox"] {
width: 18px; height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
.tailscale-options { margin-top: 1.25rem; padding-top: 1.25rem; border-top: 1px solid var(--border); }
.tailscale-options.hidden { display: none; }
.ts-warning {
margin-top: 1rem;
padding: 0.85rem 1rem;
background: rgba(245, 158, 11, 0.06);
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: var(--radius-sm);
font-size: 0.8rem;
color: var(--warning);
line-height: 1.5;
}
/* Preview */
.preview-area {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 1.25rem;
box-shadow: var(--shadow-sm);
}
.preview-tabs {
display: flex;
border-bottom: 1px solid var(--border);
background: rgba(0,0,0,0.3);
padding: 0 0.5rem;
}
.tab {
background: none;
border: none;
color: var(--text-muted);
padding: 0.85rem 1.25rem;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all 0.2s ease;
}
.tab:hover { color: var(--text-dim); }
.tab.active { color: var(--accent-hover); border-bottom-color: var(--accent); }
.preview-code {
padding: 1.5rem;
overflow-x: auto;
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: 0.78rem;
line-height: 1.6;
max-height: 500px;
overflow-y: auto;
white-space: pre;
color: var(--text-dim);
}
/* Download Button */
.download-btn {
background: var(--gradient-accent);
color: white;
border: none;
padding: 1rem 2.5rem;
border-radius: var(--radius);
font-size: 1.05rem;
font-weight: 700;
cursor: pointer;
width: 100%;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 16px rgba(99,102,241,0.25);
letter-spacing: -0.01em;
}
.download-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(99,102,241,0.35);
}
.download-btn:active:not(:disabled) { transform: translateY(0); }
.download-btn:disabled { opacity: 0.35; cursor: not-allowed; box-shadow: none; }
/* Analyser */
.analyser-desc { color: var(--text-dim); margin-bottom: 1.25rem; font-size: 0.9rem; }
.analyser-textarea {
width: 100%;
min-height: 280px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: 0.78rem;
padding: 1.25rem;
resize: vertical;
margin-bottom: 1.25rem;
line-height: 1.6;
transition: border-color 0.2s ease;
}
.analyser-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.analyse-btn {
background: var(--gradient-accent);
color: white;
border: none;
padding: 0.8rem 2.25rem;
border-radius: var(--radius-sm);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 1.5rem;
box-shadow: 0 2px 8px rgba(99,102,241,0.2);
}
.analyse-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(99,102,241,0.3); }
.analyse-btn.fixed-btn {
background: linear-gradient(135deg, #22c55e, #16a34a);
box-shadow: 0 2px 8px rgba(34,197,94,0.2);
margin-top: 1.5rem;
}
.analyse-btn.fixed-btn:hover { box-shadow: 0 4px 12px rgba(34,197,94,0.3); }
.hidden { display: none !important; }
.fixed-preview {
margin-top: 1.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.fixed-preview h3 {
padding: 1.25rem 1.5rem 0;
color: var(--success);
font-size: 0.95rem;
font-weight: 600;
}
.analyser-results { display: flex; flex-direction: column; gap: 0.6rem; }
.result-card {
background: var(--surface);
border: 1px solid var(--border);
border-left: 4px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.85rem 1.1rem;
transition: all 0.15s ease;
}
.result-card:hover { background: var(--surface-hover); }
.result-card.error { border-left-color: var(--danger); }
.result-card.warning { border-left-color: var(--warning); }
.result-card.suggestion { border-left-color: var(--accent); }
.result-card.info { border-left-color: var(--success); }
.result-card .result-title { font-weight: 600; font-size: 0.85rem; margin-bottom: 0.2rem; }
.result-card.error .result-title { color: var(--danger); }
.result-card.warning .result-title { color: var(--warning); }
.result-card.suggestion .result-title { color: var(--accent-hover); }
.result-card.info .result-title { color: var(--success); }
.result-card .result-detail { font-size: 0.78rem; color: var(--text-muted); line-height: 1.5; }
.result-summary { display: flex; gap: 0.6rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
.result-badge {
padding: 0.35rem 0.9rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.result-badge.error { background: rgba(239,68,68,0.1); color: var(--danger); border: 1px solid rgba(239,68,68,0.2); }
.result-badge.warning { background: rgba(245,158,11,0.1); color: var(--warning); border: 1px solid rgba(245,158,11,0.2); }
.result-badge.suggestion { background: rgba(99,102,241,0.1); color: var(--accent-hover); border: 1px solid rgba(99,102,241,0.2); }
.result-badge.info { background: rgba(34,197,94,0.1); color: var(--success); border: 1px solid rgba(34,197,94,0.2); }
/* Footer */
.footer {
text-align: center;
padding: 3rem 1rem;
color: var(--text-muted);
font-size: 0.8rem;
border-top: 1px solid var(--border);
}
/* Scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* Responsive */
@media (max-width: 768px) {
.header h1 { font-size: 2.2rem; }
.header { padding: 3rem 1rem 2rem; }
.category-apps { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
.config-grid { grid-template-columns: 1fr; }
.mode-nav { flex-direction: column; align-items: center; }
}
@media (max-width: 480px) {
.header h1 { font-size: 1.8rem; }
.category-apps { grid-template-columns: 1fr; }
.os-selector { flex-wrap: wrap; }
}

37
src/utils/analytics.js Normal file
View File

@@ -0,0 +1,37 @@
// Lightweight analytics — tracks app selection popularity
// Uses a simple JSON API. Replace the URL with your own backend.
const ANALYTICS_URL = '/api/analytics';
/**
* Record which apps were included in a downloaded bundle.
* Fires on download — not on selection (avoids noise).
*/
export async function trackDownload(selectedAppIds) {
try {
await fetch(`${ANALYTICS_URL}/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apps: selectedAppIds,
timestamp: new Date().toISOString(),
}),
});
} catch (e) {
// Silent fail — analytics should never break the app
}
}
/**
* Fetch popularity stats (app download counts).
* Returns: { [appId]: count }
*/
export async function fetchPopularity() {
try {
const res = await fetch(`${ANALYTICS_URL}/popularity`);
if (res.ok) return await res.json();
} catch (e) {
// Silent fail
}
return null;
}