Compare commits

2 Commits

Author SHA1 Message Date
88206ff215 chore(release): bump to 1.4.0 2026-05-06 20:29:04 -07:00
54196a2d4f chore: add scripts/release.sh for cutting + publishing releases
Automates what was previously a six-step manual process that, twice
in this codebase's history, has produced version skew between git and
the released tarball (v1.2.0 was published with package.json 1.2.0 in
the tarball but the bump was never committed back to gitea — making
"what code is in v1.2.0?" answerable only by extracting the tarball).

The script:
- Refuses to run with a dirty tree, off main, or already at the
  target version.
- Bumps dashcaddy-api/package.json, rebuilds status/dist/, commits
  + pushes to gitea — so the released artifact and gitea HEAD are
  always in lockstep.
- Clones gitea HEAD on the release host, verifies the cloned commit
  matches what we just pushed (catches a stale clone or a missed
  push), tars it, computes sha256, writes version.json.
- Refreshes install.sh on the release host alongside the tarball
  (fresh installs use the install.sh from the latest release).
- Mirrors the release dir to the get2 backup via rsync.
- Verifies live by curling version.json and re-hashing the served
  tarball.

Hosts overridable via DASHCADDY_RELEASE_HOST / DASHCADDY_MIRROR_HOST
/ DASHCADDY_GITEA_URL env vars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:28:45 -07:00
6 changed files with 139 additions and 350 deletions

View File

@@ -158,7 +158,6 @@ const APP_TEMPLATES = {
healthCheck: "/api/v3/system/status", healthCheck: "/api/v3/system/status",
subpathSupport: 'native', subpathSupport: 'native',
urlBaseEnv: 'URL_BASE', urlBaseEnv: 'URL_BASE',
dependsOn: ["prowlarr", "qbittorrent"],
setupInstructions: [ setupInstructions: [
"Configure download clients (qBittorrent, etc.)", "Configure download clients (qBittorrent, etc.)",
"Add indexers for content discovery", "Add indexers for content discovery",
@@ -191,8 +190,7 @@ const APP_TEMPLATES = {
defaultPort: 7878, defaultPort: 7878,
healthCheck: "/api/v3/system/status", healthCheck: "/api/v3/system/status",
subpathSupport: 'native', subpathSupport: 'native',
urlBaseEnv: 'URL_BASE', urlBaseEnv: 'URL_BASE'
dependsOn: ["prowlarr", "qbittorrent"]
}, },
"prowlarr": { "prowlarr": {
@@ -1174,7 +1172,6 @@ const APP_TEMPLATES = {
healthCheck: "/api/v1/system/status", healthCheck: "/api/v1/system/status",
subpathSupport: 'native', subpathSupport: 'native',
urlBaseEnv: 'URL_BASE', urlBaseEnv: 'URL_BASE',
dependsOn: ["prowlarr", "qbittorrent"],
setupInstructions: [ setupInstructions: [
"Configure download clients", "Configure download clients",
"Add indexers", "Add indexers",
@@ -1208,7 +1205,6 @@ const APP_TEMPLATES = {
healthCheck: "/api/v1/system/status", healthCheck: "/api/v1/system/status",
subpathSupport: 'native', subpathSupport: 'native',
urlBaseEnv: 'URL_BASE', urlBaseEnv: 'URL_BASE',
dependsOn: ["prowlarr", "qbittorrent"],
setupInstructions: [ setupInstructions: [
"Configure download clients", "Configure download clients",
"Add indexers for books", "Add indexers for books",
@@ -1242,7 +1238,6 @@ const APP_TEMPLATES = {
healthCheck: "/", healthCheck: "/",
subpathSupport: 'native', subpathSupport: 'native',
urlBaseEnv: 'BASE_URL', urlBaseEnv: 'BASE_URL',
dependsOn: ["sonarr", "radarr"],
setupInstructions: [ setupInstructions: [
"Connect to Sonarr and Radarr", "Connect to Sonarr and Radarr",
"Configure subtitle providers", "Configure subtitle providers",
@@ -1271,7 +1266,6 @@ const APP_TEMPLATES = {
healthCheck: "/api/v1/status", healthCheck: "/api/v1/status",
subpathSupport: 'native', subpathSupport: 'native',
urlBaseEnv: 'BASE_PATH', urlBaseEnv: 'BASE_PATH',
dependsOn: ["sonarr", "radarr"],
setupInstructions: [ setupInstructions: [
"Connect to Plex, Jellyfin, or Emby server", "Connect to Plex, Jellyfin, or Emby server",
"Link Sonarr and Radarr", "Link Sonarr and Radarr",
@@ -1301,7 +1295,6 @@ const APP_TEMPLATES = {
healthCheck: "/", healthCheck: "/",
subpathSupport: 'native', subpathSupport: 'native',
urlBaseEnv: 'TAUTULLI_HTTP_ROOT', urlBaseEnv: 'TAUTULLI_HTTP_ROOT',
dependsOn: ["plex"],
setupInstructions: [ setupInstructions: [
"Connect to Plex server", "Connect to Plex server",
"Configure notifications", "Configure notifications",

View File

@@ -1,6 +1,6 @@
{ {
"name": "dashcaddy-api", "name": "dashcaddy-api",
"version": "1.3.1", "version": "1.4.0",
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management", "description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View File

@@ -226,198 +226,6 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
} }
} }
/**
* Check which template-declared dependencies are missing from running services.
* Returns null if the template has no dependsOn field.
* @param {Object} template - App template with optional `dependsOn: string[]`
* @param {Array} runningServices - Array of services from services.json
* @returns {{ dependsOn: string[], missing: string[], satisfied: string[] } | null}
*/
function checkDependencies(template, runningServices) {
if (!template || !Array.isArray(template.dependsOn) || template.dependsOn.length === 0) {
return null;
}
const deployedTemplateIds = new Set(
(runningServices || [])
.map(s => s.appTemplate || s.deploymentManifest?.templateId)
.filter(Boolean)
);
const satisfied = [];
const missing = [];
for (const depId of template.dependsOn) {
if (deployedTemplateIds.has(depId)) satisfied.push(depId);
else missing.push(depId);
}
return { dependsOn: [...template.dependsOn], missing, satisfied };
}
/**
* Topologically sort a list of template IDs by their dependsOn relationships.
* Dependencies come before dependents. Skips IDs not present in templates.
* Detects cycles and breaks them by emitting in DFS finish order.
* @param {string[]} ids
* @returns {string[]}
*/
function topologicalSortTemplates(ids) {
const idSet = new Set(ids);
const visited = new Set();
const visiting = new Set();
const result = [];
function visit(id) {
if (visited.has(id)) return;
if (visiting.has(id)) return; // cycle — break here
visiting.add(id);
const tpl = ctx.APP_TEMPLATES[id];
if (tpl && Array.isArray(tpl.dependsOn)) {
for (const dep of tpl.dependsOn) {
if (idSet.has(dep)) visit(dep);
}
}
visiting.delete(id);
visited.add(id);
result.push(id);
}
for (const id of ids) visit(id);
return result;
}
/**
* Build a default deploy config for a dependency template.
* Uses the template's defaults: subdomain, port, IP from siteConfig.
*/
function buildDefaultDepConfig(template) {
return {
subdomain: template.subdomain,
port: template.defaultPort,
ip: ctx.siteConfig.dnsServerIp || 'localhost',
createDns: true,
tailscaleOnly: false,
allowedIPs: []
};
}
/**
* Deploy a list of dependency apps in topological order, waiting for each
* to become healthy before continuing. Skips deps already running.
* Throws on first failure.
*/
async function deployDependencyChain(depIds) {
const ordered = topologicalSortTemplates(depIds);
const services = await servicesStateManager.read();
const deployedTemplateIds = new Set(
services.map(s => s.appTemplate || s.deploymentManifest?.templateId).filter(Boolean)
);
const deployed = [];
for (const depId of ordered) {
if (deployedTemplateIds.has(depId)) {
log.info('deploy', 'Dependency already running, skipping', { depId });
continue;
}
const depTemplate = ctx.APP_TEMPLATES[depId];
if (!depTemplate) {
throw new Error(`[DC-205] Dependency "${depId}" has no template`);
}
const depConfig = buildDefaultDepConfig(depTemplate);
log.info('deploy', 'Deploying dependency', { depId, subdomain: depConfig.subdomain });
const containerId = await deployContainer(depId, depConfig, depTemplate);
await helpers.waitForHealthCheck(containerId, depTemplate.healthCheck, depConfig.port);
log.info('deploy', 'Dependency healthy', { depId, containerId });
// Generate Caddy config + DNS for the dep so it's actually reachable
const caddyOptions = {
tailscaleOnly: false,
allowedIPs: [],
subpathSupport: depTemplate.subpathSupport || 'strip'
};
const isSubdirectoryMode = ctx.siteConfig.routingMode === 'subdirectory' && ctx.siteConfig.domain;
const depCaddyConfig = caddy.generateConfig(depConfig.subdomain, depConfig.ip, depConfig.port, caddyOptions);
if (isSubdirectoryMode) {
await helpers.ensureMainDomainBlock();
await helpers.addSubpathConfig(depConfig.subdomain, depCaddyConfig);
} else {
await helpers.addCaddyConfig(depConfig.subdomain, depCaddyConfig);
}
if (!isSubdirectoryMode) {
try {
await ctx.dns.createRecord(depConfig.subdomain, depConfig.ip);
} catch (dnsErr) {
log.warn('deploy', 'DNS creation failed for dependency', { depId, error: dnsErr.message });
}
}
// Build a minimal manifest for the dep so it's restorable later
const processedDep = helpers.processTemplateVariables(depTemplate, depConfig);
const depManifest = {
templateId: depId,
config: {
subdomain: depConfig.subdomain,
port: depConfig.port,
ip: depConfig.ip,
createDns: depConfig.createDns,
tailscaleOnly: false,
allowedIPs: [],
useExisting: false
},
container: {
image: processedDep.docker.image,
ports: processedDep.docker.ports,
volumes: processedDep.docker.volumes || [],
environment: { ...processedDep.docker.environment },
capabilities: processedDep.docker.capabilities || undefined
},
caddy: {
tailscaleOnly: false,
allowedIPs: [],
subpathSupport: depTemplate.subpathSupport || 'strip',
routingMode: ctx.siteConfig.routingMode
}
};
await ctx.addServiceToConfig({
id: depConfig.subdomain,
name: depTemplate.name,
logo: depTemplate.logo || `/assets/${depId}.png`,
url: ctx.buildServiceUrl(depConfig.subdomain),
containerId,
appTemplate: depId,
tailscaleOnly: false,
routingMode: ctx.siteConfig.routingMode,
deployedAt: new Date().toISOString(),
deploymentManifest: depManifest
});
deployed.push({ depId, containerId, subdomain: depConfig.subdomain });
}
return deployed;
}
// Check dependencies for an app (frontend calls this before opening deploy modal)
router.post('/apps/check-dependencies', asyncHandler(async (req, res) => {
const { appId } = req.body;
if (!appId || typeof appId !== 'string') {
throw new ValidationError('appId is required');
}
const template = ctx.APP_TEMPLATES[appId];
if (!template) throw new ValidationError('Invalid app template');
const services = await servicesStateManager.read();
const result = checkDependencies(template, services);
if (!result) {
return res.json({ appId, dependsOn: [], missing: [], satisfied: [] });
}
// Enrich missing deps with friendly names for the UI
const missingDetails = result.missing.map(id => ({
id,
name: ctx.APP_TEMPLATES[id]?.name || id,
icon: ctx.APP_TEMPLATES[id]?.icon || '📦'
}));
res.json({ appId, ...result, missingDetails });
}, 'apps-check-dependencies'));
// Check for existing container before deployment // Check for existing container before deployment
router.post('/apps/check-existing', asyncHandler(async (req, res) => { router.post('/apps/check-existing', asyncHandler(async (req, res) => {
const { appId } = req.body; const { appId } = req.body;
@@ -433,7 +241,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
// Deploy new app // Deploy new app
router.post('/apps/deploy', asyncHandler(async (req, res) => { router.post('/apps/deploy', asyncHandler(async (req, res) => {
const { appId, config, deployDependencies } = req.body; const { appId, config } = req.body;
if (!appId || typeof appId !== 'string') { if (!appId || typeof appId !== 'string') {
throw new ValidationError('appId is required'); throw new ValidationError('appId is required');
} }
@@ -443,7 +251,6 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
if (!config.subdomain || typeof config.subdomain !== 'string') { if (!config.subdomain || typeof config.subdomain !== 'string') {
throw new ValidationError('config.subdomain is required'); throw new ValidationError('config.subdomain is required');
} }
let deployedDeps = [];
try { try {
log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain }); log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain });
const template = ctx.APP_TEMPLATES[appId]; const template = ctx.APP_TEMPLATES[appId];
@@ -452,13 +259,6 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
throw new ValidationError('Invalid app template'); throw new ValidationError('Invalid app template');
} }
// Deploy declared dependencies first if requested
if (Array.isArray(deployDependencies) && deployDependencies.length > 0) {
log.info('deploy', 'Deploying dependencies first', { appId, deps: deployDependencies });
deployedDeps = await deployDependencyChain(deployDependencies);
log.info('deploy', 'Dependencies deployed', { count: deployedDeps.length });
}
if (config.subdomain) { if (config.subdomain) {
if (!REGEX.SUBDOMAIN.test(config.subdomain)) { if (!REGEX.SUBDOMAIN.test(config.subdomain)) {
throw new ValidationError('[DC-301] Invalid subdomain format'); throw new ValidationError('[DC-301] Invalid subdomain format');
@@ -609,8 +409,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
success: true, containerId, usedExisting, success: true, containerId, usedExisting,
url: serviceUrl, url: serviceUrl,
message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`, message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`,
setupInstructions: template.setupInstructions || [], setupInstructions: template.setupInstructions || []
deployedDependencies: deployedDeps
}; };
if (dnsWarning) response.warning = dnsWarning; if (dnsWarning) response.warning = dnsWarning;

View File

@@ -70,9 +70,6 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
const deployedComponents = []; const deployedComponents = [];
const errors = []; const errors = [];
// Lazy-load apps helpers once for health waits between components
const appsHelpers = require('../apps/helpers')(ctx);
try { try {
for (const component of componentsToDeploy) { for (const component of componentsToDeploy) {
try { try {
@@ -87,18 +84,6 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
log.info('recipe', `Component deployed: ${component.id}`, { log.info('recipe', `Component deployed: ${component.id}`, {
containerId: result.containerId?.substring(0, 12) containerId: result.containerId?.substring(0, 12)
}); });
// Wait for the component to become healthy before deploying the next one
// (so dependent components — Sonarr after Prowlarr — see a working dep)
if (result.containerId && result.healthPort) {
try {
await appsHelpers.waitForHealthCheck(result.containerId, result.healthPath, result.healthPort);
log.info('recipe', `Component healthy: ${component.id}`);
} catch (healthErr) {
log.warn('recipe', `Component health wait failed: ${component.id}`, { error: healthErr.message });
// Don't abort the recipe — continue with the next component
}
}
} catch (componentError) { } catch (componentError) {
log.error('recipe', `Component failed: ${component.id}`, { log.error('recipe', `Component failed: ${component.id}`, {
error: componentError.message error: componentError.message
@@ -364,17 +349,6 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
} }
} }
// Resolve health check path + port for the waiter
let healthPath = null;
let healthPort = null;
if (component.templateRef) {
const tpl = ctx.APP_TEMPLATES[component.templateRef];
healthPath = tpl?.healthCheck || null;
healthPort = port || tpl?.defaultPort || null;
} else if (dockerConfig.ports?.length > 0) {
healthPort = port || dockerConfig.ports[0].split(/[:/]/)[0];
}
return { return {
id: component.id, id: component.id,
role: component.role, role: component.role,
@@ -384,9 +358,7 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
internal: component.internal || false, internal: component.internal || false,
templateRef: component.templateRef, templateRef: component.templateRef,
logo, logo,
url, url
healthPath,
healthPort
}; };
} }

133
scripts/release.sh Normal file
View File

@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Cut a DashCaddy release: bump dashcaddy-api/package.json, commit, push,
# build tarball + version.json on DNS2 (the get.dashcaddy.net publishing
# host), refresh install.sh, then mirror everything to the dc-contabo-de
# get2 backup.
#
# Usage: scripts/release.sh <version>
# Example: scripts/release.sh 1.4.0
#
# Pre-flight: must be on `main`, working tree clean, gitea remote reachable.
#
# Hosts/URLs are overridable via env:
# DASHCADDY_RELEASE_HOST default root@100.104.4.5 (DNS2, hosts get.dashcaddy.net)
# DASHCADDY_MIRROR_HOST default root@dc-contabo-de (hosts get2.dashcaddy.net)
# DASHCADDY_GITEA_URL default http://100.98.123.59:3000/sami7777/dashcaddy.git
set -euo pipefail
VERSION="${1:-}"
[[ -z "$VERSION" ]] && { echo "Usage: $0 <version>" >&2; exit 1; }
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Invalid version (need X.Y.Z): $VERSION" >&2; exit 1; }
REPO_ROOT="$(git rev-parse --show-toplevel)"
RELEASE_HOST="${DASHCADDY_RELEASE_HOST:-root@100.104.4.5}"
MIRROR_HOST="${DASHCADDY_MIRROR_HOST:-root@dc-contabo-de}"
GITEA_URL="${DASHCADDY_GITEA_URL:-http://100.98.123.59:3000/sami7777/dashcaddy.git}"
cd "$REPO_ROOT"
# ── Pre-flight ─────────────────────────────────────────────────────────────
[[ -f dashcaddy-api/package.json ]] || { echo "Run from dashcaddy repo root (no dashcaddy-api/package.json)" >&2; exit 1; }
[[ -n "$(git status --porcelain)" ]] && { echo "Working tree must be clean" >&2; exit 1; }
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
[[ "$BRANCH" == "main" ]] || { echo "Must be on main (current: $BRANCH)" >&2; exit 1; }
CURRENT="$(node -p "require('./dashcaddy-api/package.json').version")"
[[ "$CURRENT" == "$VERSION" ]] && { echo "package.json already at $VERSION — nothing to do" >&2; exit 1; }
echo "─── Cutting release ───"
echo " current: $CURRENT"
echo " target: $VERSION"
echo " release: $RELEASE_HOST"
echo " mirror: $MIRROR_HOST"
echo
# ── 1. Bump dashcaddy-api/package.json ────────────────────────────────────
echo "[1/6] Bumping dashcaddy-api/package.json"
node -e "
const fs = require('fs');
const pkg = require('./dashcaddy-api/package.json');
pkg.version = '$VERSION';
fs.writeFileSync('./dashcaddy-api/package.json', JSON.stringify(pkg, null, 2) + '\n');
"
# ── 2. Rebuild status frontend so dist/*.js matches source ────────────────
if [[ -f status/build.js ]]; then
echo "[2/6] Rebuilding status frontend"
(cd status && node build.js >/dev/null)
fi
# ── 3. Commit + push ──────────────────────────────────────────────────────
echo "[3/6] Committing + pushing"
git add dashcaddy-api/package.json
# status/dist/ is .gitignored but its tracked files still need re-staging on rebuild.
# -f bypasses the ignore for these specific paths.
[[ -d status/dist ]] && git add -f status/dist/ 2>/dev/null || true
git commit -m "chore(release): bump to $VERSION" >/dev/null
git push gitea main >/dev/null
COMMIT="$(git rev-parse --short HEAD)"
echo " → committed: $COMMIT"
# ── 4. Build tarball on the publishing host ───────────────────────────────
echo "[4/6] Building tarball on $RELEASE_HOST"
ssh "$RELEASE_HOST" "set -e
rm -rf /tmp/dashcaddy-build
mkdir -p /tmp/dashcaddy-build
cd /tmp/dashcaddy-build
git clone --depth 1 '$GITEA_URL' dashcaddy >/dev/null 2>&1
cd dashcaddy
ACTUAL_COMMIT=\$(git rev-parse --short HEAD)
if [ \"\$ACTUAL_COMMIT\" != \"$COMMIT\" ]; then
echo \" ! cloned commit \$ACTUAL_COMMIT does not match expected $COMMIT — aborting\" >&2
exit 1
fi
rm -rf .git
find . -type d -name node_modules -exec rm -rf {} + 2>/dev/null || true
cd /tmp/dashcaddy-build
tar zcf dashcaddy-$VERSION.tar.gz dashcaddy/
"
echo " → built /tmp/dashcaddy-build/dashcaddy-$VERSION.tar.gz"
# ── 5. Publish on the release host ────────────────────────────────────────
echo "[5/6] Publishing v$VERSION + refreshed install.sh on $RELEASE_HOST"
ssh "$RELEASE_HOST" "set -e
cd /var/www/get.dashcaddy.net
cp -a release release.backup-\$(date -u +%Y%m%d-%H%M%S)
cp /tmp/dashcaddy-build/dashcaddy-$VERSION.tar.gz release/
cp /tmp/dashcaddy-build/dashcaddy-$VERSION.tar.gz release/latest.tar.gz
( cd release && sha256sum latest.tar.gz > latest.tar.gz.sha256 )
cp /tmp/dashcaddy-build/dashcaddy/dashcaddy-installer/install.sh release/install.sh
chmod +x release/install.sh
SHA256=\$(sha256sum release/dashcaddy-$VERSION.tar.gz | cut -d' ' -f1)
cat > release/version.json <<EOF
{
\"version\": \"$VERSION\",
\"commit\": \"$COMMIT\",
\"date\": \"\$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
\"sha256\": \"\$SHA256\",
\"changelog\": \"$COMMIT chore(release): bump to $VERSION\",
\"breaking\": false,
\"tarball\": \"dashcaddy-$VERSION.tar.gz\"
}
EOF
"
# ── 6. Mirror to backup host ──────────────────────────────────────────────
echo "[6/6] Mirroring to $MIRROR_HOST"
ssh "$RELEASE_HOST" "rsync -aq --delete /var/www/get.dashcaddy.net/release/ $MIRROR_HOST:/var/www/get2.dashcaddy.net/release/"
# ── Verify ───────────────────────────────────────────────────────────────
echo
echo "─── Verifying live ───"
SERVED_VER="$(curl -fsSL --max-time 5 https://get.dashcaddy.net/release/version.json | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin')).version")"
[[ "$SERVED_VER" == "$VERSION" ]] || { echo "MISMATCH: get.dashcaddy.net serves $SERVED_VER, expected $VERSION" >&2; exit 1; }
echo " get.dashcaddy.net → $SERVED_VER"
SHA_LOCAL="$(ssh "$RELEASE_HOST" "sha256sum /var/www/get.dashcaddy.net/release/dashcaddy-$VERSION.tar.gz | cut -d' ' -f1")"
SHA_HTTP="$(curl -fsSL --max-time 30 "https://get.dashcaddy.net/release/dashcaddy-$VERSION.tar.gz" | sha256sum | cut -d' ' -f1)"
[[ "$SHA_LOCAL" == "$SHA_HTTP" ]] || { echo "SHA mismatch on served tarball" >&2; exit 1; }
echo " tarball sha256 → $SHA_HTTP"
echo
echo "Done. v$VERSION published from commit $COMMIT."

View File

@@ -10,22 +10,6 @@
</div> </div>
</div>`); </div>`);
injectModal('dep-warning-modal', `<div id="dep-warning-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 460px; max-width: 600px;">
<h3 id="dep-warning-title">Missing Dependencies</h3>
<p id="dep-warning-subtitle" class="modal-subtitle"></p>
<div id="dep-warning-list" style="display: flex; flex-direction: column; gap: 8px; margin: 16px 0;"></div>
<p style="font-size: 0.82rem; color: var(--muted); margin-top: 8px;">
Recommended: deploy these dependencies first so the app works correctly on first launch.
</p>
<div class="weather-modal-buttons" style="margin-top: 20px;">
<button id="dep-warning-cancel">Cancel</button>
<button id="dep-warning-skip">Deploy anyway</button>
<button id="dep-warning-confirm" class="btn-accent">Deploy with dependencies</button>
</div>
</div>
</div>`);
injectModal('app-deploy-modal', `<div id="app-deploy-modal" class="weather-modal"> injectModal('app-deploy-modal', `<div id="app-deploy-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 600px; max-width: 700px;"> <div class="weather-modal-content" style="min-width: 600px; max-width: 700px;">
<h3 id="app-deploy-title">Deploy Application</h3> <h3 id="app-deploy-title">Deploy Application</h3>
@@ -354,7 +338,7 @@
if (isWidget) { if (isWidget) {
option.onclick = () => toggleDashboardWidget(app, option); option.onclick = () => toggleDashboardWidget(app, option);
} else { } else {
option.onclick = () => checkAndShowDeployConfig(app); option.onclick = () => showDeployConfig(app);
} }
grid.appendChild(option); grid.appendChild(option);
}); });
@@ -391,93 +375,6 @@
showNotification(`${app.name} widget ${newState ? 'enabled' : 'disabled'}`, 'success', 2000); showNotification(`${app.name} widget ${newState ? 'enabled' : 'disabled'}`, 'success', 2000);
} }
// Check declared dependencies for an app before opening the deploy modal.
// If any are missing, show the dep-warning-modal so the user can pick which to auto-deploy.
async function checkAndShowDeployConfig(appTemplate) {
// Reset any leftover dep selection from a previous flow
delete appTemplate._deployDeps;
let depResult = null;
try {
const resp = await secureFetch('/api/v1/apps/check-dependencies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appId: appTemplate.id })
});
depResult = await resp.json();
} catch (e) {
// Network/server error — fall through to normal deploy flow
console.warn('Dep check failed, continuing without it:', e);
return showDeployConfig(appTemplate);
}
// No deps declared, or all already deployed
if (!depResult || !Array.isArray(depResult.missing) || depResult.missing.length === 0) {
return showDeployConfig(appTemplate);
}
// Show warning modal
const depModal = document.getElementById('dep-warning-modal');
const titleEl = document.getElementById('dep-warning-title');
const subtitleEl = document.getElementById('dep-warning-subtitle');
const listEl = document.getElementById('dep-warning-list');
const cancelBtn = document.getElementById('dep-warning-cancel');
const skipBtn = document.getElementById('dep-warning-skip');
const confirmBtn = document.getElementById('dep-warning-confirm');
titleEl.textContent = `${appTemplate.name} has missing dependencies`;
subtitleEl.textContent = `${appTemplate.name} works best when these apps are deployed first:`;
// Render checkboxes for each missing dep
listEl.innerHTML = depResult.missingDetails.map(d => `
<label class="radio-option" style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
<input type="checkbox" class="dep-warning-checkbox" value="${escapeHtml(d.id)}" checked style="margin: 0;" />
<span style="font-size: 1.2rem;">${escapeHtml(d.icon || '📦')}</span>
<div>
<div class="fw-500">${escapeHtml(d.name)}</div>
<div class="text-hint">Not deployed</div>
</div>
</label>
`).join('');
// Close the app selector behind it
modal.classList.remove('show');
depModal.classList.add('show');
// Helper to clean up event listeners after one of the buttons is pressed
const cleanup = () => {
cancelBtn.onclick = null;
skipBtn.onclick = null;
confirmBtn.onclick = null;
};
cancelBtn.onclick = () => {
cleanup();
depModal.classList.remove('show');
// Re-open app selector so user can pick something else
modal.classList.add('show');
};
skipBtn.onclick = () => {
cleanup();
depModal.classList.remove('show');
// Continue to normal deploy flow without any deps
showDeployConfig(appTemplate);
};
confirmBtn.onclick = () => {
// Collect checked deps
const chosen = Array.from(listEl.querySelectorAll('.dep-warning-checkbox'))
.filter(cb => cb.checked)
.map(cb => cb.value);
cleanup();
depModal.classList.remove('show');
// Stash chosen deps on the template; addAppToGrid will pick them up
if (chosen.length > 0) appTemplate._deployDeps = chosen;
showDeployConfig(appTemplate);
};
}
// Show deployment configuration modal // Show deployment configuration modal
async function showDeployConfig(appTemplate) { async function showDeployConfig(appTemplate) {
const deployModal = document.getElementById('app-deploy-modal'); const deployModal = document.getElementById('app-deploy-modal');
@@ -868,11 +765,6 @@
} }
}; };
// Include user-selected dependencies (from dep warning modal)
if (Array.isArray(appTemplate._deployDeps) && appTemplate._deployDeps.length > 0) {
apiDeployConfig.deployDependencies = appTemplate._deployDeps;
}
// Add existing container info if using existing // Add existing container info if using existing
if (usingExisting) { if (usingExisting) {
apiDeployConfig.config.useExisting = true; apiDeployConfig.config.useExisting = true;