import yaml from "js-yaml"; import type { AppComposeData } from "@/modules/apps/hooks/useAppCompose"; import type { StoreAppDetail } from "@/lib/shared/contracts/apps"; export type ConfiguratorView = "classic" | "compose" | "docker_run"; export type PortProtocol = "TCP" | "UDP" | "TCP + UDP"; export type PortRow = { id: string; host: string; container: string; protocol: PortProtocol; }; export type VolumeRow = { id: string; host: string; container: string; }; export type EnvRow = { id: string; key: string; value: string; }; export type WebUiState = { protocol: "http" | "https"; host: string; port: string; path: string; }; export type ClassicConfigState = { dockerImage: string; title: string; iconUrl: string; webUi: WebUiState; /** * Operator-set link for the app tile. Empty means "build it automatically". * Kept apart from webUi, which composes the container's APP_URL. */ webUiLink: string; network: string; ports: PortRow[]; volumes: VolumeRow[]; envVars: EnvRow[]; privileged: boolean; restartPolicy: string; capabilities: string; hostname: string; devices: string[]; containerCommands: string[]; }; export type DockerRunState = { name: string; iconUrl: string; repositoryUrl: string; source: string; }; type ComposeLike = { services?: Record>; [key: string]: unknown; }; type PrimaryServiceSelectionInput = { compose: ComposeLike; appId?: string; primaryServiceName?: string; }; const INFRA_SERVICE_TOKEN_PATTERN = /(?:^|[-_.])(db|database|postgres|postgresql|mariadb|mysql|redis|cache|broker|queue|rabbitmq|kafka|zookeeper|mongo|mongodb|elasticsearch|minio)(?:$|[-_.])/i; const APP_ALIAS_PATTERN = /^(app|main|server|web|frontend|backend)$/i; function nextId(prefix: string) { return `${prefix}-${Math.random().toString(36).slice(2, 8)}`; } function parsePortValue(port: string) { if (!port.trim()) return undefined; const parsed = Number.parseInt(port, 10); if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) { return undefined; } return parsed; } function toWebUiUrl(webUi: WebUiState) { const host = webUi.host.trim(); const port = webUi.port.trim(); if (!host || !port) return undefined; const normalizedPath = webUi.path.trim(); const path = normalizedPath ? normalizedPath.startsWith("/") ? normalizedPath : `/${normalizedPath}` : ""; return `${webUi.protocol}://${host}:${port}${path}`; } function isLoopbackHost(host: string) { const normalized = host.trim().toLowerCase(); return ( normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]" ); } function resolveWebUiHost(host: string | undefined, fallbackHost?: string) { const trimmedHost = host?.trim(); const trimmedFallback = fallbackHost?.trim(); if (!trimmedHost) { return trimmedFallback || "localhost"; } if (trimmedFallback && isLoopbackHost(trimmedHost)) { return trimmedFallback; } return trimmedHost; } function parseEnvironment(environment: unknown): Record { if (Array.isArray(environment)) { const map: Record = {}; for (const item of environment) { if (typeof item !== "string") continue; const [key, ...rest] = item.split("="); if (!key) continue; map[key] = rest.join("="); } return map; } if (environment && typeof environment === "object") { return Object.fromEntries( Object.entries(environment as Record).map( ([key, value]) => [key, typeof value === "string" ? value : String(value ?? "")], ), ); } return {}; } function parseWebUi( appUrl: string | undefined, fallbackPort: string, fallbackHost?: string, ): WebUiState { if (!appUrl) { return { protocol: "http" as const, host: resolveWebUiHost(undefined, fallbackHost), port: fallbackPort, path: "", }; } try { const parsed = new URL(appUrl); const protocol: WebUiState["protocol"] = parsed.protocol.replace(":", "") === "https" ? "https" : "http"; return { protocol, host: resolveWebUiHost(parsed.hostname, fallbackHost), port: parsed.port || fallbackPort, path: parsed.pathname === "/" ? "" : parsed.pathname, }; } catch { return { protocol: "http" as const, host: resolveWebUiHost(undefined, fallbackHost), port: fallbackPort, path: "", }; } } function parsePortMapping(value: string): Omit | null { const trimmed = value.trim().replace(/^['"]|['"]$/g, ""); if (!trimmed) return null; const [mappingPart, protocolPart] = trimmed.split("/"); const protocol = protocolPart?.toLowerCase() === "udp" ? "UDP" : "TCP"; const segments = mappingPart.split(":").filter(Boolean); let host = ""; let container = ""; if (segments.length === 1) { host = segments[0] || ""; container = segments[0] || ""; } else if (segments.length >= 2) { host = segments[segments.length - 2] || ""; container = segments[segments.length - 1] || ""; } if (!host || !container) return null; return { host, container, protocol, } satisfies Omit; } function parsePorts(ports: unknown): PortRow[] { if (!Array.isArray(ports)) return []; return ports .map((item, index) => { let parsed: Omit | null = null; if (typeof item === "string") { parsed = parsePortMapping(item); } else if (item && typeof item === "object") { const candidate = item as { published?: unknown; target?: unknown; protocol?: unknown; }; const host = candidate.published === undefined || candidate.published === null ? "" : String(candidate.published).trim(); const container = candidate.target === undefined || candidate.target === null ? "" : String(candidate.target).trim(); const protocol = typeof candidate.protocol === "string" && candidate.protocol.toLowerCase() === "udp" ? "UDP" : "TCP"; if (host && container) { parsed = { host, container, protocol, }; } } if (!parsed) return null; return { id: nextId(`port-${index}`), ...parsed, } satisfies PortRow; }) .filter((item): item is PortRow => item !== null); } function parseVolumeMapping(value: string) { const trimmed = value.trim().replace(/^['"]|['"]$/g, ""); if (!trimmed) return null; const segments = trimmed.split(":"); if (segments.length < 2) return null; const host = segments[0] || ""; const container = segments[1] || ""; if (!host || !container) return null; return { host, container, } satisfies Omit; } function parseVolumes(volumes: unknown): VolumeRow[] { if (!Array.isArray(volumes)) return []; return volumes .map((item, index) => { let parsed: Omit | null = null; if (typeof item === "string") { parsed = parseVolumeMapping(item); } else if (item && typeof item === "object") { const candidate = item as { source?: unknown; target?: unknown; }; const host = candidate.source === undefined || candidate.source === null ? "" : String(candidate.source).trim(); const container = candidate.target === undefined || candidate.target === null ? "" : String(candidate.target).trim(); if (container) { parsed = { host, container, }; } } if (!parsed) return null; return { id: nextId(`volume-${index}`), ...parsed, } satisfies VolumeRow; }) .filter((item): item is VolumeRow => item !== null); } function parseStringArray(input: unknown): string[] { if (!Array.isArray(input)) return []; return input.filter((entry): entry is string => typeof entry === "string").map((entry) => entry.trim()).filter(Boolean); } function parseCommand(input: unknown): string[] { if (Array.isArray(input)) { return input .filter((entry): entry is string => typeof entry === "string") .map((entry) => entry.trim()) .filter(Boolean); } if (typeof input === "string" && input.trim().length > 0) { return [input.trim()]; } return []; } function hasPublishedPorts(input: Record): boolean { const ports = input.ports; if (!Array.isArray(ports)) { return false; } return ports.some((entry) => { if (typeof entry === "string") { return entry.trim().length > 0; } if (!entry || typeof entry !== "object") { return false; } const candidate = entry as { target?: unknown; published?: unknown }; return Boolean(candidate.target ?? candidate.published); }); } function scorePrimaryServiceCandidate(input: { name: string; service: Record; appId?: string; }): number { const name = input.name.toLowerCase(); const appId = input.appId?.toLowerCase().trim(); const image = typeof input.service.image === "string" ? input.service.image.toLowerCase() : ""; let score = 0; if (hasPublishedPorts(input.service)) { score += 40; } if (name === "app") { score += 60; } else if (APP_ALIAS_PATTERN.test(name)) { score += 30; } if (name.includes("server")) { score += 20; } if (appId) { if (name === appId) { score += 100; } else if (name.startsWith(`${appId}-`) || name.endsWith(`-${appId}`)) { score += 45; } else if (name.includes(appId)) { score += 25; } if (image.includes(appId)) { score += 20; } } if (INFRA_SERVICE_TOKEN_PATTERN.test(name)) { score -= 80; } return score; } function getPrimaryService( input: PrimaryServiceSelectionInput, ): { name: string; service: Record } { const { compose, appId, primaryServiceName } = input; const services = compose.services ?? {}; const names = Object.keys(services); if (names.length === 0) { return { name: "app", service: {}, }; } if (primaryServiceName && services[primaryServiceName]) { return { name: primaryServiceName, service: services[primaryServiceName] ?? {}, }; } if (appId) { const normalizedAppId = appId.toLowerCase(); const exactMatch = names.find((name) => name.toLowerCase() === normalizedAppId); if (exactMatch) { return { name: exactMatch, service: services[exactMatch] ?? {}, }; } } const rankedServices = names .map((name, index) => ({ name, index, score: scorePrimaryServiceCandidate({ name, service: services[name] ?? {}, appId, }), })) .sort((a, b) => b.score - a.score || a.index - b.index); const name = rankedServices[0]?.name ?? names[0]; return { name, service: services[name] ?? {}, }; } function toEnvRows(environment: Record) { return Object.entries(environment) .filter(([key]) => key !== "APP_URL" && key !== "VOLUMES") .map(([key, value], index) => ({ id: nextId(`env-${index}`), key, value, } satisfies EnvRow)); } function toEnvPayload(state: ClassicConfigState) { const env = Object.fromEntries( state.envVars .filter((row) => row.key.trim().length > 0) .map((row) => [row.key.trim(), row.value]), ); const appUrl = toWebUiUrl(state.webUi); if (appUrl) { env.APP_URL = appUrl; } return env; } function composeFromObject(compose: ComposeLike) { return yaml.dump(compose, { lineWidth: -1, noRefs: true, sortKeys: false, }); } export function toAppId(value: string) { return value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/-{2,}/g, "-") .replace(/^-+|-+$/g, ""); } function toHostname(value: string) { const normalized = toAppId(value); return normalized.length > 0 ? normalized : "app"; } export function createDefaultClassicState(seed: { title: string; iconUrl: string; fallbackPort?: number | null; fallbackHost?: string; webUiLink?: string | null; }): ClassicConfigState { const fallbackPort = seed.fallbackPort ? String(seed.fallbackPort) : ""; return { dockerImage: "nginx:latest", title: seed.title, iconUrl: seed.iconUrl, webUi: { protocol: "http", host: resolveWebUiHost(undefined, seed.fallbackHost), port: fallbackPort, path: "", }, webUiLink: seed.webUiLink?.trim() ?? "", network: "bridge", ports: fallbackPort ? [ { id: nextId("port-0"), host: fallbackPort, container: fallbackPort, protocol: "TCP", }, ] : [], volumes: [], envVars: [], privileged: false, restartPolicy: "always", capabilities: "", hostname: toHostname(seed.title || "app"), devices: [], containerCommands: [], }; } export function buildInitialComposeDraft(input: { appId: string; template?: StoreAppDetail; composeData?: AppComposeData; dockerImageFallback?: string; }) { const { composeData, template, appId, dockerImageFallback } = input; const installed = template?.installedConfig; const baseEnv = installed?.env ?? {}; const service: Record = { image: composeData?.image || baseEnv.DOCKER_IMAGE || baseEnv.IMAGE || dockerImageFallback || `${appId}/${appId}:latest`, }; if (composeData?.ports && composeData.ports.length > 0) { service.ports = composeData.ports; } else if (template?.webUiPort) { service.ports = [`${template.webUiPort}:${template.webUiPort}`]; } if (composeData?.volumes && composeData.volumes.length > 0) { service.volumes = composeData.volumes; } const environment = { ...(composeData?.environment ?? {}), ...baseEnv, }; if (Object.keys(environment).length > 0) { service.environment = environment; } if (composeData?.networkMode || baseEnv.NETWORK_MODE) { service.network_mode = composeData?.networkMode ?? baseEnv.NETWORK_MODE; } if (composeData?.restart || baseEnv.RESTART_POLICY) { service.restart = composeData?.restart ?? baseEnv.RESTART_POLICY; } if (typeof composeData?.privileged === "boolean") { service.privileged = composeData.privileged; } else if (baseEnv.PRIVILEGED) { service.privileged = ["true", "1", "yes"].includes(baseEnv.PRIVILEGED.toLowerCase()); } if (composeData?.capAdd && composeData.capAdd.length > 0) { service.cap_add = composeData.capAdd; } else if (baseEnv.CAP_ADD) { service.cap_add = baseEnv.CAP_ADD.split(",").map((entry) => entry.trim()).filter(Boolean); } if (composeData?.hostname || installed?.stackName) { service.hostname = composeData?.hostname ?? installed?.stackName; } if (composeData?.devices && composeData.devices.length > 0) { service.devices = composeData.devices; } if (composeData?.command) { service.command = composeData.command; } return composeFromObject({ services: { app: service, }, }); } export function composeToClassicState(input: { composeDraft: string; seed: { title: string; iconUrl: string; fallbackPort?: number | null; fallbackHost?: string; webUiLink?: string | null; }; appId?: string; primaryServiceName?: string; }): ClassicConfigState { const parsed = yaml.load(input.composeDraft) as ComposeLike; if (!parsed || typeof parsed !== "object") { throw new Error("Compose is empty or invalid"); } const { service } = getPrimaryService({ compose: parsed, appId: input.appId, primaryServiceName: input.primaryServiceName, }); const environment = parseEnvironment(service.environment); const ports = parsePorts(service.ports); const fallbackPort = ports[0]?.host || (input.seed.fallbackPort ? String(input.seed.fallbackPort) : ""); const webUi = parseWebUi(environment.APP_URL, fallbackPort, input.seed.fallbackHost); return { dockerImage: typeof service.image === "string" && service.image.length > 0 ? service.image : "", title: input.seed.title, iconUrl: input.seed.iconUrl, webUi, webUiLink: input.seed.webUiLink?.trim() ?? "", network: typeof service.network_mode === "string" && service.network_mode.length > 0 ? service.network_mode : "bridge", ports, volumes: parseVolumes(service.volumes), envVars: toEnvRows(environment), privileged: Boolean(service.privileged), restartPolicy: typeof service.restart === "string" && service.restart.length > 0 ? service.restart : "always", capabilities: parseStringArray(service.cap_add).join(","), hostname: typeof service.hostname === "string" && service.hostname.length > 0 ? service.hostname : toHostname(input.seed.title || "app"), devices: parseStringArray(service.devices), containerCommands: parseCommand(service.command), }; } export function safeComposeToClassicState(input: { composeDraft: string; seed: { title: string; iconUrl: string; fallbackPort?: number | null; fallbackHost?: string; webUiLink?: string | null; }; appId?: string; primaryServiceName?: string; }): { state: ClassicConfigState | null; error: string | null; } { try { return { state: composeToClassicState(input), error: null, }; } catch (error) { return { state: null, error: error instanceof Error ? error.message : "Invalid docker compose", }; } } export function classicStateToCompose( state: ClassicConfigState, previousComposeDraft?: string, options?: { appId?: string; primaryServiceName?: string; }, ) { let compose: ComposeLike = { services: { app: {}, }, }; if (previousComposeDraft) { try { const parsed = yaml.load(previousComposeDraft) as ComposeLike; if (parsed && typeof parsed === "object") { compose = parsed; } } catch { // Keep fallback skeleton when old compose is invalid. } } if (!compose.services) { compose.services = {}; } const serviceInfo = getPrimaryService({ compose, appId: options?.appId, primaryServiceName: options?.primaryServiceName, }); const previousService = serviceInfo.service; const nextService: Record = { ...previousService, image: state.dockerImage, environment: toEnvPayload(state), network_mode: state.network, restart: state.restartPolicy, privileged: state.privileged, hostname: state.hostname, }; const normalizedPorts = state.network.trim().toLowerCase() === "host" ? [] : state.ports .filter((row) => row.host.trim() && row.container.trim()) .map((row) => row.protocol === "UDP" ? `${row.host}:${row.container}/udp` : `${row.host}:${row.container}`, ); if (normalizedPorts.length > 0) { nextService.ports = normalizedPorts; } else { delete nextService.ports; } const normalizedVolumes = state.volumes .filter((row) => row.host.trim() && row.container.trim()) .map((row) => `${row.host}:${row.container}`); if (normalizedVolumes.length > 0) { nextService.volumes = normalizedVolumes; } else { delete nextService.volumes; } const capAdd = state.capabilities .split(",") .map((entry) => entry.trim()) .filter(Boolean); if (capAdd.length > 0) { nextService.cap_add = capAdd; } else { delete nextService.cap_add; } const devices = state.devices.map((entry) => entry.trim()).filter(Boolean); if (devices.length > 0) { nextService.devices = devices; } else { delete nextService.devices; } const command = state.containerCommands.map((entry) => entry.trim()).filter(Boolean); if (command.length === 1) { nextService.command = command[0]; } else if (command.length > 1) { nextService.command = command; } else { delete nextService.command; } compose.services[serviceInfo.name] = nextService; return composeFromObject(compose); } export function buildInstallPayloadFromClassic(input: { appId: string; state: ClassicConfigState; composeSource?: string; }) { const payload = { appId: input.appId, displayName: input.state.title.trim() || undefined, env: toEnvPayload(input.state), webUiPort: parsePortValue(input.state.webUi.port), composeSource: input.composeSource, }; return payload; } export function buildSettingsPayloadFromClassic(input: { appId: string; current: ClassicConfigState; initial: ClassicConfigState; composeSource?: string; initialComposeSource?: string; }) { const payload: { appId: string; displayName: string; iconUrl: string | null; env?: Record; webUiPort?: number; webUiUrl?: string | null; composeSource?: string; } = { appId: input.appId, displayName: input.current.title, iconUrl: input.current.iconUrl.trim() || null, }; const nextCompose = input.composeSource?.trim(); const initialCompose = input.initialComposeSource?.trim(); if (nextCompose && nextCompose.length > 0 && nextCompose !== initialCompose) { payload.composeSource = input.composeSource; } const currentEnv = toEnvPayload(input.current); const initialEnv = toEnvPayload(input.initial); if (JSON.stringify(currentEnv) !== JSON.stringify(initialEnv)) { payload.env = currentEnv; } const currentPort = parsePortValue(input.current.webUi.port); const initialPort = parsePortValue(input.initial.webUi.port); if (currentPort !== undefined && currentPort !== initialPort) { payload.webUiPort = currentPort; } // The link override only changes what we render, never the compose file, so // it travels on its own and does not trigger a redeploy. const currentLink = input.current.webUiLink.trim(); if (currentLink !== input.initial.webUiLink.trim()) { // Null clears the override and restores the automatic link. payload.webUiUrl = currentLink.length > 0 ? currentLink : null; } return payload; } export function buildInitialDockerRunState(input: { title: string; iconUrl: string; port: string; }) { const appName = input.title.trim() || "my-app"; const webUiPort = input.port.trim() || "8080"; return { name: appName, iconUrl: input.iconUrl, repositoryUrl: "", source: `docker run --name ${toAppId(appName)} -p ${webUiPort}:80 nginx:latest`, } satisfies DockerRunState; }