1 Commits

Author SHA1 Message Date
affc645b95 Fixing README TOC
Links all had a leading -
2025-11-19 11:59:43 -06:00
33 changed files with 2116 additions and 5160 deletions

View File

@@ -63,7 +63,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix=sha-
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image to Docker Hub

View File

@@ -21,13 +21,13 @@ A web-based tool for building, managing, and converting Docker Compose files, co
## Table of Contents
- [Features](#-features)
- [Quick Start](#-quick-start)
- [Deployment Options](#-deployment-options)
- [Usage Guide](#-usage-guide)
- [Tech Stack](#-tech-stack)
- [Contributing](#-contributing)
- [License](#-license)
- [Features](#features)
- [Quick Start](#quick-start)
- [Deployment Options](#deployment-options)
- [Usage Guide](#usage-guide)
- [Tech Stack](#tech-stack)
- [Contributing](#contributing)
- [License](#license)
---

7
package-lock.json generated
View File

@@ -8,7 +8,6 @@
"dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@hookform/resolvers": "^5.1.1",
"@iarna/toml": "^2.2.5",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
@@ -1368,12 +1367,6 @@
"react-hook-form": "^7.55.0"
}
},
"node_modules/@iarna/toml": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz",
"integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==",
"license": "ISC"
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",

View File

@@ -12,7 +12,6 @@
"dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@hookform/resolvers": "^5.1.1",
"@iarna/toml": "^2.2.5",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",

View File

@@ -1,21 +1,16 @@
import React, { useState, useMemo } from "react";
import React, { useState } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { hyperLink } from '@uiw/codemirror-extensions-hyper-link';
import {yaml} from "@codemirror/lang-yaml";
import { Button } from "./ui/button";
import { monokaiDimmed } from '@uiw/codemirror-theme-monokai-dimmed';
import { useTheme } from "./ThemeProvider";
import { Check, Copy } from "lucide-react";
interface CodeEditorProps {
content: string;
onContentChange: (value: string) => void;
width?: number | string;
height?: number | string;
editable?: boolean;
showCopyButton?: boolean;
minHeight?: number;
maxHeight?: number;
width?: number;
height?: number;
}
export const CodeEditor: React.FC<CodeEditorProps> = ({
@@ -23,10 +18,6 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
onContentChange,
width,
height,
editable = false,
showCopyButton = true,
minHeight = 200,
maxHeight,
}) => {
const [copied, setCopied] = useState(false);
const { theme } = useTheme();
@@ -35,7 +26,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
setTimeout(() => setCopied(false), 1200);
} catch (e) {
setCopied(false);
}
@@ -43,87 +34,57 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
// Determine which theme to use
const isDark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
// For light mode, we'll use a custom style approach
const editorTheme = monokaiDimmed;
// Calculate responsive height based on content
const calculatedHeight = useMemo(() => {
if (height !== undefined) {
return typeof height === 'number' ? `${height}px` : height;
}
// Auto-calculate based on line count
const lines = content.split('\n').length;
const lineHeight = 24; // approximate line height in pixels
const calculatedPx = Math.max(minHeight, Math.min(lines * lineHeight + 40, maxHeight || 800));
return `${calculatedPx}px`;
}, [content, height, minHeight, maxHeight]);
const calculatedWidth = useMemo(() => {
if (width !== undefined) {
return typeof width === 'number' ? `${width}px` : width;
}
return '100%';
}, [width]);
return (
<div
className="relative flex flex-col overflow-hidden rounded-lg border bg-sidebar"
style={{
width: calculatedWidth,
height: calculatedHeight,
minHeight: `${minHeight}px`,
maxHeight: maxHeight ? `${maxHeight}px` : undefined,
width: width ? width : '100%',
height: height ? height : '100%',
margin: 0,
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
background: 'var(--background, #18181b)',
borderRadius: 8,
overflow: 'hidden',
position: 'relative',
}}
>
{/* Copy button in top right */}
{showCopyButton && (
<Button
variant="secondary"
size="sm"
onClick={handleCopy}
className="absolute top-2 right-2 z-10 h-8 px-3 shadow-md"
title={copied ? "Copied!" : "Copy to clipboard"}
aria-label="Copy code"
type="button"
>
{copied ? (
<>
<Check className="h-3.5 w-3.5 mr-1.5" />
Copied
</>
) : (
<>
<Copy className="h-3.5 w-3.5 mr-1.5" />
Copy
</>
)}
</Button>
)}
<div className={`flex-1 overflow-auto ${isDark ? "" : "cm-light-theme"}`}>
<Button
variant="outline"
size="icon"
onClick={handleCopy}
className="absolute top-2 right-2 z-10 border-2 border-primary shadow-lg bg-background hover:bg-primary/20 active:bg-primary/30 transition-colors"
title={copied ? "Copied!" : "Copy to clipboard"}
aria-label="Copy code"
type="button"
>
{/* Clipboard SVG icon */}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15V5a2 2 0 0 1 2-2h10"></path></svg>
</Button>
<div className={isDark ? "" : "cm-light-theme"}>
<CodeMirror
value={content}
height="100%"
width="100%"
height={height ? `${height}px` : `100%`}
width={width ? `${width}px` : `100%`}
theme={editorTheme}
editable={editable}
editable={false}
extensions={[
yaml(),
hyperLink,
]}
onChange={(value: string) => onContentChange(value)}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: editable,
highlightActiveLine: editable,
foldGutter: true,
}}
basicSetup={{ lineNumbers: true }}
style={{
fontSize: 14,
fontFamily: 'monospace',
}}
flex: 1,
minHeight: 0,
minWidth: 0,
fontSize: 16,
}}
/>
</div>
</div>

View File

@@ -1,135 +0,0 @@
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "./ui/dialog";
import { Checkbox } from "./ui/checkbox";
import { Label } from "./ui/label";
import { Copy, Download } from "lucide-react";
import { copyToClipboard, downloadFile } from "../utils/clipboard";
import type { ConversionType } from "../hooks/useConversionDialog";
export interface ConversionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
conversionType: ConversionType | string;
conversionOutput: string;
clearEnvAfterDownload?: boolean;
onClearEnvChange?: (checked: boolean) => void;
onToast?: (message: { title: string; description?: string; variant?: "default" | "success" | "error" }) => void;
}
const CONVERSION_TITLES: Record<string, string> = {
"docker-run": "Docker Run Commands",
systemd: "Systemd Service Files",
env: ".env File",
redact: "Redacted Compose File",
komodo: "Komodo TOML Configuration",
};
const CONVERSION_FILENAMES: Record<string, string> = {
"docker-run": "docker-run.sh",
systemd: "docker-compose.service",
env: ".env",
redact: "docker-compose-redacted.yml",
komodo: "komodo.toml",
};
const CONVERSION_MIME_TYPES: Record<string, string> = {
"docker-run": "text/x-shellscript",
systemd: "text/plain",
env: "text/plain",
redact: "text/yaml",
komodo: "text/toml",
};
export function ConversionDialog({
open,
onOpenChange,
conversionType,
conversionOutput,
clearEnvAfterDownload = false,
onClearEnvChange,
onToast,
}: ConversionDialogProps) {
const title = CONVERSION_TITLES[conversionType] || "Conversion Output";
const filename = CONVERSION_FILENAMES[conversionType] || "output.txt";
const mimeType = CONVERSION_MIME_TYPES[conversionType] || "text/plain";
const handleCopy = async () => {
try {
await copyToClipboard(conversionOutput);
onToast?.({
title: "Copied to clipboard",
description: `${title} has been copied to your clipboard.`,
variant: "success",
});
} catch (error) {
onToast?.({
title: "Failed to copy",
description: "Could not copy to clipboard. Please try again.",
variant: "error",
});
}
};
const handleDownload = () => {
try {
downloadFile(conversionOutput, filename, mimeType);
onToast?.({
title: "Download started",
description: `Downloading ${filename}`,
variant: "success",
});
} catch (error) {
onToast?.({
title: "Download failed",
description: "Could not download file. Please try again.",
variant: "error",
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">
<pre className="p-4 bg-muted rounded-md text-sm font-mono whitespace-pre-wrap break-all">
{conversionOutput}
</pre>
</div>
{conversionType === "env" && onClearEnvChange && (
<div className="flex items-center space-x-2 py-2">
<Checkbox
id="clearEnv"
checked={clearEnvAfterDownload}
onCheckedChange={onClearEnvChange}
/>
<Label htmlFor="clearEnv" className="text-sm cursor-pointer">
Clear environment variables from compose file after download
</Label>
</div>
)}
<DialogFooter className="flex-row justify-end gap-2">
<Button variant="outline" onClick={handleCopy}>
<Copy className="h-4 w-4 mr-2" />
Copy
</Button>
<Button onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,84 +0,0 @@
import { Settings } from "lucide-react";
import { Card, CardContent } from "../ui/card";
export interface TemplateCardProps {
id: string;
name: string;
description?: string;
version?: string;
logo?: string;
tags?: string[];
onClick: () => void;
}
export function TemplateCard({
name,
description,
version,
logo,
tags,
onClick,
}: TemplateCardProps) {
return (
<Card
className="group cursor-pointer transition-all duration-200 hover:shadow-lg hover:border-primary"
onClick={onClick}
>
<CardContent className="p-4 flex flex-col gap-3 h-full">
{/* Header with logo and name */}
<div className="flex items-start gap-3">
{logo ? (
<img
src={logo}
alt={name}
className="w-12 h-12 object-contain flex-shrink-0 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
<div className="w-12 h-12 bg-muted rounded flex items-center justify-center flex-shrink-0">
<Settings className="w-6 h-6 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-base leading-tight break-words">
{name}
</h3>
{version && (
<p className="text-xs text-muted-foreground mt-0.5">
v{version}
</p>
)}
</div>
</div>
{/* Description */}
{description && (
<p className="text-sm text-muted-foreground line-clamp-2 flex-1">
{description}
</p>
)}
{/* Tags */}
{tags && tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-xs bg-primary/10 text-primary rounded-md border border-primary/20"
>
{tag}
</span>
))}
{tags.length > 3 && (
<span className="px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-md">
+{tags.length - 3}
</span>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,309 +0,0 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../ui/dialog";
import { Button } from "../ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
import { CodeEditor } from "../CodeEditor";
import { Copy, Download, Github, Globe, BookOpen } from "lucide-react";
import { copyToClipboard, downloadFile } from "../../utils/clipboard";
import { useToast } from "../ui/toast";
export interface TemplateDetails {
id: string;
name: string;
description?: string;
version?: string;
logoUrl?: string;
tags?: string[];
links?: {
github?: string;
website?: string;
docs?: string;
};
composeContent?: string;
}
export interface TemplateDetailModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
template: TemplateDetails | null;
onImport: (template: TemplateDetails) => Promise<void>;
loading?: boolean;
}
export function TemplateDetailModal({
open,
onOpenChange,
template,
onImport,
loading = false,
}: TemplateDetailModalProps) {
const [activeTab, setActiveTab] = useState("overview");
const [importing, setImporting] = useState(false);
const { toast } = useToast();
const handleCopy = async () => {
if (!template?.composeContent) return;
try {
await copyToClipboard(template.composeContent);
toast({
title: "Copied to clipboard",
description: "Docker Compose content has been copied",
variant: "success",
});
} catch (error) {
toast({
title: "Failed to copy",
description: "Could not copy to clipboard",
variant: "error",
});
}
};
const handleDownload = () => {
if (!template?.composeContent) return;
try {
downloadFile(
template.composeContent,
`${template.name.toLowerCase().replace(/\s+/g, "-")}-compose.yml`,
"text/yaml"
);
toast({
title: "Download started",
description: "Docker Compose file is downloading",
variant: "success",
});
} catch (error) {
toast({
title: "Download failed",
description: "Could not download file",
variant: "error",
});
}
};
const handleImport = async () => {
if (!template) return;
setImporting(true);
try {
await onImport(template);
toast({
title: "Template imported",
description: `${template.name} has been imported successfully`,
variant: "success",
});
onOpenChange(false);
} catch (error: any) {
toast({
title: "Import failed",
description: error.message || "Could not import template",
variant: "error",
});
} finally {
setImporting(false);
}
};
if (!template) return null;
return (
<Dialog
open={open}
onOpenChange={(open) => {
onOpenChange(open);
if (!open) {
setActiveTab("overview");
}
}}
>
<DialogContent className="max-w-6xl max-h-[90vh] flex flex-col gap-0 p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<div className="flex items-start gap-4">
{template.logoUrl && (
<img
src={template.logoUrl}
alt={template.name}
className="w-16 h-16 object-contain rounded flex-shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<div className="flex-1 min-w-0">
<DialogTitle className="text-2xl font-bold">
{template.name}
</DialogTitle>
{template.version && (
<p className="text-sm text-muted-foreground mt-1">
Version {template.version}
</p>
)}
{template.description && (
<p className="text-sm text-muted-foreground mt-2">
{template.description}
</p>
)}
</div>
</div>
{/* Tags and Links */}
<div className="flex flex-wrap items-center gap-3 mt-4">
{template.tags && template.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{template.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 text-xs bg-primary/10 text-primary rounded-md border border-primary/20"
>
{tag}
</span>
))}
</div>
)}
{template.links && (
<div className="flex gap-2 ml-auto">
{template.links.github && (
<Button
size="sm"
variant="outline"
asChild
>
<a
href={template.links.github}
target="_blank"
rel="noopener noreferrer"
>
<Github className="h-3.5 w-3.5 mr-1.5" />
GitHub
</a>
</Button>
)}
{template.links.website && (
<Button
size="sm"
variant="outline"
asChild
>
<a
href={template.links.website}
target="_blank"
rel="noopener noreferrer"
>
<Globe className="h-3.5 w-3.5 mr-1.5" />
Website
</a>
</Button>
)}
{template.links.docs && (
<Button
size="sm"
variant="outline"
asChild
>
<a
href={template.links.docs}
target="_blank"
rel="noopener noreferrer"
>
<BookOpen className="h-3.5 w-3.5 mr-1.5" />
Docs
</a>
</Button>
)}
</div>
)}
</div>
</DialogHeader>
{/* Tabs */}
<div className="px-6 pt-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="compose">Docker Compose</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Tab Content */}
<div className="flex-1 overflow-y-auto px-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsContent value="overview" className="space-y-4 py-4">
{/* Overview content can be expanded here */}
<div className="prose prose-sm max-w-none dark:prose-invert">
<h3 className="text-lg font-semibold">About this template</h3>
<p className="text-muted-foreground">
{template.description ||
"This template provides a pre-configured Docker Compose setup."}
</p>
</div>
</TabsContent>
<TabsContent value="compose" className="py-4">
{template.composeContent ? (
<div className="space-y-4">
<div className="border rounded-lg overflow-hidden">
<div className="min-h-[500px] max-h-[60vh]">
<CodeEditor
content={template.composeContent}
onContentChange={() => {}}
height={undefined}
width={undefined}
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={handleCopy}>
<Copy className="h-4 w-4 mr-2" />
Copy
</Button>
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-center py-12 text-center">
<div className="space-y-2">
<p className="text-muted-foreground">
Docker Compose content not available
</p>
</div>
</div>
)}
</TabsContent>
</Tabs>
</div>
{/* Footer */}
<DialogFooter className="px-6 py-4 border-t bg-muted/30">
<div className="flex gap-3 justify-end w-full">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={importing}
>
Cancel
</Button>
<Button
onClick={handleImport}
disabled={importing || loading}
>
{importing ? "Importing..." : "Import Template"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,204 +0,0 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "../ui/dialog";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { EmptyState } from "../ui/empty-state";
import { Skeleton } from "../ui/skeleton";
import { RefreshCw, Package, AlertCircle } from "lucide-react";
import { TemplateCard } from "./TemplateCard";
export interface Template {
id: string;
name: string;
description?: string;
version?: string;
logo?: string;
tags?: string[];
}
export interface TemplateStoreModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
templates: Template[];
loading?: boolean;
error?: string | null;
cacheTimestamp?: number | null;
onRefresh: () => void;
onTemplateSelect: (template: Template) => void;
}
export function TemplateStoreModal({
open,
onOpenChange,
templates,
loading = false,
error = null,
cacheTimestamp,
onRefresh,
onTemplateSelect,
}: TemplateStoreModalProps) {
const [searchQuery, setSearchQuery] = useState("");
const filteredTemplates = templates.filter(
(template) =>
template.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.tags?.some((tag) =>
tag.toLowerCase().includes(searchQuery.toLowerCase())
)
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-7xl max-h-[90vh] flex flex-col gap-0 p-0">
<DialogHeader className="px-6 pt-6 pb-4 space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<DialogTitle className="text-2xl font-bold">
Template Marketplace
</DialogTitle>
<DialogDescription className="text-base mt-1.5">
Browse and import pre-configured Docker Compose templates.
</DialogDescription>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span>
Templates from{" "}
<a
href="https://github.com/Dokploy/templates"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Dokploy/templates
</a>
</span>
{cacheTimestamp && (
<>
<span></span>
<span>
Cached{" "}
{Math.round((Date.now() - cacheTimestamp) / 60000)}m ago
</span>
</>
)}
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={onRefresh}
disabled={loading}
className="flex-shrink-0"
>
<RefreshCw
className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
{/* Search Bar */}
<div className="pt-2">
<Input
placeholder="Search templates by name, description, or tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-10"
/>
</div>
</DialogHeader>
{/* Content Area */}
<div className="flex-1 overflow-y-auto px-6 pb-6">
{loading ? (
<TemplateGridSkeleton />
) : error ? (
<div className="flex items-center justify-center py-12">
<EmptyState
icon={AlertCircle}
title="Failed to load templates"
description={error}
action={{
label: "Try Again",
onClick: onRefresh,
}}
/>
</div>
) : filteredTemplates.length === 0 ? (
<div className="flex items-center justify-center py-12">
<EmptyState
icon={Package}
title={
searchQuery
? "No templates found"
: "No templates available"
}
description={
searchQuery
? `No templates match "${searchQuery}". Try a different search term.`
: "Templates will appear here once loaded."
}
action={
searchQuery
? {
label: "Clear Search",
onClick: () => setSearchQuery(""),
}
: {
label: "Refresh",
onClick: onRefresh,
}
}
/>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 py-4">
{filteredTemplates.map((template) => (
<TemplateCard
key={template.id}
{...template}
logo={
template.logo
? `https://raw.githubusercontent.com/Dokploy/templates/main/blueprints/${template.id}/${template.logo}`
: undefined
}
onClick={() => onTemplateSelect(template)}
/>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
function TemplateGridSkeleton() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 py-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="border rounded-lg p-4 space-y-3">
<div className="flex items-start gap-3">
<Skeleton className="w-12 h-12 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-3 w-16" />
</div>
</div>
<Skeleton className="h-10 w-full" />
<div className="flex gap-2">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-16" />
</div>
</div>
))}
</div>
);
}

View File

@@ -1,49 +0,0 @@
import type { LucideIcon } from "lucide-react"
import { cn } from "../../lib/utils"
import { Button } from "./button"
export interface EmptyStateProps {
icon?: LucideIcon
title: string
description?: string
action?: {
label: string
onClick: () => void
}
className?: string
}
export function EmptyState({
icon: Icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
"flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 bg-muted/10 p-8 text-center",
"min-h-[400px]",
className
)}
>
{Icon && (
<div className="mb-4 rounded-full bg-muted p-3">
<Icon className="h-10 w-10 text-muted-foreground" />
</div>
)}
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
{description && (
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
{description}
</p>
)}
{action && (
<Button onClick={action.onClick} size="default">
{action.label}
</Button>
)}
</div>
)
}

View File

@@ -1,64 +0,0 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number
max?: number
showLabel?: boolean
size?: "sm" | "md" | "lg"
variant?: "default" | "success" | "warning" | "error"
}
const sizeStyles = {
sm: "h-1",
md: "h-2",
lg: "h-3",
}
const variantStyles = {
default: "bg-primary",
success: "bg-green-500",
warning: "bg-yellow-500",
error: "bg-red-500",
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, max = 100, showLabel = false, size = "md", variant = "default", ...props }, ref) => {
const percentage = Math.min(Math.max((value / max) * 100, 0), 100)
return (
<div className="w-full">
<div
ref={ref}
role="progressbar"
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
className={cn(
"relative w-full overflow-hidden rounded-full bg-secondary",
sizeStyles[size],
className
)}
{...props}
>
<div
className={cn(
"h-full transition-all duration-300 ease-in-out",
variantStyles[variant]
)}
style={{ width: `${percentage}%` }}
/>
</div>
{showLabel && (
<div className="mt-1 text-xs text-muted-foreground text-right">
{Math.round(percentage)}%
</div>
)}
</div>
)
}
)
Progress.displayName = "Progress"
export { Progress }

View File

@@ -1,91 +0,0 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const TabsContext = React.createContext<{
value: string
onValueChange: (value: string) => void
}>({
value: "",
onValueChange: () => {},
})
export interface TabsProps extends React.HTMLAttributes<HTMLDivElement> {
value: string
onValueChange: (value: string) => void
}
export function Tabs({
value,
onValueChange,
className,
children,
...props
}: TabsProps) {
return (
<TabsContext.Provider value={{ value, onValueChange }}>
<div className={cn("w-full", className)} {...props}>
{children}
</div>
</TabsContext.Provider>
)
}
export interface TabsListProps extends React.HTMLAttributes<HTMLDivElement> {}
export function TabsList({ className, ...props }: TabsListProps) {
return (
<div
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
)
}
export interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
value: string
}
export function TabsTrigger({ value, className, ...props }: TabsTriggerProps) {
const { value: selectedValue, onValueChange } = React.useContext(TabsContext)
const isSelected = selectedValue === value
return (
<button
type="button"
role="tab"
aria-selected={isSelected}
data-state={isSelected ? "active" : "inactive"}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
isSelected
? "bg-background text-foreground shadow-sm"
: "hover:bg-background/50 hover:text-foreground",
className
)}
onClick={() => onValueChange(value)}
{...props}
/>
)
}
export interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
value: string
}
export function TabsContent({ value, className, ...props }: TabsContentProps) {
const { value: selectedValue } = React.useContext(TabsContext)
if (selectedValue !== value) return null
return (
<div
role="tabpanel"
data-state={selectedValue === value ? "active" : "inactive"}
className={cn("mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className)}
{...props}
/>
)
}

View File

@@ -1,108 +0,0 @@
import * as React from "react"
import { X } from "lucide-react"
import { cn } from "../../lib/utils"
export interface Toast {
id: string
title?: string
description?: string
action?: React.ReactNode
variant?: "default" | "success" | "error" | "warning"
duration?: number
}
type ToastContextType = {
toasts: Toast[]
toast: (props: Omit<Toast, "id">) => void
dismiss: (id: string) => void
}
const ToastContext = React.createContext<ToastContextType | undefined>(undefined)
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = React.useState<Toast[]>([])
const toast = React.useCallback((props: Omit<Toast, "id">) => {
const id = Math.random().toString(36).substring(2, 9)
const newToast: Toast = { id, ...props }
setToasts((prev) => [...prev, newToast])
const duration = props.duration ?? 3000
if (duration > 0) {
setTimeout(() => {
dismiss(id)
}, duration)
}
}, [])
const dismiss = React.useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
return (
<ToastContext.Provider value={{ toasts, toast, dismiss }}>
{children}
<ToastContainer />
</ToastContext.Provider>
)
}
export function useToast() {
const context = React.useContext(ToastContext)
if (!context) {
throw new Error("useToast must be used within ToastProvider")
}
return context
}
function ToastContainer() {
const { toasts, dismiss } = useToast()
return (
<div className="fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={dismiss} />
))}
</div>
)
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
const variantStyles = {
default: "bg-background border-border",
success: "bg-green-500/10 border-green-500/50 text-green-700 dark:text-green-400",
error: "bg-red-500/10 border-red-500/50 text-red-700 dark:text-red-400",
warning: "bg-yellow-500/10 border-yellow-500/50 text-yellow-700 dark:text-yellow-400",
}
return (
<div
className={cn(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 pr-8 shadow-lg transition-all",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full",
"data-[state=open]:slide-in-from-bottom-full data-[state=open]:sm:slide-in-from-top-full",
variantStyles[toast.variant ?? "default"]
)}
data-state="open"
>
<div className="grid gap-1 flex-1">
{toast.title && (
<div className="text-sm font-semibold">{toast.title}</div>
)}
{toast.description && (
<div className="text-sm opacity-90">{toast.description}</div>
)}
</div>
{toast.action}
<button
onClick={() => onDismiss(toast.id)}
className="absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
)
}

View File

@@ -1,86 +0,0 @@
import { useState, useCallback } from "react";
import type { ServiceConfig } from "../types/compose";
import type { VPNConfig } from "../types/vpn-configs";
import {
convertToDockerRun,
convertToSystemd,
generateKomodoToml,
generateEnvFile,
} from "../utils/converters";
import { redactSensitiveData } from "../utils/validation";
export type ConversionType = "docker-run" | "systemd" | "env" | "redact" | "komodo";
export interface UseConversionDialogOptions {
services: ServiceConfig[];
selectedIdx: number | null;
yaml: string;
vpnConfig: VPNConfig;
}
export function useConversionDialog({
services,
selectedIdx,
yaml,
vpnConfig,
}: UseConversionDialogOptions) {
const [conversionDialogOpen, setConversionDialogOpen] = useState(false);
const [conversionType, setConversionType] = useState<ConversionType | "">("");
const [conversionOutput, setConversionOutput] = useState<string>("");
const handleConversion = useCallback(
(type: ConversionType) => {
setConversionType(type);
let output = "";
try {
switch (type) {
case "docker-run":
if (selectedIdx !== null && services[selectedIdx]) {
output = convertToDockerRun(services[selectedIdx]);
} else {
output = services.map((s) => convertToDockerRun(s)).join("\n\n");
}
break;
case "systemd":
if (selectedIdx !== null && services[selectedIdx]) {
output = convertToSystemd(services[selectedIdx]);
} else {
output = services.map((s) => convertToSystemd(s)).join("\n\n");
}
break;
case "env":
output = generateEnvFile(services, vpnConfig);
break;
case "redact":
output = redactSensitiveData(yaml);
break;
case "komodo":
output = generateKomodoToml(yaml);
break;
default:
output = "Unknown conversion type";
}
setConversionOutput(output);
setConversionDialogOpen(true);
} catch (error: any) {
setConversionOutput(`Error: ${error.message}`);
setConversionDialogOpen(true);
}
},
[services, selectedIdx, yaml, vpnConfig]
);
const closeDialog = useCallback(() => {
setConversionDialogOpen(false);
}, []);
return {
conversionDialogOpen,
setConversionDialogOpen,
conversionType,
conversionOutput,
handleConversion,
closeDialog,
};
}

View File

@@ -1,47 +0,0 @@
import { useLayoutEffect, useRef, useState } from "react";
export interface EditorSize {
width: number;
height: number;
}
/**
* Hook to manage code editor size with ResizeObserver
* @returns Object containing codeFileRef and editorSize
*/
export function useEditorSize() {
const codeFileRef = useRef<HTMLDivElement>(null);
const [editorSize, setEditorSize] = useState<EditorSize>({ width: 0, height: 0 });
useLayoutEffect(() => {
if (!codeFileRef.current) return;
const handleResize = () => {
const rect = codeFileRef.current?.getBoundingClientRect();
if (rect) {
// Ensure minimum dimensions for small screens
setEditorSize({
width: Math.max(rect.width, 300),
height: Math.max(rect.height, 200),
});
}
};
handleResize();
const ro = new ResizeObserver(handleResize);
ro.observe(codeFileRef.current);
// Also listen to window resize for better responsiveness
window.addEventListener("resize", handleResize);
return () => {
ro.disconnect();
window.removeEventListener("resize", handleResize);
};
}, []);
return {
codeFileRef,
editorSize,
};
}

View File

@@ -1,181 +0,0 @@
import { useState, useCallback } from "react";
import type { NetworkConfig, VolumeConfig, ServiceConfig } from "../types/compose";
import { defaultNetwork, defaultVolume } from "../utils/default-configs";
export interface UseNetworkVolumeManagerReturn {
// Networks
networks: NetworkConfig[];
selectedNetworkIdx: number | null;
setSelectedNetworkIdx: (idx: number | null) => void;
addNetwork: () => void;
updateNetwork: (idx: number, field: keyof NetworkConfig, value: any) => void;
removeNetwork: (idx: number) => void;
// Volumes
volumes: VolumeConfig[];
selectedVolumeIdx: number | null;
setSelectedVolumeIdx: (idx: number | null) => void;
addVolume: () => void;
updateVolume: (idx: number, field: keyof VolumeConfig, value: any) => void;
removeVolume: (idx: number) => void;
}
export interface UseNetworkVolumeManagerOptions {
initialNetworks?: NetworkConfig[];
initialVolumes?: VolumeConfig[];
setServices?: (services: ServiceConfig[] | ((prev: ServiceConfig[]) => ServiceConfig[])) => void;
onSelectionChange?: (type: "service" | "network" | "volume", idx: number | null) => void;
}
export function useNetworkVolumeManager({
initialNetworks = [],
initialVolumes = [],
setServices,
onSelectionChange,
}: UseNetworkVolumeManagerOptions = {}): UseNetworkVolumeManagerReturn {
const [networks, setNetworks] = useState<NetworkConfig[]>(initialNetworks);
const [volumes, setVolumes] = useState<VolumeConfig[]>(initialVolumes);
const [selectedNetworkIdx, setSelectedNetworkIdx] = useState<number | null>(null);
const [selectedVolumeIdx, setSelectedVolumeIdx] = useState<number | null>(null);
// Network management
const addNetwork = useCallback(() => {
setNetworks((prev) => {
const newNetworks = [...prev, defaultNetwork()];
setSelectedNetworkIdx(newNetworks.length - 1);
onSelectionChange?.("network", newNetworks.length - 1);
return newNetworks;
});
setSelectedVolumeIdx(null);
}, [onSelectionChange]);
const updateNetwork = useCallback((idx: number, field: keyof NetworkConfig, value: any) => {
setNetworks((prev) => {
const newNetworks = [...prev];
// If renaming a network, update all service references
if (field === "name" && setServices) {
const oldName = newNetworks[idx].name;
newNetworks[idx][field] = value;
setServices((prevServices) =>
prevServices.map((svc) => ({
...svc,
networks: svc.networks?.map((n) => (n === oldName ? value : n)) || [],
}))
);
return newNetworks;
}
(newNetworks[idx] as any)[field] = value;
return newNetworks;
});
}, [setServices]);
const removeNetwork = useCallback((idx: number) => {
setNetworks((prev) => {
const newNetworks = [...prev];
const removedName = newNetworks[idx].name;
newNetworks.splice(idx, 1);
// Remove network references from services
if (setServices) {
setServices((prevServices) =>
prevServices.map((svc) => ({
...svc,
networks: svc.networks?.filter((n) => n !== removedName) || [],
}))
);
}
if (newNetworks.length === 0) {
setSelectedNetworkIdx(null);
onSelectionChange?.("service", null);
} else {
setSelectedNetworkIdx(0);
}
return newNetworks;
});
}, [setServices, onSelectionChange]);
// Volume management
const addVolume = useCallback(() => {
setVolumes((prev) => {
const newVolumes = [...prev, defaultVolume()];
setSelectedVolumeIdx(newVolumes.length - 1);
onSelectionChange?.("volume", newVolumes.length - 1);
return newVolumes;
});
setSelectedNetworkIdx(null);
}, [onSelectionChange]);
const updateVolume = useCallback((idx: number, field: keyof VolumeConfig, value: any) => {
setVolumes((prev) => {
const newVolumes = [...prev];
// If renaming a volume, update all service references
if (field === "name" && setServices) {
const oldName = newVolumes[idx].name;
newVolumes[idx][field] = value;
setServices((prevServices) =>
prevServices.map((svc) => ({
...svc,
volumes:
svc.volumes?.map((v) =>
v.host === oldName ? { ...v, host: value } : v
) || [],
}))
);
return newVolumes;
}
(newVolumes[idx] as any)[field] = value;
return newVolumes;
});
}, [setServices]);
const removeVolume = useCallback((idx: number) => {
setVolumes((prev) => {
const newVolumes = [...prev];
const removedName = newVolumes[idx].name;
newVolumes.splice(idx, 1);
// Remove volume references from services
if (setServices) {
setServices((prevServices) =>
prevServices.map((svc) => ({
...svc,
volumes: svc.volumes?.filter((v) => v.host !== removedName) || [],
}))
);
}
if (newVolumes.length === 0) {
setSelectedVolumeIdx(null);
onSelectionChange?.("service", null);
} else {
setSelectedVolumeIdx(0);
}
return newVolumes;
});
}, [setServices, onSelectionChange]);
return {
networks,
selectedNetworkIdx,
setSelectedNetworkIdx,
addNetwork,
updateNetwork,
removeNetwork,
volumes,
selectedVolumeIdx,
setSelectedVolumeIdx,
addVolume,
updateVolume,
removeVolume,
};
}

View File

@@ -1,563 +0,0 @@
import { useState, useCallback } from "react";
import type { ServiceConfig, Healthcheck } from "../types/compose";
import { defaultService } from "../utils/default-configs";
export interface UseServiceManagerReturn {
services: ServiceConfig[];
selectedIdx: number | null;
setSelectedIdx: (idx: number | null) => void;
updateServiceField: (field: keyof ServiceConfig, value: any) => void;
updateListField: (field: keyof ServiceConfig, idx: number, value: any) => void;
addListField: (field: keyof ServiceConfig) => void;
removeListField: (field: keyof ServiceConfig, idx: number) => void;
addService: () => void;
removeService: (idx: number) => void;
// Ports
updatePortField: (idx: number, field: "host" | "container" | "protocol", value: string) => void;
addPortField: () => void;
removePortField: (idx: number) => void;
// Volumes
updateVolumeField: (idx: number, field: "host" | "container" | "read_only", value: string | boolean) => void;
addVolumeField: () => void;
removeVolumeField: (idx: number) => void;
// Healthcheck
updateHealthcheckField: (field: keyof Healthcheck, value: string) => void;
// Dependencies
updateDependsOn: (idx: number, value: string) => void;
addDependsOn: () => void;
removeDependsOn: (idx: number) => void;
// Security
updateSecurityOpt: (idx: number, value: string) => void;
addSecurityOpt: () => void;
removeSecurityOpt: (idx: number) => void;
// Capabilities
updateCapAdd: (idx: number, value: string) => void;
addCapAdd: () => void;
removeCapAdd: (idx: number) => void;
updateCapDrop: (idx: number, value: string) => void;
addCapDrop: () => void;
removeCapDrop: (idx: number) => void;
// Sysctls
updateSysctl: (idx: number, field: "key" | "value", value: string) => void;
addSysctl: () => void;
removeSysctl: (idx: number) => void;
// Devices
updateDevice: (idx: number, value: string) => void;
addDevice: () => void;
removeDevice: (idx: number) => void;
// Tmpfs
updateTmpfs: (idx: number, value: string) => void;
addTmpfs: () => void;
removeTmpfs: (idx: number) => void;
// Ulimits
updateUlimit: (idx: number, field: "name" | "soft" | "hard", value: string) => void;
addUlimit: () => void;
removeUlimit: (idx: number) => void;
// Labels
updateLabel: (idx: number, field: "key" | "value", value: string) => void;
addLabel: () => void;
removeLabel: (idx: number) => void;
// Resources
updateResourceField: (type: "limits" | "reservations", field: "cpus" | "memory", value: string) => void;
}
export function useServiceManager(
initialServices: ServiceConfig[] = [defaultService()],
onSelectionChange?: (type: "service" | "network" | "volume", idx: number | null) => void
): UseServiceManagerReturn {
const [services, setServices] = useState<ServiceConfig[]>(initialServices);
const [selectedIdx, setSelectedIdx] = useState<number | null>(0);
const updateServiceField = useCallback((field: keyof ServiceConfig, value: any) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
(newServices[selectedIdx] as any)[field] = value;
return newServices;
});
}, [selectedIdx]);
const updateListField = useCallback((field: keyof ServiceConfig, idx: number, value: any) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
(newServices[selectedIdx][field] as any[])[idx] = value;
return newServices;
});
}, [selectedIdx]);
const addListField = useCallback((field: keyof ServiceConfig) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (field === "environment") {
newServices[selectedIdx].environment.push({ key: "", value: "" });
} else {
(newServices[selectedIdx][field] as any[]).push("");
}
return newServices;
});
}, [selectedIdx]);
const removeListField = useCallback((field: keyof ServiceConfig, idx: number) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
(newServices[selectedIdx][field] as any[]).splice(idx, 1);
return newServices;
});
}, [selectedIdx]);
const addService = useCallback(() => {
setServices((prev) => {
const newServices = [...prev, defaultService()];
setSelectedIdx(prev.length);
onSelectionChange?.("service", prev.length);
return newServices;
});
}, [onSelectionChange]);
const removeService = useCallback((idx: number) => {
setServices((prev) => {
const newServices = prev.filter((_, i) => i !== idx);
const finalServices = newServices.length === 0 ? [defaultService()] : newServices;
const newSelectedIdx = typeof selectedIdx === "number"
? Math.max(0, Math.min(finalServices.length - 1, selectedIdx - (idx <= selectedIdx ? 1 : 0)))
: 0;
setSelectedIdx(newSelectedIdx);
return finalServices;
});
}, [selectedIdx]);
const updatePortField = useCallback((idx: number, field: "host" | "container" | "protocol", value: string) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (field === "protocol") {
newServices[selectedIdx].ports[idx][field] = value;
} else {
newServices[selectedIdx].ports[idx][field] = value.replace(/[^0-9]/g, "");
}
return newServices;
});
}, [selectedIdx]);
const addPortField = useCallback(() => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].ports.push({ host: "", container: "", protocol: "none" });
return newServices;
});
}, [selectedIdx]);
const removePortField = useCallback((idx: number) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].ports.splice(idx, 1);
return newServices;
});
}, [selectedIdx]);
const updateVolumeField = useCallback((idx: number, field: "host" | "container" | "read_only", value: string | boolean) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
(newServices[selectedIdx].volumes[idx] as any)[field] = value;
return newServices;
});
}, [selectedIdx]);
const addVolumeField = useCallback(() => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].volumes.push({ host: "", container: "", read_only: false });
return newServices;
});
}, [selectedIdx]);
const removeVolumeField = useCallback((idx: number) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].volumes.splice(idx, 1);
return newServices;
});
}, [selectedIdx]);
const updateHealthcheckField = useCallback((field: keyof Healthcheck, value: string) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].healthcheck) {
newServices[selectedIdx].healthcheck = {
test: "",
interval: "",
timeout: "",
retries: "",
start_period: "",
start_interval: "",
};
}
newServices[selectedIdx].healthcheck![field] = value;
return newServices;
});
}, [selectedIdx]);
const updateDependsOn = useCallback((idx: number, value: string) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].depends_on![idx] = value;
return newServices;
});
}, [selectedIdx]);
const addDependsOn = useCallback(() => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].depends_on) newServices[selectedIdx].depends_on = [];
newServices[selectedIdx].depends_on!.push("");
return newServices;
});
}, [selectedIdx]);
const removeDependsOn = useCallback((idx: number) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].depends_on!.splice(idx, 1);
return newServices;
});
}, [selectedIdx]);
const updateSecurityOpt = useCallback((idx: number, value: string) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].security_opt![idx] = value;
return newServices;
});
}, [selectedIdx]);
const addSecurityOpt = useCallback(() => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].security_opt) newServices[selectedIdx].security_opt = [];
newServices[selectedIdx].security_opt!.push("");
return newServices;
});
}, [selectedIdx]);
const removeSecurityOpt = useCallback((idx: number) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].security_opt!.splice(idx, 1);
return newServices;
});
}, [selectedIdx]);
const updateCapAdd = useCallback((idx: number, value: string) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].cap_add) newServices[selectedIdx].cap_add = [];
newServices[selectedIdx].cap_add![idx] = value;
return newServices;
});
}, [selectedIdx]);
const addCapAdd = useCallback(() => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].cap_add) newServices[selectedIdx].cap_add = [];
newServices[selectedIdx].cap_add!.push("");
return newServices;
});
}, [selectedIdx]);
const removeCapAdd = useCallback((idx: number) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].cap_add!.splice(idx, 1);
return newServices;
});
}, [selectedIdx]);
const updateCapDrop = useCallback((idx: number, value: string) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].cap_drop) newServices[selectedIdx].cap_drop = [];
newServices[selectedIdx].cap_drop![idx] = value;
return newServices;
});
}, [selectedIdx]);
const addCapDrop = useCallback(() => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].cap_drop) newServices[selectedIdx].cap_drop = [];
newServices[selectedIdx].cap_drop!.push("");
return newServices;
});
}, [selectedIdx]);
const removeCapDrop = useCallback((idx: number) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].cap_drop!.splice(idx, 1);
return newServices;
});
}, [selectedIdx]);
const updateSysctl = useCallback((idx: number, field: "key" | "value", value: string) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].sysctls) newServices[selectedIdx].sysctls = [];
newServices[selectedIdx].sysctls![idx] = {
...newServices[selectedIdx].sysctls![idx],
[field]: value,
};
return newServices;
});
}, [selectedIdx]);
const addSysctl = useCallback(() => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].sysctls) newServices[selectedIdx].sysctls = [];
newServices[selectedIdx].sysctls!.push({ key: "", value: "" });
return newServices;
});
}, [selectedIdx]);
const removeSysctl = useCallback((idx: number) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].sysctls!.splice(idx, 1);
return newServices;
});
}, [selectedIdx]);
const updateDevice = useCallback((idx: number, value: string) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].devices) newServices[selectedIdx].devices = [];
newServices[selectedIdx].devices![idx] = value;
return newServices;
});
}, [selectedIdx]);
const addDevice = useCallback(() => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].devices) newServices[selectedIdx].devices = [];
newServices[selectedIdx].devices!.push("");
return newServices;
});
}, [selectedIdx]);
const removeDevice = useCallback((idx: number) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].devices!.splice(idx, 1);
return newServices;
});
}, [selectedIdx]);
const updateTmpfs = useCallback((idx: number, value: string) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].tmpfs) newServices[selectedIdx].tmpfs = [];
newServices[selectedIdx].tmpfs![idx] = value;
return newServices;
});
}, [selectedIdx]);
const addTmpfs = useCallback(() => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].tmpfs) newServices[selectedIdx].tmpfs = [];
newServices[selectedIdx].tmpfs!.push("");
return newServices;
});
}, [selectedIdx]);
const removeTmpfs = useCallback((idx: number) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].tmpfs!.splice(idx, 1);
return newServices;
});
}, [selectedIdx]);
const updateUlimit = useCallback((idx: number, field: "name" | "soft" | "hard", value: string) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].ulimits) newServices[selectedIdx].ulimits = [];
newServices[selectedIdx].ulimits![idx] = {
...newServices[selectedIdx].ulimits![idx],
[field]: value,
};
return newServices;
});
}, [selectedIdx]);
const addUlimit = useCallback(() => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].ulimits) newServices[selectedIdx].ulimits = [];
newServices[selectedIdx].ulimits!.push({ name: "", soft: "", hard: "" });
return newServices;
});
}, [selectedIdx]);
const removeUlimit = useCallback((idx: number) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].ulimits!.splice(idx, 1);
return newServices;
});
}, [selectedIdx]);
const updateLabel = useCallback((idx: number, field: "key" | "value", value: string) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].labels) newServices[selectedIdx].labels = [];
newServices[selectedIdx].labels![idx] = {
...newServices[selectedIdx].labels![idx],
[field]: value,
};
return newServices;
});
}, [selectedIdx]);
const addLabel = useCallback(() => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].labels) newServices[selectedIdx].labels = [];
newServices[selectedIdx].labels!.push({ key: "", value: "" });
return newServices;
});
}, [selectedIdx]);
const removeLabel = useCallback((idx: number) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
newServices[selectedIdx].labels!.splice(idx, 1);
return newServices;
});
}, [selectedIdx]);
const updateResourceField = useCallback((
type: "limits" | "reservations",
field: "cpus" | "memory",
value: string
) => {
setServices((prev) => {
if (typeof selectedIdx !== "number") return prev;
const newServices = [...prev];
if (!newServices[selectedIdx].deploy) {
newServices[selectedIdx].deploy = { resources: {} };
}
if (!newServices[selectedIdx].deploy!.resources) {
newServices[selectedIdx].deploy!.resources = {};
}
if (!newServices[selectedIdx].deploy!.resources![type]) {
newServices[selectedIdx].deploy!.resources![type] = {};
}
if (value.trim() === "") {
delete (newServices[selectedIdx].deploy!.resources![type] as any)[field];
if (Object.keys(newServices[selectedIdx].deploy!.resources![type]!).length === 0) {
delete newServices[selectedIdx].deploy!.resources![type];
}
if (Object.keys(newServices[selectedIdx].deploy!.resources!).length === 0) {
delete newServices[selectedIdx].deploy!.resources;
if (Object.keys(newServices[selectedIdx].deploy!).length === 0) {
delete newServices[selectedIdx].deploy;
}
}
} else {
(newServices[selectedIdx].deploy!.resources![type] as any)[field] = value;
}
return newServices;
});
}, [selectedIdx]);
return {
services,
selectedIdx,
setSelectedIdx,
updateServiceField,
updateListField,
addListField,
removeListField,
addService,
removeService,
updatePortField,
addPortField,
removePortField,
updateVolumeField,
addVolumeField,
removeVolumeField,
updateHealthcheckField,
updateDependsOn,
addDependsOn,
removeDependsOn,
updateSecurityOpt,
addSecurityOpt,
removeSecurityOpt,
updateCapAdd,
addCapAdd,
removeCapAdd,
updateCapDrop,
addCapDrop,
removeCapDrop,
updateSysctl,
addSysctl,
removeSysctl,
updateDevice,
addDevice,
removeDevice,
updateTmpfs,
addTmpfs,
removeTmpfs,
updateUlimit,
addUlimit,
removeUlimit,
updateLabel,
addLabel,
removeLabel,
updateResourceField,
};
}

View File

@@ -1,69 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import type { ServiceConfig, NetworkConfig, VolumeConfig } from "../types/compose";
import type { VPNConfig } from "../types/vpn-configs";
import { validateServices } from "../utils/validation";
import { generateYaml } from "../utils/yaml-generator";
import { defaultVPNConfig } from "../utils/default-configs";
export interface UseYamlValidationOptions {
services: ServiceConfig[];
networks: NetworkConfig[];
volumes: VolumeConfig[];
vpnConfig: VPNConfig;
}
export function useYamlValidation({
services,
networks,
volumes,
vpnConfig,
}: UseYamlValidationOptions) {
const [yaml, setYaml] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
const [validationSuccess, setValidationSuccess] = useState(false);
// Auto-generate YAML when services, networks, volumes, or vpnConfig change
useEffect(() => {
setYaml(
generateYaml(services, networks, volumes, vpnConfig || defaultVPNConfig())
);
}, [services, networks, volumes, vpnConfig]);
const validateAndReformat = useCallback(() => {
try {
setValidationError(null);
setValidationSuccess(false);
// Validate services
const errors = validateServices(services);
if (errors.length > 0) {
setValidationError(errors.join("; "));
return;
}
// Regenerate YAML using the imported generateYaml function
// This preserves VPN configs, JSON content, and proper formatting
const reformatted = generateYaml(
services,
networks,
volumes,
vpnConfig || defaultVPNConfig()
);
setYaml(reformatted);
setValidationSuccess(true);
setTimeout(() => setValidationSuccess(false), 3000);
} catch (error: any) {
setValidationError(error.message || "Invalid YAML format");
setValidationSuccess(false);
}
}, [services, networks, volumes, vpnConfig]);
return {
yaml,
setYaml,
validationError,
validationSuccess,
validateAndReformat,
};
}

View File

@@ -3,8 +3,6 @@ import {ThemeProvider} from "../components/ThemeProvider";
import {Header} from "../components/Header";
import {Footer} from "../components/Footer";
import {MetaTags} from "../components/MetaTags";
import {ToastProvider} from "../components/ui/toast";
import {TooltipProvider} from "../components/ui/tooltip";
export const Route = createRootRoute({
component: RootComponent,
@@ -16,18 +14,14 @@ function RootComponent() {
return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<ToastProvider>
<TooltipProvider>
<MetaTags />
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">
<Outlet/>
</main>
{isIndexPage && <Footer />}
</div>
</TooltipProvider>
</ToastProvider>
<MetaTags />
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">
<Outlet/>
</main>
{isIndexPage && <Footer />}
</div>
</ThemeProvider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,130 +0,0 @@
// Shared types for Docker Compose builder
export interface PortMapping {
host: string;
container: string;
protocol: string;
}
export interface VolumeMapping {
host: string;
container: string;
read_only?: boolean;
}
export interface Healthcheck {
test: string;
interval: string;
timeout: string;
retries: string;
start_period: string;
start_interval: string;
}
export interface ResourceLimits {
cpus?: string;
memory?: string;
}
export interface ResourceReservations {
cpus?: string;
memory?: string;
}
export interface DeployResources {
limits?: ResourceLimits;
reservations?: ResourceReservations;
}
export interface ServiceConfig {
name: string;
image: string;
container_name?: string;
ports: PortMapping[];
expose: string[];
volumes: VolumeMapping[];
environment: { key: string; value: string }[];
environment_syntax: "array" | "dict";
volumes_syntax: "array" | "dict";
command: string;
restart: string;
healthcheck?: Healthcheck;
depends_on?: string[];
entrypoint?: string;
env_file?: string;
extra_hosts?: string[];
dns?: string[];
networks?: string[];
user?: string;
working_dir?: string;
labels?: { key: string; value: string }[];
privileged?: boolean;
read_only?: boolean;
shm_size?: string;
security_opt?: string[];
// Network options
network_mode?: string;
// Capabilities
cap_add?: string[];
cap_drop?: string[];
// System controls
sysctls?: { key: string; value: string }[];
// Device management
devices?: string[];
// Temporary filesystems
tmpfs?: string[];
// Resource limits
ulimits?: { name: string; soft?: string; hard?: string }[];
// Container lifecycle
init?: boolean;
stop_grace_period?: string;
stop_signal?: string;
// Terminal/interactive
tty?: boolean;
stdin_open?: boolean;
// Hostname/DNS
hostname?: string;
domainname?: string;
mac_address?: string;
// IPC/PID/UTS namespaces
ipc_mode?: string;
pid?: string;
uts?: string;
// Cgroup
cgroup_parent?: string;
// Isolation
isolation?: string;
deploy?: {
resources?: DeployResources;
};
}
export interface NetworkConfig {
name: string;
driver: string;
driver_opts: { key: string; value: string }[];
attachable: boolean;
labels: { key: string; value: string }[];
external: boolean;
name_external: string;
internal: boolean;
enable_ipv6: boolean;
ipam: {
driver: string;
config: { subnet: string; gateway: string }[];
options: { key: string; value: string }[];
};
}
export interface VolumeConfig {
name: string;
driver: string;
driver_opts: { key: string; value: string }[];
labels: { key: string; value: string }[];
external: boolean;
name_external: string;
driver_opts_type: string;
driver_opts_device: string;
driver_opts_o: string;
}

View File

@@ -1,80 +0,0 @@
// Template metadata types based on Dokploy/templates format
export interface TemplateVariable {
name: string;
value: string;
helper?: string; // Helper type like "domain", "password:32", "uuid", etc.
description?: string;
}
export interface TemplateDomain {
serviceName: string;
port: number;
host: string;
path?: string;
env?: string[]; // Environment variables for this domain
}
export interface TemplateMount {
filePath: string;
content: string;
description?: string;
}
export interface TemplateConfig {
domains: TemplateDomain[];
env: { key: string; value: string; description?: string }[];
mounts: TemplateMount[];
}
export interface TemplateMetadata {
id?: string;
name?: string;
version?: string;
description?: string;
logo?: string;
links?: {
github?: string;
website?: string;
docs?: string;
};
tags?: string[];
}
export interface TemplateFormat {
variables: TemplateVariable[];
config: TemplateConfig;
metadata?: TemplateMetadata;
}
export type TemplateHelper =
| "domain"
| "password"
| "password:32"
| "base64"
| "base64:32"
| "hash"
| "uuid"
| "randomPort"
| "email"
| "username"
| "timestamp"
| "jwt"
| "custom";
export const TEMPLATE_HELPERS: { value: TemplateHelper; label: string; description: string }[] = [
{ value: "domain", label: "Domain", description: "Generate a random domain" },
{ value: "password", label: "Password (default length)", description: "Generate a random password" },
{ value: "password:32", label: "Password (32 chars)", description: "Generate a 32-character password" },
{ value: "base64", label: "Base64", description: "Encode to base64" },
{ value: "base64:32", label: "Base64 (32 bytes)", description: "Encode 32 bytes to base64" },
{ value: "hash", label: "Hash", description: "Generate a hash" },
{ value: "uuid", label: "UUID", description: "Generate a UUID" },
{ value: "randomPort", label: "Random Port", description: "Generate a random port" },
{ value: "email", label: "Email", description: "Generate a random email" },
{ value: "username", label: "Username", description: "Generate a random username" },
{ value: "timestamp", label: "Timestamp", description: "Generate current timestamp" },
{ value: "jwt", label: "JWT", description: "Generate a JWT token" },
{ value: "custom", label: "Custom", description: "Custom value" },
];

View File

@@ -1,76 +0,0 @@
// VPN Provider Configuration Types
export interface TailscaleConfig {
authKey: string;
hostname: string;
acceptDns: boolean;
authOnce: boolean;
userspace: boolean;
exitNode: string;
exitNodeAllowLan: boolean;
enableServe: boolean;
serveConfig: string; // JSON string
certDomain: string;
serveTargetService: string;
serveExternalPort: string;
serveInternalPort: string;
servePath: string;
serveProtocol: "HTTPS" | "HTTP";
containerName: string;
enableHealthCheck: boolean;
healthCheckEndpoint: string;
localAddrPort: string;
dns: string[];
configPath: string;
stateDir: string;
tmpfsEnabled: boolean;
tmpfsPath: string;
capAdd: string[];
serveConfigPath: string;
}
export interface NewtConfig {
endpoint: string;
newtId: string;
newtSecret: string;
networkName: string;
}
export interface CloudflaredConfig {
tunnelToken: string;
noAutoupdate: boolean;
}
export interface WireguardConfig {
configPath: string;
interfaceName: string;
}
export interface ZerotierConfig {
networkId: string;
identityPath: string;
}
export interface NetbirdConfig {
setupKey: string;
managementUrl: string;
}
export interface VPNConfig {
enabled: boolean;
type:
| "tailscale"
| "newt"
| "cloudflared"
| "wireguard"
| "zerotier"
| "netbird"
| null;
tailscale?: TailscaleConfig;
newt?: NewtConfig;
cloudflared?: CloudflaredConfig;
wireguard?: WireguardConfig;
zerotier?: ZerotierConfig;
netbird?: NetbirdConfig;
servicesUsingVpn: string[]; // Service names that should use VPN
}

View File

@@ -1,31 +0,0 @@
// VPN provider abstraction types
export interface VPNProviderConfig {
enabled: boolean;
type: string;
servicesUsingVpn: string[];
}
export interface VPNServiceGenerator {
generateService(config: VPNProviderConfig): any;
generateVolumes(config: VPNProviderConfig): any[];
generateNetworks(config: VPNProviderConfig): any[];
getServiceName(): string;
usesNetworkMode(): boolean;
supportsHealthCheck(): boolean;
}
export type VPNProviderType =
| "tailscale"
| "newt"
| "cloudflared"
| "wireguard"
| "zerotier"
| "netbird";
export interface VPNProviderRegistry {
register(type: VPNProviderType, provider: VPNServiceGenerator): void;
get(type: VPNProviderType): VPNServiceGenerator | undefined;
getAll(): VPNProviderType[];
}

View File

@@ -1,45 +0,0 @@
/**
* Copy text to clipboard
* @param text Text to copy to clipboard
* @returns Promise that resolves when text is copied
*/
export async function copyToClipboard(text: string): Promise<void> {
await navigator.clipboard.writeText(text);
}
/**
* Download text as a file
* @param content File content
* @param filename Name of the file to download
* @param mimeType MIME type of the file (default: text/plain)
*/
export function downloadFile(
content: string,
filename: string,
mimeType = "text/plain"
): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Download multiple files as separate downloads
* @param files Array of file objects with filename and content
*/
export function downloadMultipleFiles(
files: Array<{ filename: string; content: string; mimeType?: string }>
): void {
files.forEach((file, index) => {
// Stagger downloads slightly to avoid browser blocking
setTimeout(() => {
downloadFile(file.content, file.filename, file.mimeType);
}, index * 100);
});
}

View File

@@ -1,240 +0,0 @@
// Conversion utilities for Docker Compose to other formats
import type { ServiceConfig } from "../types/compose";
import type { VPNConfig } from "../types/vpn-configs";
import jsyaml from "js-yaml";
// Convert Docker Compose service to docker run command
export function convertToDockerRun(service: ServiceConfig): string {
let cmd = "docker run";
if (service.container_name) {
cmd += ` --name ${service.container_name}`;
}
if (service.restart) {
cmd += ` --restart ${service.restart}`;
}
service.ports.forEach((p) => {
if (p.host && p.container) {
const protocol =
p.protocol && p.protocol !== "none" ? `/${p.protocol}` : "";
cmd += ` -p ${p.host}:${p.container}${protocol}`;
}
});
service.volumes.forEach((v) => {
if (v.host && v.container) {
cmd += ` -v ${v.host}:${v.container}`;
if (v.read_only) cmd += ":ro";
}
});
service.environment.forEach((e) => {
if (e.key) {
cmd += ` -e ${e.key}=${e.value || ""}`;
}
});
if (service.user) {
cmd += ` --user ${service.user}`;
}
if (service.working_dir) {
cmd += ` -w ${service.working_dir}`;
}
if (service.privileged) {
cmd += " --privileged";
}
if (service.read_only) {
cmd += " --read-only";
}
if (service.shm_size) {
cmd += ` --shm-size ${service.shm_size}`;
}
service.security_opt?.forEach((opt) => {
if (opt) cmd += ` --security-opt ${opt}`;
});
service.extra_hosts?.forEach((host) => {
if (host) cmd += ` --add-host ${host}`;
});
service.dns?.forEach((dns) => {
if (dns) cmd += ` --dns ${dns}`;
});
if (service.networks && service.networks.length > 0) {
cmd += ` --network ${service.networks[0]}`;
}
cmd += ` ${service.image || ""}`;
if (service.command) {
try {
const parsed = JSON.parse(service.command);
if (Array.isArray(parsed)) {
cmd += ` ${parsed.join(" ")}`;
} else {
cmd += ` ${service.command}`;
}
} catch {
cmd += ` ${service.command}`;
}
}
return cmd;
}
// Convert Docker Compose service to systemd service file
export function convertToSystemd(service: ServiceConfig): string {
const containerName = service.container_name || service.name;
let unit = `[Unit]
Description=Docker Container ${containerName}
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start ${containerName}
ExecStop=/usr/bin/docker stop ${containerName}
Restart=${service.restart === "always" ? "always" : service.restart === "unless-stopped" ? "on-failure" : "no"}
[Install]
WantedBy=multi-user.target
`;
return unit;
}
// Generate Komodo .toml from YAML
export function generateKomodoToml(yaml: string): string {
try {
// Extract services from compose file if available
const composeData = jsyaml.load(yaml) as any;
const services = composeData?.services || {};
let toml = `# Komodo configuration generated from Portainer stack
# Generated from Docker Compose configuration
`;
Object.entries(services).forEach(([name, service]: [string, any]) => {
toml += `[${name}]\n`;
if (service.image) {
toml += `image = "${service.image}"\n`;
}
if (service.container_name) {
toml += `container_name = "${service.container_name}"\n`;
}
if (service.restart) {
toml += `restart = "${service.restart}"\n`;
}
if (service.ports && Array.isArray(service.ports)) {
toml += `ports = [\n`;
service.ports.forEach((port: string) => {
toml += ` "${port}",\n`;
});
toml += `]\n`;
}
if (service.volumes && Array.isArray(service.volumes)) {
toml += `volumes = [\n`;
service.volumes.forEach((vol: string) => {
toml += ` "${vol}",\n`;
});
toml += `]\n`;
}
if (service.environment) {
if (Array.isArray(service.environment)) {
toml += `environment = [\n`;
service.environment.forEach((env: string) => {
toml += ` "${env}",\n`;
});
toml += `]\n`;
} else {
toml += `environment = {}\n`;
Object.entries(service.environment).forEach(
([key, value]: [string, any]) => {
toml += `environment.${key} = "${value}"\n`;
}
);
}
}
toml += `\n`;
});
return toml;
} catch (error: any) {
return `# Komodo configuration generated from Docker Compose
# Note: Error parsing configuration: ${error.message}
# Please adjust manually
[service]
name = "service"
image = ""
# Add configuration as needed
`;
}
}
// Generate .env file from services and VPN config
export function generateEnvFile(
services: ServiceConfig[],
vpnConfig?: VPNConfig
): string {
const envVars = new Set<string>();
// Extract env vars from services
services.forEach((service) => {
service.environment.forEach(({ key, value }) => {
if (key && value && value.startsWith("${") && value.endsWith("}")) {
const envKey = value.slice(2, -1);
envVars.add(`${envKey}=`);
}
});
});
// Add VPN-specific env vars
if (vpnConfig?.enabled && vpnConfig.type) {
switch (vpnConfig.type) {
case "tailscale":
if (vpnConfig.tailscale?.authKey) {
envVars.add("TS_AUTHKEY=");
}
break;
case "newt":
if (vpnConfig.newt?.newtId) {
envVars.add("NEWT_ID=");
}
if (vpnConfig.newt?.newtSecret) {
envVars.add("NEWT_SECRET=");
}
break;
case "cloudflared":
if (vpnConfig.cloudflared?.tunnelToken) {
envVars.add("TUNNEL_TOKEN=");
}
break;
case "zerotier":
if (vpnConfig.zerotier?.networkId) {
envVars.add("ZT_NETWORK_ID=");
}
break;
case "netbird":
if (vpnConfig.netbird?.setupKey) {
envVars.add("NETBIRD_SETUP_KEY=");
}
break;
}
}
return Array.from(envVars).sort().join("\n");
}

View File

@@ -1,177 +0,0 @@
// Default configuration factories for Docker Compose entities
import type {
ServiceConfig,
NetworkConfig,
VolumeConfig,
} from "../types/compose";
import type {
TailscaleConfig,
NewtConfig,
CloudflaredConfig,
WireguardConfig,
ZerotierConfig,
NetbirdConfig,
VPNConfig,
} from "../types/vpn-configs";
export function defaultTailscaleConfig(): TailscaleConfig {
return {
authKey: "",
hostname: "",
acceptDns: false,
authOnce: true,
userspace: false,
exitNode: "",
exitNodeAllowLan: false,
enableServe: false,
serveConfig: "",
certDomain: "",
serveTargetService: "",
serveExternalPort: "443",
serveInternalPort: "8080",
servePath: "/",
serveProtocol: "HTTPS",
// ScaleTail patterns - defaults
containerName: "",
enableHealthCheck: true,
healthCheckEndpoint: "/healthz",
localAddrPort: "127.0.0.1:41234",
dns: [],
configPath: "./config",
stateDir: "./state",
tmpfsEnabled: false,
tmpfsPath: "/tmp",
capAdd: ["NET_ADMIN"],
serveConfigPath: "/config/serve.json",
};
}
export function defaultNewtConfig(): NewtConfig {
return {
endpoint: "https://app.pangolin.net",
newtId: "",
newtSecret: "",
networkName: "newt",
};
}
export function defaultCloudflaredConfig(): CloudflaredConfig {
return {
tunnelToken: "",
noAutoupdate: true,
};
}
export function defaultWireguardConfig(): WireguardConfig {
return {
configPath: "/etc/wireguard/wg0.conf",
interfaceName: "wg0",
};
}
export function defaultZerotierConfig(): ZerotierConfig {
return {
networkId: "",
identityPath: "/var/lib/zerotier-one",
};
}
export function defaultNetbirdConfig(): NetbirdConfig {
return {
setupKey: "",
managementUrl: "",
};
}
export function defaultVPNConfig(): VPNConfig {
return {
enabled: false,
type: null,
servicesUsingVpn: [],
};
}
export function defaultService(): ServiceConfig {
return {
name: "",
image: "",
container_name: "",
ports: [],
expose: [],
volumes: [],
environment: [],
environment_syntax: "array",
volumes_syntax: "array",
command: "",
restart: "",
healthcheck: undefined,
depends_on: [],
entrypoint: "",
env_file: "",
extra_hosts: [],
dns: [],
networks: [],
user: "",
working_dir: "",
labels: [],
privileged: undefined,
read_only: undefined,
shm_size: "",
security_opt: [],
network_mode: "",
cap_add: [],
cap_drop: [],
sysctls: [],
devices: [],
tmpfs: [],
ulimits: [],
init: undefined,
stop_grace_period: "",
stop_signal: "",
tty: undefined,
stdin_open: undefined,
hostname: "",
domainname: "",
mac_address: "",
ipc_mode: "",
pid: "",
uts: "",
cgroup_parent: "",
isolation: "",
deploy: undefined,
};
}
export function defaultNetwork(): NetworkConfig {
return {
name: "",
driver: "",
driver_opts: [],
attachable: false,
labels: [],
external: false,
name_external: "",
internal: false,
enable_ipv6: false,
ipam: {
driver: "",
config: [],
options: [],
},
};
}
export function defaultVolume(): VolumeConfig {
return {
name: "",
driver: "",
driver_opts: [],
labels: [],
external: false,
name_external: "",
driver_opts_type: "",
driver_opts_device: "",
driver_opts_o: "",
};
}

View File

@@ -1,149 +0,0 @@
// .env file generation utilities (ScaleTail style with comments)
import type { ServiceConfig } from "../types/compose";
export interface EnvVariable {
key: string;
value: string;
comment?: string;
required?: boolean;
}
/**
* Generate .env file content with comments (ScaleTail style)
*/
export function generateEnvFile(
services: ServiceConfig[],
vpnConfig?: any
): string {
const lines: string[] = [];
// Header comment
lines.push("# .env file generated by Dock-Dploy");
lines.push("# Make sure you have updated/checked the .env file with the correct variables.");
lines.push("# All the ${ xx } need to be defined here.");
lines.push("");
// Service environment variables
const envVars = new Map<string, EnvVariable>();
services.forEach((service) => {
service.environment.forEach((env) => {
if (env.key && !envVars.has(env.key)) {
envVars.set(env.key, {
key: env.key,
value: env.value || "",
comment: `Used by service: ${service.name}`,
});
}
});
});
if (envVars.size > 0) {
lines.push("# Service Environment Variables");
envVars.forEach((envVar) => {
if (envVar.comment) {
lines.push(`# ${envVar.comment}`);
}
// Check if value is a template variable reference
const isTemplateVar = envVar.value.startsWith("${") && envVar.value.endsWith("}");
if (isTemplateVar) {
lines.push(`${envVar.key}=${envVar.value}`);
} else {
lines.push(`${envVar.key}=${envVar.value || ""}`);
}
});
lines.push("");
}
// VPN-specific variables
if (vpnConfig && vpnConfig.enabled) {
lines.push("# VPN Configuration Variables");
if (vpnConfig.type === "tailscale" && vpnConfig.tailscale) {
const ts = vpnConfig.tailscale;
if (ts.authKey) {
lines.push("# Tailscale auth key - get from https://login.tailscale.com/admin/settings/keys");
lines.push("TS_AUTHKEY=");
}
if (ts.hostname) {
lines.push(`# Tailscale hostname for this device`);
lines.push(`TS_HOSTNAME=${ts.hostname}`);
}
} else if (vpnConfig.type === "newt" && vpnConfig.newt) {
const newt = vpnConfig.newt;
if (newt.newtId) {
lines.push("# Newt ID from Pangolin");
lines.push("NEWT_ID=");
}
if (newt.newtSecret) {
lines.push("# Newt Secret from Pangolin");
lines.push("NEWT_SECRET=");
}
} else if (vpnConfig.type === "cloudflared" && vpnConfig.cloudflared) {
if (vpnConfig.cloudflared.tunnelToken) {
lines.push("# Cloudflare Tunnel Token");
lines.push("TUNNEL_TOKEN=");
}
} else if (vpnConfig.type === "zerotier" && vpnConfig.zerotier) {
if (vpnConfig.zerotier.networkId) {
lines.push("# ZeroTier Network ID");
lines.push("ZT_NETWORK_ID=");
}
} else if (vpnConfig.type === "netbird" && vpnConfig.netbird) {
if (vpnConfig.netbird.setupKey) {
lines.push("# Netbird Setup Key");
lines.push("NETBIRD_SETUP_KEY=");
}
}
lines.push("");
}
// Common service variables
const commonVars: EnvVariable[] = [
{ key: "SERVICE", value: "", comment: "Service name - used for container naming" },
{ key: "IMAGE_URL", value: "", comment: "Docker image URL" },
{ key: "SERVICEPORT", value: "", comment: "Service port number" },
{ key: "DNS_SERVER", value: "9.9.9.9", comment: "DNS server (optional, uncomment if needed)" },
];
const hasCommonVars = services.some((s) =>
s.environment.some((e) =>
commonVars.some((cv) => e.key === cv.key)
)
);
if (hasCommonVars) {
lines.push("# Common Variables");
commonVars.forEach((cv) => {
if (cv.comment) {
lines.push(`# ${cv.comment}`);
}
lines.push(`${cv.key}=${cv.value || ""}`);
});
}
return lines.join("\n");
}
/**
* Extract environment variables from services for .env file
*/
export function extractEnvVariables(services: ServiceConfig[]): EnvVariable[] {
const envVars = new Map<string, EnvVariable>();
services.forEach((service) => {
service.environment.forEach((env) => {
if (env.key && !envVars.has(env.key)) {
envVars.set(env.key, {
key: env.key,
value: env.value || "",
comment: `Used by service: ${service.name}`,
});
}
});
});
return Array.from(envVars.values());
}

View File

@@ -1,93 +0,0 @@
// Template TOML parser utility for Dokploy templates
import * as TOML from "@iarna/toml";
import type { TemplateFormat, TemplateVariable, TemplateDomain, TemplateMount } from "../types/template";
export interface ParsedTemplateToml {
variables?: Record<string, string>;
domains?: TemplateDomain[];
env?: { key: string; value: string }[];
mounts?: TemplateMount[];
}
/**
* Parse template.toml content into structured format
*/
export function parseTemplateToml(tomlContent: string): ParsedTemplateToml {
try {
const parsed = TOML.parse(tomlContent) as any;
const result: ParsedTemplateToml = {};
// Extract variables section
if (parsed.variables) {
result.variables = parsed.variables;
}
// Extract config.domains array
if (parsed.config?.domains && Array.isArray(parsed.config.domains)) {
result.domains = parsed.config.domains.map((domain: any) => ({
serviceName: domain.serviceName || "",
port: domain.port || 0,
host: domain.host || "",
path: domain.path,
env: domain.env || [],
}));
}
// Extract config.env (can be array or object)
if (parsed.config?.env) {
if (Array.isArray(parsed.config.env)) {
// Array format: ["KEY=value", "KEY2=value2"]
result.env = parsed.config.env.map((envStr: string) => {
const [key, ...valueParts] = envStr.split("=");
return {
key: key.trim(),
value: valueParts.join("=").trim(),
};
});
} else if (typeof parsed.config.env === "object") {
// Object format: { KEY: "value", KEY2: "value2" }
result.env = Object.entries(parsed.config.env).map(([key, value]) => ({
key,
value: String(value),
}));
}
}
// Extract config.mounts array
if (parsed.config?.mounts && Array.isArray(parsed.config.mounts)) {
result.mounts = parsed.config.mounts.map((mount: any) => ({
filePath: mount.filePath || "",
content: mount.content || "",
description: mount.description,
}));
}
return result;
} catch (error) {
console.error("Error parsing template.toml:", error);
throw new Error(`Failed to parse template.toml: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Convert parsed template to TemplateFormat interface
*/
export function toTemplateFormat(parsed: ParsedTemplateToml): TemplateFormat {
const variables: TemplateVariable[] = parsed.variables
? Object.entries(parsed.variables).map(([name, value]) => ({
name,
value: String(value),
}))
: [];
return {
variables,
config: {
domains: parsed.domains || [],
env: parsed.env || [],
mounts: parsed.mounts || [],
},
};
}

View File

@@ -1,148 +0,0 @@
// Validation utilities for Docker Compose configurations
import type { ServiceConfig } from "../types/compose";
export function validateServiceName(name: string): string | null {
if (!name) return "Service name is required";
if (!/^[a-z0-9_-]+$/i.test(name)) {
return "Service name must contain only alphanumeric characters, hyphens, and underscores";
}
return null;
}
export function validatePort(port: string): string | null {
if (!port) return null;
const num = parseInt(port, 10);
if (isNaN(num) || num < 1 || num > 65535) {
return "Port must be between 1 and 65535";
}
return null;
}
export function validateEnvVarKey(key: string): string | null {
if (!key) return null;
if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) {
return "Environment variable key should start with a letter or underscore and contain only alphanumeric characters and underscores";
}
return null;
}
export function validateCpuValue(cpu: string): string | null {
if (!cpu) return null;
if (!/^\d+(\.\d+)?$/.test(cpu)) {
return "CPU value must be a number (e.g., 0.5, 1, 2)";
}
const num = parseFloat(cpu);
if (num < 0) {
return "CPU value must be positive";
}
return null;
}
export function validateMemoryValue(memory: string): string | null {
if (!memory) return null;
if (!/^\d+[kmgKMG]?[bB]?$/.test(memory) && !/^\d+$/.test(memory)) {
return "Memory value must be a number with optional unit (e.g., 512m, 2g, 1024)";
}
return null;
}
export function validateServices(services: ServiceConfig[]): string[] {
const errors: string[] = [];
services.forEach((svc, idx) => {
if (!svc.name) {
errors.push(`Service ${idx + 1}: Name is required`);
} else {
const nameError = validateServiceName(svc.name);
if (nameError) errors.push(`Service "${svc.name}": ${nameError}`);
}
if (!svc.image) {
errors.push(`Service "${svc.name || idx + 1}": Image is required`);
}
svc.ports.forEach((port, pIdx) => {
if (port.host) {
const portError = validatePort(port.host);
if (portError)
errors.push(
`Service "${svc.name || idx + 1}" port ${pIdx + 1} host: ${portError}`
);
}
if (port.container) {
const portError = validatePort(port.container);
if (portError)
errors.push(
`Service "${svc.name || idx + 1}" port ${pIdx + 1} container: ${portError}`
);
}
});
svc.environment.forEach((env, eIdx) => {
if (env.key) {
const keyError = validateEnvVarKey(env.key);
if (keyError)
errors.push(
`Service "${svc.name || idx + 1}" env var ${eIdx + 1}: ${keyError}`
);
}
});
if (svc.deploy?.resources?.limits?.cpus) {
const cpuError = validateCpuValue(svc.deploy.resources.limits.cpus);
if (cpuError)
errors.push(`Service "${svc.name || idx + 1}" CPU limit: ${cpuError}`);
}
if (svc.deploy?.resources?.limits?.memory) {
const memError = validateMemoryValue(
svc.deploy.resources.limits.memory
);
if (memError)
errors.push(
`Service "${svc.name || idx + 1}" memory limit: ${memError}`
);
}
if (svc.deploy?.resources?.reservations?.cpus) {
const cpuError = validateCpuValue(
svc.deploy.resources.reservations.cpus
);
if (cpuError)
errors.push(
`Service "${svc.name || idx + 1}" CPU reservation: ${cpuError}`
);
}
if (svc.deploy?.resources?.reservations?.memory) {
const memError = validateMemoryValue(
svc.deploy.resources.reservations.memory
);
if (memError)
errors.push(
`Service "${svc.name || idx + 1}" memory reservation: ${memError}`
);
}
});
return errors;
}
export function redactSensitiveData(yamlText: string): string {
const sensitivePatterns = [
/password\s*[:=]\s*["']?([^"'\n]+)["']?/gi,
/secret\s*[:=]\s*["']?([^"'\n]+)["']?/gi,
/api[_-]?key\s*[:=]\s*["']?([^"'\n]+)["']?/gi,
/token\s*[:=]\s*["']?([^"'\n]+)["']?/gi,
/auth[_-]?token\s*[:=]\s*["']?([^"'\n]+)["']?/gi,
/access[_-]?key\s*[:=]\s*["']?([^"'\n]+)["']?/gi,
/private[_-]?key\s*[:=]\s*["']?([^"'\n]+)["']?/gi,
];
let redacted = yamlText;
sensitivePatterns.forEach((pattern) => {
redacted = redacted.replace(pattern, (match, value) => {
return match.replace(value, "***REDACTED***");
});
});
return redacted;
}

View File

@@ -1,242 +0,0 @@
// VPN Service Generation Utilities
import type { VPNConfig } from "../types/vpn-configs";
import type { NetworkConfig, VolumeConfig } from "../types/compose";
export function generateTailscaleServeConfig(
_targetService: string,
externalPort: string,
internalPort: string,
path: string,
protocol: "HTTPS" | "HTTP",
certDomain: string
): string {
const config: any = {
TCP: {
[externalPort]: {
HTTPS: protocol === "HTTPS",
},
},
};
if (protocol === "HTTPS") {
const certDomainKey = certDomain
? certDomain
: "$${TS_CERT_DOMAIN}";
config.Web = {
[`${certDomainKey}:${externalPort}`]: {
Handlers: {
[path]: {
Proxy: `http://127.0.0.1:${internalPort}`,
},
},
},
};
} else {
config.TCP[externalPort] = {
HTTP: true,
Handlers: {
[path]: {
Proxy: `http://127.0.0.1:${internalPort}`,
},
},
};
}
return JSON.stringify(config, null, 2);
}
export function getVpnServiceName(vpnType: string): string {
return vpnType;
}
export function generateVpnService(vpnConfig: VPNConfig | undefined): any {
if (!vpnConfig || !vpnConfig.enabled || !vpnConfig.type) return null;
const serviceName = getVpnServiceName(vpnConfig.type);
let service: any = {
restart: "always",
};
switch (vpnConfig.type) {
case "tailscale": {
const ts = vpnConfig.tailscale!;
service.image = "tailscale/tailscale:latest";
service.privileged = true;
service.volumes = [
"tailscale:/var/lib/tailscale",
"/dev/net/tun:/dev/net/tun",
];
service.environment = {
TS_STATE_DIR: "/var/lib/tailscale",
TS_ACCEPT_DNS: ts.acceptDns ? "true" : "false",
TS_AUTH_ONCE: ts.authOnce ? "true" : "false",
TS_USERSPACE: ts.userspace ? "true" : "false",
TS_AUTHKEY: ts.authKey ? "$TS_AUTHKEY" : undefined,
TS_HOSTNAME: ts.hostname || undefined,
};
if (ts.exitNode) {
service.environment.TS_EXTRA_ARGS = `--exit-node=$TS_EXIT_NODE${ts.exitNodeAllowLan ? " --exit-node-allow-lan-access" : ""}`;
}
if (ts.enableServe && ts.serveTargetService) {
service.environment.TS_SERVE_CONFIG = "/etc/tailscale/serve.json";
service.configs = [
{
source: "serve-config",
target: "/etc/tailscale/serve.json",
},
];
}
// Remove undefined environment variables
Object.keys(service.environment).forEach(
(key) =>
service.environment[key] === undefined &&
delete service.environment[key]
);
break;
}
case "newt": {
const newt = vpnConfig.newt!;
service.image = "fosrl/newt";
service.container_name = "newt";
service.environment = {
PANGOLIN_ENDPOINT: newt.endpoint,
NEWT_ID: newt.newtId ? "${NEWT_ID}" : undefined,
NEWT_SECRET: newt.newtSecret ? "${NEWT_SECRET}" : undefined,
};
service.networks = [newt.networkName];
Object.keys(service.environment).forEach(
(key) =>
service.environment[key] === undefined &&
delete service.environment[key]
);
break;
}
case "cloudflared": {
const cf = vpnConfig.cloudflared!;
service.image = "cloudflare/cloudflared";
service.command = cf.noAutoupdate
? "--no-autoupdate tunnel run"
: "tunnel run";
service.environment = {
TUNNEL_TOKEN: cf.tunnelToken ? "${TUNNEL_TOKEN}" : undefined,
};
Object.keys(service.environment).forEach(
(key) =>
service.environment[key] === undefined &&
delete service.environment[key]
);
break;
}
case "wireguard": {
const wg = vpnConfig.wireguard!;
service.image = "linuxserver/wireguard:latest";
service.cap_add = ["NET_ADMIN", "SYS_MODULE"];
service.environment = {
PUID: "1000",
PGID: "1000",
TZ: "Etc/UTC",
};
service.sysctls = ["net.ipv4.conf.all.src_valid_mark=1"];
service.volumes = [wg.configPath + ":/config"];
break;
}
case "zerotier": {
const zt = vpnConfig.zerotier!;
service.image = "zerotier/zerotier:latest";
service.privileged = true;
service.networks = ["host"];
service.volumes = [zt.identityPath + ":/var/lib/zerotier-one"];
service.environment = {
ZT_NC_NETWORK: zt.networkId ? "${ZT_NETWORK_ID}" : undefined,
};
Object.keys(service.environment).forEach(
(key) =>
service.environment[key] === undefined &&
delete service.environment[key]
);
break;
}
case "netbird": {
const nb = vpnConfig.netbird!;
service.image = "netbirdio/netbird:latest";
service.privileged = true;
service.cap_add = ["NET_ADMIN", "SYS_MODULE"];
service.sysctls = [
"net.ipv4.ip_forward=1",
"net.ipv6.conf.all.forwarding=1",
];
service.environment = {
NETBIRD_SETUP_KEY: nb.setupKey ? "${NETBIRD_SETUP_KEY}" : undefined,
NETBIRD_MANAGEMENT_URL: nb.managementUrl || undefined,
};
Object.keys(service.environment).forEach(
(key) =>
service.environment[key] === undefined &&
delete service.environment[key]
);
break;
}
}
return { [serviceName]: service };
}
export function getVpnVolumes(vpnConfig: VPNConfig | undefined): VolumeConfig[] {
if (!vpnConfig || !vpnConfig.enabled || !vpnConfig.type) return [];
const volumes: VolumeConfig[] = [];
switch (vpnConfig.type) {
case "tailscale": {
volumes.push({
name: "tailscale",
driver: "",
driver_opts: [],
labels: [],
external: false,
name_external: "",
driver_opts_type: "",
driver_opts_device: "",
driver_opts_o: "",
});
break;
}
}
return volumes;
}
export function getVpnNetworks(vpnConfig: VPNConfig | undefined): NetworkConfig[] {
if (!vpnConfig || !vpnConfig.enabled || !vpnConfig.type) return [];
const networks: NetworkConfig[] = [];
switch (vpnConfig.type) {
case "newt": {
const newt = vpnConfig.newt!;
networks.push({
name: newt.networkName,
driver: "",
driver_opts: [],
attachable: false,
labels: [],
external: true,
name_external: newt.networkName,
internal: false,
enable_ipv6: false,
ipam: {
driver: "",
config: [],
options: [],
},
});
break;
}
}
return networks;
}

View File

@@ -1,230 +0,0 @@
// YAML comment generation utilities
import type { ServiceConfig } from "../types/compose";
export interface YAMLComment {
key: string;
comment: string;
position: "before" | "after";
}
/**
* Generate comments for a service based on its configuration
*/
export function generateServiceComments(service: ServiceConfig): string[] {
const comments: string[] = [];
if (service.name) {
comments.push(`# ${service.name} service`);
}
if (service.image) {
comments.push(`# Image: ${service.image}`);
}
if (service.ports && service.ports.length > 0) {
const portComments = service.ports.map((p) => {
if (p.host && p.container) {
return `# Port ${p.host}:${p.container}${p.protocol && p.protocol !== "none" ? `/${p.protocol}` : ""}`;
} else if (p.container) {
return `# Exposed port ${p.container}${p.protocol && p.protocol !== "none" ? `/${p.protocol}` : ""}`;
}
return "";
}).filter(Boolean);
if (portComments.length > 0) {
comments.push(...portComments);
}
}
if (service.volumes && service.volumes.length > 0) {
const volumeComments = service.volumes.map((v) => {
if (v.host && v.container) {
return `# Volume: ${v.host} -> ${v.container}${v.read_only ? " (read-only)" : ""}`;
} else if (v.container) {
return `# Anonymous volume: ${v.container}`;
}
return "";
}).filter(Boolean);
if (volumeComments.length > 0) {
comments.push(...volumeComments);
}
}
if (service.environment && service.environment.length > 0) {
const envCount = service.environment.filter((e) => e.key).length;
if (envCount > 0) {
comments.push(`# Environment variables: ${envCount} defined`);
}
}
if (service.healthcheck) {
comments.push("# Health check configured");
}
if (service.depends_on && service.depends_on.length > 0) {
comments.push(`# Depends on: ${service.depends_on.join(", ")}`);
}
return comments;
}
/**
* Generate comments for VPN service
*/
export function generateVpnServiceComments(vpnType: string, config: any): string[] {
const comments: string[] = [];
switch (vpnType) {
case "tailscale":
comments.push("# Tailscale Sidecar Configuration");
comments.push("# Routes traffic through Tailscale VPN");
if (config?.hostname) {
comments.push(`# Hostname: ${config.hostname}`);
}
if (config?.enableServe) {
comments.push("# Tailscale Serve enabled - exposes service on Tailnet");
}
if (config?.exitNode) {
comments.push(`# Using exit node: ${config.exitNode}`);
}
break;
case "newt":
comments.push("# Newt VPN Configuration");
comments.push("# Lightweight VPN with Pangolin integration");
break;
case "cloudflared":
comments.push("# Cloudflared Tunnel Configuration");
comments.push("# Routes traffic through Cloudflare Tunnel");
break;
case "wireguard":
comments.push("# WireGuard VPN Configuration");
break;
case "zerotier":
comments.push("# ZeroTier VPN Configuration");
break;
case "netbird":
comments.push("# Netbird VPN Configuration");
break;
}
return comments;
}
/**
* Generate comments for network configuration
*/
export function generateNetworkComments(network: any): string[] {
const comments: string[] = [];
if (network.name) {
comments.push(`# Network: ${network.name}`);
}
if (network.driver) {
comments.push(`# Driver: ${network.driver}`);
}
if (network.external) {
comments.push("# External network");
}
if (network.internal) {
comments.push("# Internal network (no external access)");
}
return comments;
}
/**
* Generate comments for volume configuration
*/
export function generateVolumeComments(volume: any): string[] {
const comments: string[] = [];
if (volume.name) {
comments.push(`# Volume: ${volume.name}`);
}
if (volume.driver) {
comments.push(`# Driver: ${volume.driver}`);
}
if (volume.external) {
comments.push("# External volume");
}
return comments;
}
/**
* Add comments to YAML string
*/
export function addCommentsToYAML(
yaml: string,
services: ServiceConfig[],
vpnConfig?: any
): string {
const lines = yaml.split("\n");
const commentedLines: string[] = [];
let inServices = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Detect sections
if (trimmed === "services:" || trimmed.startsWith("services:")) {
inServices = true;
commentedLines.push(line);
continue;
}
if (trimmed === "networks:" || trimmed.startsWith("networks:")) {
inServices = false;
commentedLines.push(line);
continue;
}
if (trimmed === "volumes:" || trimmed.startsWith("volumes:")) {
inServices = false;
commentedLines.push(line);
continue;
}
// Add comments before service definitions
if (inServices && trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("-") && trimmed.endsWith(":")) {
const serviceName = trimmed.replace(":", "").trim();
const currentService = services.find((s) => s.name === serviceName) || null;
if (currentService) {
const serviceComments = generateServiceComments(currentService);
if (serviceComments.length > 0) {
commentedLines.push("");
serviceComments.forEach((comment) => {
commentedLines.push(comment);
});
}
}
}
// Add VPN service comments
if (inServices && trimmed && vpnConfig?.enabled && vpnConfig?.type) {
const vpnServiceName = vpnConfig.type;
if (trimmed.startsWith(vpnServiceName + ":") || trimmed === `${vpnServiceName}:`) {
const vpnComments = generateVpnServiceComments(vpnConfig.type, vpnConfig[vpnConfig.type]);
if (vpnComments.length > 0) {
commentedLines.push("");
vpnComments.forEach((comment) => {
commentedLines.push(comment);
});
}
}
}
commentedLines.push(line);
}
return commentedLines.join("\n");
}

View File

@@ -1,615 +0,0 @@
// YAML Generation Utilities for Docker Compose
import type {
ServiceConfig,
NetworkConfig,
VolumeConfig,
} from "../types/compose";
import type { VPNConfig } from "../types/vpn-configs";
import {
generateVpnService,
getVpnVolumes,
getVpnNetworks,
getVpnServiceName,
generateTailscaleServeConfig,
} from "./vpn-generator";
import { defaultVPNConfig } from "./default-configs";
export function generateYaml(
services: ServiceConfig[],
networks: NetworkConfig[],
volumes: VolumeConfig[],
vpnConfig?: VPNConfig
): string {
// Ensure vpnConfig has a default value
const vpn = vpnConfig || defaultVPNConfig();
const compose: any = { services: {} };
services.forEach((svc) => {
if (!svc.name) return;
const parseCommandString = (cmd: string): string[] => {
if (!cmd) return [];
if (Array.isArray(cmd)) {
return cmd;
}
try {
const parsed = JSON.parse(cmd);
if (Array.isArray(parsed)) {
return parsed;
}
} catch (e) {}
const parts = cmd.match(/(?:"[^"]*"|'[^']*'|\S+)/g) || [];
return parts.map((part) => {
const trimmed = part.replace(/^["']|["']$/g, "");
return trimmed;
});
};
// Check if service should use VPN
const shouldUseVpn =
vpn.enabled &&
vpnConfig?.type &&
vpn.servicesUsingVpn.includes(svc.name);
const vpnServiceName =
vpn.enabled && vpn.type ? getVpnServiceName(vpn.type) : null;
// VPN types that use network_mode
const usesNetworkMode =
vpn.enabled &&
vpn.type &&
["tailscale", "cloudflared"].includes(vpn.type) &&
shouldUseVpn;
compose.services[svc.name] = {
image: svc.image || undefined,
container_name: svc.container_name || undefined,
command: svc.command ? parseCommandString(svc.command) : undefined,
restart: svc.restart || undefined,
// If using VPN with network_mode, don't expose ports (they go through VPN)
ports: usesNetworkMode
? undefined
: svc.ports.length
? svc.ports
.map((p) => {
if (!p.container) return undefined;
const portStr =
p.host && p.container
? `${p.host}:${p.container}`
: p.container;
// Only add protocol if it's not "none"
return p.protocol && p.protocol !== "none"
? `${portStr}/${p.protocol}`
: portStr;
})
.filter(Boolean)
: undefined,
expose:
svc.expose && svc.expose.length > 0
? svc.expose.filter(Boolean)
: undefined,
// Network mode: use VPN network_mode if enabled, otherwise use user-defined
network_mode:
usesNetworkMode && vpnServiceName
? `service:${vpnServiceName}`
: svc.network_mode || undefined,
volumes: svc.volumes.length
? svc.volumes_syntax === "dict"
? svc.volumes
.map((v) => {
if (v.host && v.container) {
const vol: any = {
type: "bind",
source: v.host,
target: v.container,
};
if (v.read_only) {
vol.read_only = true;
}
return vol;
} else if (v.container) {
// Anonymous volume - just target path
return {
type: "volume",
target: v.container,
};
}
return undefined;
})
.filter(Boolean)
: svc.volumes
.map((v) => {
if (v.host && v.container) {
return v.read_only
? `${v.host}:${v.container}:ro`
: `${v.host}:${v.container}`;
}
return v.container ? v.container : undefined;
})
.filter(Boolean)
: undefined,
environment: svc.environment.length
? svc.environment_syntax === "dict"
? svc.environment
.filter(({ key }) => key)
.reduce(
(acc, { key, value }) => {
acc[key] = value;
return acc;
},
{} as Record<string, string>
)
: svc.environment
.filter(({ key }) => key)
.map(({ key, value }) => `${key}=${value}`)
: undefined,
healthcheck:
svc.healthcheck && svc.healthcheck.test
? {
test: parseCommandString(svc.healthcheck.test),
interval: svc.healthcheck.interval || undefined,
timeout: svc.healthcheck.timeout || undefined,
retries: svc.healthcheck.retries || undefined,
start_period: svc.healthcheck.start_period || undefined,
start_interval: svc.healthcheck.start_interval || undefined,
}
: undefined,
depends_on:
svc.depends_on && svc.depends_on.filter(Boolean).length
? svc.depends_on.filter(Boolean)
: undefined,
entrypoint: svc.entrypoint
? parseCommandString(svc.entrypoint)
: undefined,
env_file:
svc.env_file && svc.env_file.trim()
? svc.env_file.split(",").map((f) => f.trim())
: undefined,
extra_hosts:
svc.extra_hosts && svc.extra_hosts.filter(Boolean).length
? svc.extra_hosts.filter(Boolean)
: undefined,
dns:
svc.dns && svc.dns.filter(Boolean).length
? svc.dns.filter(Boolean)
: undefined,
networks: usesNetworkMode
? undefined
: shouldUseVpn && vpn.type === "newt" && vpn.newt
? [vpn.newt.networkName]
: svc.networks && svc.networks.filter(Boolean).length
? svc.networks.filter(Boolean)
: undefined,
user: svc.user ? `"${svc.user}"` : undefined,
working_dir: svc.working_dir || undefined,
labels:
svc.labels && svc.labels.filter((l) => l.key).length
? svc.labels
.filter((l) => l.key)
.map(({ key, value }) => `"${key}=${value}"`)
: undefined,
privileged: svc.privileged !== undefined ? svc.privileged : undefined,
read_only: svc.read_only !== undefined ? svc.read_only : undefined,
shm_size: svc.shm_size || undefined,
security_opt:
svc.security_opt && svc.security_opt.filter(Boolean).length
? svc.security_opt.filter(Boolean)
: undefined,
cap_add:
svc.cap_add && svc.cap_add.filter(Boolean).length
? svc.cap_add.filter(Boolean)
: undefined,
cap_drop:
svc.cap_drop && svc.cap_drop.filter(Boolean).length
? svc.cap_drop.filter(Boolean)
: undefined,
sysctls:
svc.sysctls && svc.sysctls.filter((s) => s.key).length
? svc.sysctls
.filter((s) => s.key)
.reduce(
(acc, { key, value }) => {
acc[key] = value || undefined;
return acc;
},
{} as Record<string, string | undefined>
)
: undefined,
devices:
svc.devices && svc.devices.filter(Boolean).length
? svc.devices.filter(Boolean)
: undefined,
tmpfs:
svc.tmpfs && svc.tmpfs.filter(Boolean).length
? svc.tmpfs.filter(Boolean)
: undefined,
ulimits:
svc.ulimits && svc.ulimits.filter((u) => u.name).length
? svc.ulimits
.filter((u) => u.name)
.reduce(
(acc, u) => {
if (u.soft && u.hard) {
acc[u.name] = {
soft: parseInt(u.soft),
hard: parseInt(u.hard),
};
} else if (u.soft) {
acc[u.name] = { soft: parseInt(u.soft) };
} else if (u.hard) {
acc[u.name] = { hard: parseInt(u.hard) };
} else {
acc[u.name] = {};
}
return acc;
},
{} as Record<string, any>
)
: undefined,
init: svc.init !== undefined ? svc.init : undefined,
stop_grace_period: svc.stop_grace_period || undefined,
stop_signal: svc.stop_signal || undefined,
tty: svc.tty !== undefined ? svc.tty : undefined,
stdin_open: svc.stdin_open !== undefined ? svc.stdin_open : undefined,
hostname: svc.hostname || undefined,
domainname: svc.domainname || undefined,
mac_address: svc.mac_address || undefined,
ipc: svc.ipc_mode || undefined,
pid: svc.pid || undefined,
uts: svc.uts || undefined,
cgroup_parent: svc.cgroup_parent || undefined,
isolation: svc.isolation || undefined,
deploy:
svc.deploy && svc.deploy.resources
? (() => {
const limits: any = {};
if (svc.deploy.resources.limits?.cpus)
limits.cpus = svc.deploy.resources.limits.cpus;
if (svc.deploy.resources.limits?.memory)
limits.memory = svc.deploy.resources.limits.memory;
const reservations: any = {};
if (svc.deploy.resources.reservations?.cpus)
reservations.cpus = svc.deploy.resources.reservations.cpus;
if (svc.deploy.resources.reservations?.memory)
reservations.memory =
svc.deploy.resources.reservations.memory;
const resources: any = {};
if (Object.keys(limits).length > 0) resources.limits = limits;
if (Object.keys(reservations).length > 0)
resources.reservations = reservations;
return Object.keys(resources).length > 0
? { resources }
: undefined;
})()
: undefined,
};
});
for (const name in compose.services) {
Object.keys(compose.services[name]).forEach(
(k) =>
compose.services[name][k] === undefined &&
delete compose.services[name][k]
);
}
// Add VPN service if enabled
if (vpn.enabled && vpn.type) {
const vpnService = generateVpnService(vpn);
if (vpnService) {
Object.assign(compose.services, vpnService);
}
}
// Add VPN volumes
const vpnVolumes = getVpnVolumes(vpn);
if (vpnVolumes.length > 0) {
volumes = [...volumes, ...vpnVolumes];
}
// Add VPN networks
const vpnNetworks = getVpnNetworks(vpn);
if (vpnNetworks.length > 0) {
networks = [...networks, ...vpnNetworks];
}
// Add Tailscale serve configs if enabled
if (
vpn.enabled &&
vpn.type === "tailscale" &&
vpn.tailscale?.enableServe &&
vpn.tailscale?.serveTargetService
) {
const ts = vpn.tailscale;
const serveConfig = generateTailscaleServeConfig(
ts.serveTargetService,
ts.serveExternalPort,
ts.serveInternalPort,
ts.servePath,
ts.serveProtocol,
ts.certDomain
);
if (!compose.configs) {
compose.configs = {};
}
compose.configs["serve-config"] = {
content: serveConfig,
};
}
if (networks.length) {
compose.networks = {};
networks.forEach((n) => {
if (!n.name) return;
if (n.external) {
compose.networks[n.name] = {
external: n.name_external ? { name: n.name_external } : true,
};
} else {
compose.networks[n.name] = {
driver: n.driver || undefined,
attachable: n.attachable !== undefined ? n.attachable : undefined,
internal: n.internal !== undefined ? n.internal : undefined,
enable_ipv6:
n.enable_ipv6 !== undefined ? n.enable_ipv6 : undefined,
driver_opts:
n.driver_opts && n.driver_opts.length
? n.driver_opts
.filter((opt) => opt.key)
.reduce(
(acc, { key, value }) => {
acc[key] = value;
return acc;
},
{} as Record<string, string>
)
: undefined,
labels:
n.labels && n.labels.length
? n.labels
.filter((l) => l.key)
.map(({ key, value }) => `"${key}=${value}"`)
: undefined,
ipam:
n.ipam.driver || n.ipam.config.length || n.ipam.options.length
? {
driver: n.ipam.driver || undefined,
config: n.ipam.config.length ? n.ipam.config : undefined,
options: n.ipam.options.length
? n.ipam.options
.filter((opt) => opt.key)
.reduce(
(acc, { key, value }) => {
acc[key] = value;
return acc;
},
{} as Record<string, string>
)
: undefined,
}
: undefined,
};
}
Object.keys(compose.networks[n.name]).forEach(
(k) =>
compose.networks[n.name][k] === undefined &&
delete compose.networks[n.name][k]
);
});
}
if (volumes.length) {
compose.volumes = {};
volumes.forEach((v) => {
if (!v.name) return;
if (v.external) {
const externalVolume: any = {
external: v.name_external ? { name: v.name_external } : true,
};
if (v.driver) {
externalVolume.driver = v.driver;
}
const driverOpts: Record<string, string> = {};
if (v.driver_opts && v.driver_opts.length) {
v.driver_opts
.filter((opt) => opt.key)
.forEach(({ key, value }) => {
driverOpts[key] = value;
});
}
if (v.driver_opts_type) driverOpts.type = v.driver_opts_type;
if (v.driver_opts_device) driverOpts.device = v.driver_opts_device;
if (v.driver_opts_o) driverOpts.o = v.driver_opts_o;
if (Object.keys(driverOpts).length > 0) {
externalVolume.driver_opts = driverOpts;
}
if (v.labels && v.labels.length) {
externalVolume.labels = v.labels
.filter((l) => l.key)
.map(({ key, value }) => `"${key}=${value}"`);
}
compose.volumes[v.name] = externalVolume;
} else {
const driverOpts: Record<string, string> = {};
if (v.driver_opts && v.driver_opts.length) {
v.driver_opts
.filter((opt) => opt.key)
.forEach(({ key, value }) => {
driverOpts[key] = value;
});
}
if (v.driver_opts_type) driverOpts.type = v.driver_opts_type;
if (v.driver_opts_device) driverOpts.device = v.driver_opts_device;
if (v.driver_opts_o) driverOpts.o = v.driver_opts_o;
compose.volumes[v.name] = {
driver: v.driver || undefined,
driver_opts:
Object.keys(driverOpts).length > 0 ? driverOpts : undefined,
labels:
v.labels && v.labels.length
? v.labels
.filter((l) => l.key)
.map(({ key, value }) => `"${key}=${value}"`)
: undefined,
};
}
Object.keys(compose.volumes[v.name]).forEach(
(k) =>
compose.volumes[v.name][k] === undefined &&
delete compose.volumes[v.name][k]
);
});
}
let yamlOutput = yamlStringify(compose);
// Add comments to YAML for VPN services
if (vpn.enabled && vpn.type) {
const lines = yamlOutput.split("\n");
const commentedLines: string[] = [];
let inVpnService = false;
let inServicesSection = false;
let inVolumesSection = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Track which section we're in
if (trimmed === "services:") {
inServicesSection = true;
inVolumesSection = false;
} else if (trimmed === "volumes:") {
inVolumesSection = true;
inServicesSection = false;
} else if (trimmed === "networks:" || trimmed === "configs:") {
inServicesSection = false;
inVolumesSection = false;
}
// Detect VPN service (only in services section, not volumes)
if (
inServicesSection &&
!inVolumesSection &&
(trimmed.startsWith(`${vpn.type}:`) || trimmed === `${vpn.type}:`)
) {
inVpnService = true;
commentedLines.push("");
commentedLines.push(`# ${vpn.type} VPN Sidecar Configuration`);
if (vpn.type === "tailscale") {
commentedLines.push("# Routes traffic through Tailscale VPN");
if (vpn.tailscale?.hostname) {
commentedLines.push(`# Hostname: ${vpn.tailscale.hostname}`);
}
if (vpn.tailscale?.enableServe) {
commentedLines.push(
"# Tailscale Serve enabled - exposes service on Tailnet"
);
}
}
}
// Detect end of service (next service or section)
if (
inVpnService &&
trimmed &&
!trimmed.startsWith(" ") &&
!trimmed.startsWith("-") &&
trimmed.endsWith(":") &&
trimmed !== `${vpn.type}:`
) {
inVpnService = false;
}
commentedLines.push(line);
}
yamlOutput = commentedLines.join("\n");
}
return yamlOutput;
}
export function yamlStringify(obj: any, indent = 0, parentKey = ""): string {
const pad = (n: number) => " ".repeat(n);
if (typeof obj !== "object" || obj === null) return String(obj);
if (Array.isArray(obj)) {
const shouldBeSingleLine =
["command", "entrypoint"].includes(parentKey) ||
(parentKey === "test" && indent > 0);
if (shouldBeSingleLine && obj.length > 0 && typeof obj[0] === "string") {
return `[${obj.map((v) => `"${v}"`).join(", ")}]`;
}
return obj
.map(
(v) =>
`\n${pad(indent)}- ${yamlStringify(v, indent + 1, parentKey).trimStart()}`
)
.join("");
}
const entries = Object.entries(obj)
.map(([k, v]) => {
if (v === undefined) return "";
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
return `\n${pad(indent)}${k}:` + yamlStringify(v, indent + 1, k);
}
if (Array.isArray(v)) {
if (
["command", "entrypoint"].includes(k) ||
(k === "test" && indent > 0)
) {
return `\n${pad(indent)}${k}: [${v.map((item) => `"${item}"`).join(", ")}]`;
}
return `\n${pad(indent)}${k}: ` + yamlStringify(v, indent + 1, k);
}
// Handle multi-line strings (like JSON in configs.content) using literal block scalar
if (
typeof v === "string" &&
k === "content" &&
parentKey &&
v.includes("\n")
) {
// Use YAML literal block scalar (|) to preserve multi-line strings
const lines = v.split("\n");
const escapedLines = lines.map((line, idx) => {
// Escape special YAML characters if needed
if (line.trim() === "" && idx === lines.length - 1) return "";
return line;
});
return `\n${pad(indent)}${k}: |\n${escapedLines.map((line) => `${pad(indent + 1)}${line}`).join("\n")}`;
}
// For regular strings, output as-is (don't add quotes unless necessary)
// Port strings (like "8080:8080" or "8080/tcp") should not be quoted
if (typeof v === "string") {
// Don't quote port mappings (format: "host:container" or "port/protocol")
const isPortMapping = /^\d+(:\d+)?(\/\w+)?$/.test(v);
// Don't quote simple numeric strings or port-like values
if (isPortMapping || /^\d+$/.test(v)) {
return `\n${pad(indent)}${k}: ${v}`;
}
// Only quote if the string contains special YAML characters that need escaping
// But exclude colons in port mappings which are already handled above
const needsQuotes =
/^[\d-]|[:{}\[\],&*#?|>'"%@`]/.test(v) || v.trim() !== v;
return `\n${pad(indent)}${k}: ${needsQuotes ? `"${v.replace(/"/g, '\\"')}"` : v}`;
}
return `\n${pad(indent)}${k}: ${v}`;
})
.join("");
return indent === 0 && entries.startsWith("\n")
? entries.slice(1)
: entries;
}