mirror of
https://github.com/hhftechnology/Dock-Dploy.git
synced 2026-04-25 02:47:53 -05:00
Compare commits
1 Commits
v0.0.3
...
ninjasurge
| Author | SHA1 | Date | |
|---|---|---|---|
| affc645b95 |
2
.github/workflows/docker-build-push.yml
vendored
2
.github/workflows/docker-build-push.yml
vendored
@@ -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
|
||||
|
||||
14
README.md
14
README.md
@@ -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
7
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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: "",
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user