Files
coolify/resources/js/deploy-canvas.js
T

511 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Deploy canvas: build a deploy workflow (project → server → source →
* settings → deploy) from nodes, run it through Coolify's API controllers
* (at /canvas/api, with the browser session) and follow the deployment logs.
*
* Mounted on the /canvas page into [data-deploy-canvas] by
* canvas/deploy/index.js (Svelte components). This file keeps the DOM-free
* parts: step types, flow rules and storage; `node --test` imports it.
*/
import { api } from './canvas-core.js';
// ── Settings ───────────────────────────────────────────────────────────
export const LOG_POLL_MS = 2000; // Deployment log refresh (Coolify's own log view uses 2 s too).
export const STORE = 'coolify-canvas'; // localStorage prefix (flow, view).
// ── Small helpers ──────────────────────────────────────────────────────
// Icons: the app's reicon glyphs (rendered by the page into
// [data-deploy-canvas-icons]), or a few local paths in the same style.
export const LOCAL_ICONS = {
minus: ['M5 12h14'],
fit: ['M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5'],
back: ['M19 12H5', 'M11 6l-6 6 6 6'], // the environment canvas back arrow
fold: ['M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z', 'M9 4v16'], // the sidebar toggle in the navbar
};
export const uid = () => Math.random().toString(36).slice(2, 10);
const b64 = text => btoa(unescape(encodeURIComponent(text)));
export const load = (key, fallback) => {
try { return JSON.parse(localStorage.getItem(`${STORE}:${key}`)) ?? fallback; } catch { return fallback; }
};
export const save = (key, value) => localStorage.setItem(`${STORE}:${key}`, JSON.stringify(value));
export const sleep = ms => new Promise(r => setTimeout(r, ms));
export const ICONS = {
start: 'projects',
server: 'servers',
git: 'sources',
image: 'cloud',
file: 'file-content',
layers: 'layers',
db: 'database',
sliders: 'sliders',
variables: 'variables',
globe: 'globe',
deploy: 'play-circle',
x: 'x',
plus: 'plus',
minus: 'minus',
fit: 'fit',
out: 'external-link',
fold: 'fold',
check: 'check',
chevron: 'chevron-down',
info: 'info-circle',
};
// Cached option lists for the node fields (key → array; loading state).
// onChange runs when a list loads or fails (the canvas redraws its fields).
export const lists = { cache: {}, pending: {}, errors: {}, onChange: () => {} };
function options(key, fetcher) {
const { cache, pending, errors } = lists;
if (cache[key]) return cache[key];
if (!pending[key]) {
pending[key] = fetcher().then(list => {
cache[key] = list;
delete errors[key];
}).catch(error => {
errors[key] = error.message;
}).finally(() => {
delete pending[key];
lists.onChange();
});
}
return null;
}
// The cache key a select loads from (to show its error).
export function loaderKey(f, d) {
const map = { project: 'projects', environment: `envs:${d.project}`, server: 'servers', destination: `dest:${d.server}`, ghApp: 'gh', ghRepo: `repos:${d.ghApp}`, ghBranch: `br:${d.ghApp}:${d.ghRepo}`, key: 'keys' };
return map[f.key];
}
const LOADERS = {
projects: () => options('projects', async () => (await api('GET', '/projects')).map(p => [p.uuid, p.name])),
envs: project => project && options(`envs:${project}`, async () => (await api('GET', `/projects/${project}/environments`)).map(e => [e.uuid, e.name])),
// Build-only servers cannot run resources (as on the New resource page).
servers: () => options('servers', async () => (await api('GET', '/servers')).filter(s => s.settings?.server_role !== 'build').map(s => [s.uuid, `${s.name}${s.is_reachable === false ? ' (unreachable)' : ''}`])),
destinations: server => server && options(`dest:${server}`, async () => (await api('GET', `/servers/${server}/destinations`)).map(d => [d.uuid, `${d.name} · ${d.network}`])),
ghApps: () => options('gh', async () => (await api('GET', '/github-apps')).filter(a => !a.is_public && a.app_id).map(a => [`${a.id}|${a.uuid}`, a.name])),
repos: app => app && options(`repos:${app}`, async () => (await api('GET', `/github-apps/${app.split('|')[0]}/repositories`)).repositories.map(r => [r.full_name, r.full_name])),
branches: (app, repo) => app && repo && options(`br:${app}:${repo}`, async () => (await api('GET', `/github-apps/${app.split('|')[0]}/repositories/${repo}/branches`)).branches.map(b => [b.name, b.name])),
keys: () => options('keys', async () => (await api('GET', '/security/keys')).map(k => [k.uuid, k.name])),
};
// ── Node types ─────────────────────────────────────────────────────────
// stage: start → infra → source → config → ship. kind: what a source creates.
// Select options are [value, label, badge?]; the badge shows next to the label.
const BUILD_PACKS = [['railpack', 'Railpack'], ['nixpacks', 'Nixpacks', 'Deprecated'], ['static', 'Static'], ['dockerfile', 'Dockerfile'], ['dockercompose', 'Docker Compose']];
// The PostgreSQL images of the New resource page (project/new/select.blade.php).
const POSTGRES_IMAGES = [
['postgres:18-alpine', 'PostgreSQL 18'],
['postgres:17-alpine', 'PostgreSQL 17'],
['postgres:16-alpine', 'PostgreSQL 16'],
['supabase/postgres:17.4.1.032', 'Supabase PostgreSQL'],
['postgis/postgis:17-3.5-alpine', 'PostGIS'],
['pgvector/pgvector:pg18', 'PGVector 18'],
['pgvector/pgvector:pg17', 'PGVector 17'],
];
const DATABASES = [['postgresql', 'PostgreSQL'], ['mysql', 'MySQL'], ['mariadb', 'MariaDB'], ['mongodb', 'MongoDB'], ['redis', 'Redis'], ['keydb', 'KeyDB'], ['dragonfly', 'Dragonfly'], ['clickhouse', 'ClickHouse']];
// One-click service templates ([key, name]), from the page (deploy-canvas.blade.php).
let serviceTemplates = null;
const templateOptions = () => {
if (!serviceTemplates) {
try { serviceTemplates = JSON.parse(document.querySelector('[data-deploy-canvas-services]')?.textContent || '[]'); } catch { serviceTemplates = []; }
}
return serviceTemplates;
};
// The logo of a source step: the one resource_logo() (bootstrap/helpers/services.php) and the
// New resource page show for what it creates. Service logos come from the page
// (deploy-canvas.blade.php). Null keeps the step's glyph.
// The page gives versioned URLs (resource_logo_url()), so a changed logo is not loaded from the cache.
let resourceLogos = null;
const resourceLogoUrl = name => {
if (!resourceLogos) {
try { resourceLogos = JSON.parse(globalThis.document?.querySelector('[data-deploy-canvas-resource-logos]')?.textContent || '{}'); } catch { resourceLogos = {}; }
}
return resourceLogos[name] ?? `/svgs/resources/${name}.svg`;
};
const resourceLogo = (name, hasDark = false) => ({
src: resourceLogoUrl(name), dark: hasDark ? resourceLogoUrl(`${name}-dark`) : null, fallback: null, padded: false,
});
const DATABASE_LOGOS = { postgresql: 'postgres' };
let serviceLogos = null;
const serviceLogo = key => {
if (!serviceLogos) {
try { serviceLogos = JSON.parse(document.querySelector('[data-deploy-canvas-service-logos]')?.textContent || '{}'); } catch { serviceLogos = {}; }
}
const logo = serviceLogos[key];
return logo ? { src: logo[0], dark: null, fallback: logo[1], padded: true } : null;
};
export function logoOf(n) {
const d = n.data || {};
switch (n.type) {
case 'git':
if (d.buildPack === 'dockerfile') return resourceLogo('dockerfile');
if (d.buildPack === 'dockercompose') return resourceLogo('docker-compose');
if (d.mode === 'github') return resourceLogo('github-app', true);
return resourceLogo(d.mode === 'key' ? 'deploy-key' : 'public-repo');
case 'image': return resourceLogo('docker-image');
case 'dockerfile': return resourceLogo('dockerfile');
case 'service': return d.mode === 'compose' ? resourceLogo('docker-compose') : (d.type ? serviceLogo(d.type) : null);
case 'database': return d.engine ? resourceLogo(DATABASE_LOGOS[d.engine] ?? d.engine, ['keydb', 'dragonfly'].includes(d.engine)) : null;
default: return null;
}
}
export const TYPES = {
start: {
title: 'Start', sub: 'Which project and environment', stage: 'start', icon: ICONS.start,
fields: [
// `add`: a "New …" row at the end of the list creates one and selects it.
{ key: 'project', label: 'Project', kind: 'select', load: () => LOADERS.projects(), rerender: true, required: true,
add: { label: 'New project', placeholder: 'Project name', list: () => 'projects', create: async name => (await api('POST', '/projects', { name })).uuid } },
{ key: 'environment', label: 'Environment', kind: 'select', load: d => LOADERS.envs(d.project), required: true, only: true,
add: { label: 'New environment', placeholder: 'Environment name', list: d => `envs:${d.project}`, create: async (name, d) => (await api('POST', `/projects/${d.project}/environments`, { name })).uuid } },
],
run: async (ctx, d) => {
ctx.project = d.project;
ctx.environment = d.environment;
return 'Project and environment set.';
},
},
server: {
title: 'Server', sub: 'Where it runs', stage: 'infra', icon: ICONS.server,
fields: [
{ key: 'server', label: 'Server', kind: 'select', load: () => LOADERS.servers(), rerender: true, required: true, only: true },
{ key: 'destination', label: 'Network', kind: 'select', load: d => LOADERS.destinations(d.server), optional: 'Default' },
],
run: async (ctx, d) => {
ctx.server = d.server;
ctx.destination = d.destination || undefined;
return 'Server picked.';
},
},
git: {
title: 'Git repository', sub: 'Build from source', stage: 'source', kind: 'application', icon: ICONS.git,
fields: [
{ key: 'mode', label: 'Access', kind: 'segmented', options: [['public', 'Public'], ['github', 'GitHub app'], ['key', 'Deploy key']], rerender: true, value: 'public' },
{ key: 'repository', label: 'Repository URL', kind: 'text', placeholder: 'https://github.com/coollabsio/coolify-examples', show: d => d.mode !== 'github', required: true, secret: v => /^\w+:\/\/[^/]*@/.test(v) },
{ key: 'ghApp', label: 'GitHub app', kind: 'select', load: () => LOADERS.ghApps(), show: d => d.mode === 'github', rerender: true, required: true },
{ key: 'ghRepo', label: 'Repository', kind: 'select', load: d => LOADERS.repos(d.ghApp), show: d => d.mode === 'github', rerender: true, required: true },
{ key: 'key', label: 'Deploy key', kind: 'select', load: () => LOADERS.keys(), show: d => d.mode === 'key', required: true },
{ key: 'branch', label: 'Branch', kind: 'text', placeholder: 'main', value: 'main', show: d => d.mode !== 'github', required: true },
{ key: 'ghBranch', label: 'Branch', kind: 'select', load: d => LOADERS.branches(d.ghApp, d.ghRepo), show: d => d.mode === 'github', required: true },
{ key: 'buildPack', label: 'Build pack', kind: 'select', options: BUILD_PACKS, value: 'railpack', required: true },
{ key: 'ports', label: 'Port', kind: 'text', placeholder: '3000', value: '3000' },
{ key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' },
],
run: async (ctx, d) => {
ctx.applicationSource = true;
const base = common(ctx, d);
Object.assign(base, { build_pack: d.buildPack, ports_exposes: d.ports || undefined });
let res;
if (d.mode === 'github') {
res = await api('POST', '/applications/private-github-app', { ...base, github_app_uuid: d.ghApp.split('|')[1], git_repository: d.ghRepo, git_branch: d.ghBranch });
} else if (d.mode === 'key') {
res = await api('POST', '/applications/private-deploy-key', { ...base, private_key_uuid: d.key, git_repository: d.repository, git_branch: d.branch });
} else {
res = await api('POST', '/applications/public', { ...base, git_repository: d.repository, git_branch: d.branch });
}
ctx.resource = { kind: 'application', sub: 'git', uuid: res.uuid };
ctx.createdDomains = res.domains && domainList(res.domains);
return 'Application created.';
},
},
image: {
title: 'Docker image', sub: 'Run a prebuilt image', stage: 'source', kind: 'application', icon: ICONS.image,
fields: [
{ key: 'image', label: 'Image', kind: 'text', placeholder: 'nginx', required: true },
{ key: 'tag', label: 'Tag', kind: 'text', placeholder: 'latest, or the tag in the image' },
{ key: 'ports', label: 'Port', kind: 'text', placeholder: '80', value: '80' },
{ key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' },
],
run: async (ctx, d) => {
ctx.applicationSource = true;
const { name, tag } = splitImage(d.image, d.tag);
const res = await api('POST', '/applications/dockerimage', { ...common(ctx, d), docker_registry_image_name: name, docker_registry_image_tag: tag, ports_exposes: d.ports || undefined });
ctx.resource = { kind: 'application', sub: 'image', uuid: res.uuid };
ctx.createdDomains = res.domains && domainList(res.domains);
return 'Application created.';
},
},
dockerfile: {
title: 'Dockerfile', sub: 'Build from a Dockerfile', stage: 'source', kind: 'application', icon: ICONS.file,
fields: [
{ key: 'dockerfile', label: 'Dockerfile', kind: 'textarea', placeholder: 'FROM nginx:alpine\nCOPY . /usr/share/nginx/html', required: true },
{ key: 'ports', label: 'Port', kind: 'text', placeholder: '80', value: '80' },
{ key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' },
],
run: async (ctx, d) => {
ctx.applicationSource = true;
const res = await api('POST', '/applications/dockerfile', { ...common(ctx, d), dockerfile: b64(d.dockerfile), ports_exposes: d.ports || undefined });
ctx.resource = { kind: 'application', sub: 'dockerfile', uuid: res.uuid };
ctx.createdDomains = res.domains && domainList(res.domains);
return 'Application created.';
},
},
service: {
title: 'Service', sub: 'One-click template or compose', stage: 'source', kind: 'service', icon: ICONS.layers,
fields: [
{ key: 'mode', label: 'From', kind: 'segmented', options: [['template', 'Template'], ['compose', 'Compose']], rerender: true, value: 'template' },
{ key: 'type', label: 'Template', kind: 'select', load: () => templateOptions(), show: d => d.mode !== 'compose', required: true },
{ key: 'compose', label: 'docker-compose.yml', kind: 'textarea', placeholder: 'services:\n app:\n image: nginx', show: d => d.mode === 'compose', required: true, secret: true },
{ key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' },
],
run: async (ctx, d) => {
const body = common(ctx, d);
if (d.mode === 'compose') body.docker_compose_raw = b64(d.compose);
else body.type = d.type.trim();
const res = await api('POST', '/services', body);
ctx.resource = { kind: 'service', uuid: res.uuid };
return 'Service created.';
},
},
database: {
title: 'Database', sub: 'A managed database', stage: 'source', kind: 'database', icon: ICONS.db,
fields: [
// A new engine starts with its default image (PostgreSQL: PostgreSQL 18).
{ key: 'engine', label: 'Engine', kind: 'select', options: DATABASES, value: 'postgresql', required: true,
after: d => { d.image = d.engine === 'postgresql' ? POSTGRES_IMAGES[0][0] : ''; } },
{ key: 'image', label: 'Image', kind: 'select', options: POSTGRES_IMAGES, value: POSTGRES_IMAGES[0][0], show: d => d.engine === 'postgresql', required: true },
{ key: 'image', label: 'Image', kind: 'text', placeholder: 'Default for the engine', show: d => d.engine !== 'postgresql' },
{ key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' },
],
run: async (ctx, d) => {
const body = common(ctx, d);
if (d.image) body.image = d.image;
const res = await api('POST', `/databases/${d.engine}`, body);
ctx.resource = { kind: 'database', uuid: res.uuid };
return 'Database created.';
},
},
build: {
title: 'Build settings', sub: 'Directories and commands', stage: 'config', icon: ICONS.sliders, for: ['git'],
fields: [
{ key: 'base_directory', label: 'Base directory', kind: 'text', placeholder: '/' },
{ key: 'publish_directory', label: 'Publish directory', kind: 'text', placeholder: '/dist' },
{ key: 'install_command', label: 'Install command', kind: 'text', placeholder: 'Detected' },
{ key: 'build_command', label: 'Build command', kind: 'text', placeholder: 'Detected' },
{ key: 'start_command', label: 'Start command', kind: 'text', placeholder: 'Detected' },
],
run: async (ctx, d) => {
const body = Object.fromEntries(['base_directory', 'publish_directory', 'install_command', 'build_command', 'start_command'].filter(k => d[k]).map(k => [k, d[k]]));
if (!Object.keys(body).length) return 'Nothing to change.';
await api('PATCH', `/applications/${ctx.resource.uuid}`, body);
return 'Build settings saved.';
},
},
env: {
title: 'Environment variables', sub: 'KEY=value, one per line', stage: 'config', icon: ICONS.variables, for: ['application', 'service'],
fields: [
{ key: 'vars', label: 'Variables', kind: 'textarea', placeholder: 'NODE_ENV=production\nAPI_URL=https://api.example.com', required: true, mono: true, secret: true },
],
run: async (ctx, d) => {
const data = parseEnvVars(d.vars);
if (!data.length) return 'No variables.';
const kind = ctx.resource.kind === 'service' ? 'services' : 'applications';
await api('PATCH', `/${kind}/${ctx.resource.uuid}/envs/bulk`, { data });
return `${data.length} variable${data.length === 1 ? '' : 's'} saved.`;
},
},
domain: {
title: 'Domains', sub: 'Your own public addresses', stage: 'config', icon: ICONS.globe, for: ['git', 'image', 'dockerfile'],
fields: [
{ key: 'domains', label: 'Domains', kind: 'textarea', placeholder: 'https://app.example.com', required: true, mono: true, rows: 2 },
{ key: 'note', kind: 'note', text: 'Without this step, Coolify makes an address from the server’s wildcard domain (or sslip.io).' },
],
run: async (ctx, d) => {
const domains = domainList(d.domains);
if (ctx.createdDomains === domains) return `Set at creation: ${domains.replace(/,/g, ', ')}.`;
const res = await api('PATCH', `/applications/${ctx.resource.uuid}`, { domains });
return `Domains saved${res?.uuid ? '' : ''}: ${domains.replace(/,/g, ', ')}.`;
},
},
deploy: {
title: 'Deploy', sub: 'Save or deploy every resource', stage: 'ship', icon: ICONS.deploy,
fields: [],
},
};
// "nginx:alpine" → nginx + alpine; "ghcr.io:443/a/b@sha256:…" → digest tag
// (Coolify's "sha256-…" form). A tag in the image wins over the Tag field.
export function splitImage(image, tagField) {
let name = String(image || '').trim();
let tag = '';
const at = name.indexOf('@sha256:');
if (at >= 0) {
tag = `sha256-${name.slice(at + 8)}`;
name = name.slice(0, at);
} else {
const colon = name.lastIndexOf(':');
if (colon > name.lastIndexOf('/')) {
tag = name.slice(colon + 1);
name = name.slice(0, colon);
}
}
return { name, tag: tag || String(tagField || '').trim() || 'latest' };
}
// Fields common to every create call. Domains from a Domains step later in
// the chain go in at creation (with no generated domain), as Coolify's
// create endpoints accept them.
function common(ctx, d) {
const body = { project_uuid: ctx.project, environment_uuid: ctx.environment, server_uuid: ctx.server, instant_deploy: false };
if (ctx.destination) body.destination_uuid = ctx.destination;
if (d.name) body.name = d.name;
if (ctx.domains && ctx.applicationSource) {
body.domains = ctx.domains;
body.autogenerate_domain = false;
}
return body;
}
// "KEY=value" lines → [{ key, value }]; skips blanks and # comments, strips quotes.
export function parseEnvVars(text) {
return String(text || '').split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('#') && line.includes('=')).map(line => {
const i = line.indexOf('=');
return { key: line.slice(0, i).trim(), value: line.slice(i + 1).trim().replace(/^(['"])(.*)\1$/, '$2') };
});
}
export const domainList = text => String(text || '').split(/[\n,]/).map(v => v.trim()).filter(Boolean).join(',');
export const PALETTE = [
['Infrastructure', ['server']],
['Source', ['git', 'image', 'dockerfile', 'service', 'database']],
['Configure', ['build', 'env', 'domain']],
];
// ── Flow state ─────────────────────────────────────────────────────────
// Every deploy needs a server, so a new canvas has one, already connected
// (id 'server': it cannot be removed, nor its connection to Start).
export const fresh = () => withDeploy({
nodes: [{ id: 'start', type: 'start', x: 80, y: 160, data: {} }, { id: 'server', type: 'server', x: 456, y: 160, data: {} }],
edges: [{ from: 'start', to: 'server' }],
});
// A select with `only: true` takes its option when it has exactly one.
export const onlyOption = (list, value) => (!value && list?.length === 1 ? list[0][0] : value);
// Every flow ends in one Deploy step. Resource lines reach it on their own, so
// edges into it (from older saved flows) are dropped.
export function withDeploy(f) {
if (!f.nodes.some(n => n.type === 'deploy')) f.nodes.push({ id: 'deploy', type: 'deploy', x: 1240, y: 160, data: {} });
const deployIds = new Set(f.nodes.filter(n => n.type === 'deploy').map(n => n.id));
f.edges = f.edges.filter(e => !deployIds.has(e.to) && !deployIds.has(e.from));
return f;
}
// The environment that opened the canvas (?project=…&environment=…, from the
// New resource page), or null. Both values must look like UUIDs.
export function targetFrom(search) {
const params = new URLSearchParams(search);
const project = params.get('project');
const environment = params.get('environment');
const valid = value => /^[A-Za-z0-9_-]+$/.test(value ?? '');
return valid(project) && valid(environment) ? { project, environment } : null;
}
// The Start step targets that environment, also in a flow restored from an earlier visit.
export function withTarget(f, t) {
const start = f.nodes.find(n => n.type === 'start');
if (t && start) start.data = { project: t.project, environment: t.environment };
return f;
}
// Secret fields (`secret: true`, or a test on the value) stay in memory only;
// they are never written to localStorage.
export function withoutSecrets(f) {
const nodes = f.nodes.map(n => {
const data = { ...n.data };
for (const { key, secret } of TYPES[n.type]?.fields || []) {
if (secret === true || (typeof secret === 'function' && data[key] && secret(data[key]))) delete data[key];
}
return { ...n, data };
});
return { ...f, nodes };
}
// The flow is a tree: Start → Servers → one line per resource (source →
// settings…), and every line ends in the one Deploy step.
export const nodeById = (id, f) => f.nodes.find(n => n.id === id);
export const outsOf = (id, f) => f.edges.filter(e => e.from === id).map(e => e.to);
export const nextOf = (id, f) => outsOf(id, f)[0];
export const prevOf = (id, f) => f.edges.find(e => e.to === id)?.from;
// Start and Server fan out; only Deploy takes more than one line in.
export const multiOut = n => ['start', 'infra'].includes(TYPES[n.type].stage);
// The source node above a node in its line (what the settings apply to).
export function sourceAbove(id, f) {
const seen = new Set();
for (let cur = id; cur && !seen.has(cur); cur = prevOf(cur, f)) {
seen.add(cur);
const n = nodeById(cur, f);
if (n && TYPES[n.type].stage === 'source') return n;
}
return null;
}
// Can a `type` step come after `prev`? With `target`, also: the link makes no loop.
export function canFollow(prev, type, target, f) {
const t = TYPES[type];
const p = TYPES[prev.type];
if (type === 'start' || type === 'deploy' || prev.type === 'deploy') return false;
if (target) {
const seen = new Set();
for (let cur = prev.id; cur && !seen.has(cur); cur = prevOf(cur, f)) {
if (cur === target.id) return false;
seen.add(cur);
}
}
if (p.stage === 'start') return type === 'server';
if (p.stage === 'infra') return t.stage === 'source';
const src = sourceAbove(prev.id, f);
if (!src) return false;
if (t.stage === 'ship') return true;
if (t.stage !== 'config') return false;
const sub = src.type === 'git' || src.type === 'image' || src.type === 'dockerfile' ? src.type : null;
return t.for.includes(TYPES[src.type].kind) || (sub !== null && t.for.includes(sub));
}
// What a run does: each resource line under each Server under Start.
export function planOf(f) {
const servers = outsOf('start', f).map(id => nodeById(id, f)).filter(n => n?.type === 'server');
const lines = [];
for (const server of servers) {
for (const id of outsOf(server.id, f)) {
const source = nodeById(id, f);
if (!source || TYPES[source.type].stage !== 'source') continue;
const settings = [];
const seen = new Set([source.id]);
for (let cur = nextOf(source.id, f); cur && !seen.has(cur); cur = nextOf(cur, f)) {
seen.add(cur);
const n = nodeById(cur, f);
if (!n) break;
settings.push(n);
}
lines.push({ server, source, settings, end: settings.at(-1) || source });
}
}
return { start: nodeById('start', f), servers, lines, deploy: f.nodes.find(n => n.type === 'deploy') || null };
}
// Source id → label; same labels get a number ("Docker image · nginx #2").
export function uniqueLabels(sources) {
const labels = sources.map(resourceLabel);
const seen = {};
return new Map(sources.map((src, i) => {
const label = labels[i];
if (labels.filter(l => l === label).length < 2) return [src.id, label];
seen[label] = (seen[label] || 0) + 1;
return [src.id, `${label} #${seen[label]}`];
}));
}
// "Docker image · nginx": what a source step makes, for lists and logs.
export function resourceLabel(src) {
const d = src.data;
const detail = d.name || {
git: d.mode === 'github' ? d.ghRepo : d.repository,
image: d.image,
service: d.mode === 'compose' ? 'compose' : d.type,
database: d.engine,
}[src.type];
return detail ? `${TYPES[src.type].title} · ${detail}` : TYPES[src.type].title;
}
export function defaults(type) {
const data = {};
for (const f of TYPES[type].fields) if (f.value !== undefined) data[f.key] = f.value;
return data;
}
// The Coolify page of a created resource (its deployment, while one runs).
export function resourceHref(ctx) {
const base = `/project/${ctx.project}/environment/${ctx.environment}`;
const kind = { application: 'application', service: 'service', database: 'database' }[ctx.resource.kind];
return ctx.deployment && kind === 'application' ? `${base}/application/${ctx.resource.uuid}/deployment/${ctx.deployment}` : `${base}/${kind}/${ctx.resource.uuid}`;
}