15 Commits

Author SHA1 Message Date
hhftechnologies
2b95bec646 Update pnpm-lock.yaml 2025-12-07 16:00:55 +05:30
hhftechnologies
65fdced852 CVE-2025-55182-Fix-updated 2025-12-07 15:55:24 +05:30
HHF Technology
6827fa5f38 Fix Docker badge link case sensitivity 2025-12-05 19:32:37 +05:30
hhftechnologies
7b260e4460 Update vpn-generator.ts 2025-12-01 19:22:36 +05:30
HHF Technology
d57b51871c Merge pull request #8 from hhftechnology/dev
bug-fixes-optimizations
2025-12-01 18:41:55 +05:30
hhftechnologies
0b0b1e708b bug-fixes-optimizations 2025-12-01 18:32:52 +05:30
hhftechnologies
7364582ba9 refactor-UI-code 2025-11-21 19:26:38 +05:30
hhftechnologies
a97a91b9bf Update docker-build-push.yml 2025-11-21 18:53:57 +05:30
hhftechnologies
1a7964c297 Update compose-builder.tsx 2025-11-21 18:49:20 +05:30
hhftechnologies
795d9d1d05 quick-bug-fix 2025-11-21 18:44:04 +05:30
HHF Technology
8438763a03 Merge pull request #6 from hhftechnology/vpn
UI-VPN-bugs-Fixes
2025-11-21 18:34:22 +05:30
hhftechnologies
826e6d1a2d UI-VPN-bugs-Fixes 2025-11-21 18:33:29 +05:30
hhftechnologies
ae62b1c8d8 update-icons 2025-11-18 17:09:40 +05:30
HHF Technology
ccc423fd98 Update README.md 2025-11-18 13:02:24 +05:30
HHF Technology
7549943336 Update README.md 2025-11-18 12:46:07 +05:30
55 changed files with 15513 additions and 6680 deletions

View File

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

View File

@@ -1,12 +1,23 @@
# Dock-Dploy
<div align="center">
<h1 align="center"><a href="https://github.com/hhftechnology/Dock-Dploy">Dock-Dploy</a></h1>
</div>
**A web-based tool for building, managing, and converting Docker Compose files, configuration files, and schedulers.**
A web-based tool for building, managing, and converting Docker Compose files, configuration files, and schedulers.
<div align="center">
[![Docker](https://img.shields.io/docker/pulls/hhftechnology/dock-dploy?style=flat-square)](https://hub.docker.com/r/hhftechnology/dock-dploy)
![Stars](https://img.shields.io/github/stars/hhftechnology/Dock-Dploy?style=flat-square)
[![Discord](https://img.shields.io/discord/994247717368909884?logo=discord&style=flat-square)](https://discord.gg/HDCt9MjyMJ)
[![React](https://img.shields.io/badge/React-19.0-61dafb.svg)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178c6.svg)](https://www.typescriptlang.org/)
[![Vite](https://img.shields.io/badge/Vite-6.1-646cff.svg)](https://vitejs.dev/)
---
</div>
<img width="1735" height="2354" alt="image" src="https://github.com/user-attachments/assets/2f06bbe1-8161-4263-a030-f9cd258ee372" />
<img width="1735" height="817" alt="image" src="https://github.com/user-attachments/assets/615c49e8-181f-4be8-8c07-aebc7aa2fb29" />
## Table of Contents
@@ -212,9 +223,7 @@ docker-compose up -d
```yaml
services:
dock-dploy:
build:
context: .
dockerfile: Dockerfile
image: hhftechnology/dock-dploy:latest
container_name: dock-dploy
restart: unless-stopped
ports:

2729
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,32 +11,32 @@
},
"dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@hookform/resolvers": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.7",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-router": "^1.121.2",
"@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/router-plugin": "^1.121.2",
"@uiw/codemirror-extensions-hyper-link": "^4.23.14",
"@uiw/codemirror-theme-monokai-dimmed": "^4.23.14",
"@uiw/react-codemirror": "^4.23.14",
"@tanstack/react-router": "^1.139.14",
"@tanstack/react-router-devtools": "^1.139.14",
"@tanstack/router-plugin": "^1.139.14",
"@uiw/codemirror-extensions-hyper-link": "^4.25.3",
"@uiw/codemirror-theme-monokai-dimmed": "^4.25.3",
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"js-yaml": "^4.1.0",
"lucide-react": "^0.476.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.60.0",
"lucide-react": "^0.556.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.6",
"tailwindcss-animate": "^1.0.7",
@@ -45,16 +45,16 @@
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@testing-library/react": "^16.3.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.0.10",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"jsdom": "^26.0.0",
"typescript": "^5.7.2",
"vite": "^6.1.0",
"vitest": "^3.0.5",
"web-vitals": "^4.2.4"
}
}
}

4743
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,13 +1,7 @@
{
"short_name": "Dock-Dploy",
"name": "Build Docker Compose Files",
"icons": [
{
"src": "favicon.ico",
"sizes": "256x256 128x128 64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",

View File

@@ -1,16 +1,21 @@
import React, { useState } from "react";
import React, { useState, useMemo } 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;
height?: number;
width?: number | string;
height?: number | string;
editable?: boolean;
showCopyButton?: boolean;
minHeight?: number;
maxHeight?: number;
}
export const CodeEditor: React.FC<CodeEditorProps> = ({
@@ -18,6 +23,10 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
onContentChange,
width,
height,
editable = false,
showCopyButton = true,
minHeight = 200,
maxHeight,
}) => {
const [copied, setCopied] = useState(false);
const { theme } = useTheme();
@@ -26,7 +35,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
setCopied(false);
}
@@ -34,57 +43,87 @@ 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: 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',
width: calculatedWidth,
height: calculatedHeight,
minHeight: `${minHeight}px`,
maxHeight: maxHeight ? `${maxHeight}px` : undefined,
}}
>
{/* Copy button in top right */}
<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"}>
{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"}`}>
<CodeMirror
value={content}
height={height ? `${height}px` : `100%`}
width={width ? `${width}px` : `100%`}
height="100%"
width="100%"
theme={editorTheme}
editable={false}
editable={editable}
extensions={[
yaml(),
hyperLink,
]}
onChange={(value: string) => onContentChange(value)}
basicSetup={{ lineNumbers: true }}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: editable,
highlightActiveLine: editable,
foldGutter: true,
}}
style={{
flex: 1,
minHeight: 0,
minWidth: 0,
fontSize: 16,
}}
fontSize: 14,
fontFamily: 'monospace',
}}
/>
</div>
</div>

View File

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

View File

@@ -34,6 +34,7 @@ export function Header() {
}}
/>
<span className="font-bold text-lg">Dock-Dploy</span>
<span className="text-[8px] text-muted-foreground hidden sm:inline-block">by HHF Technology</span>
</div>
<div className="flex items-center gap-2">
<Button

View File

@@ -1,3 +1,5 @@
import { useState, useEffect } from "react";
import { ChevronDown, Container, FileText, Clock } from "lucide-react";
import { useNavigate, useRouter } from "@tanstack/react-router";
import {
@@ -10,6 +12,7 @@ import {
SidebarMenuItem,
SidebarHeader,
SidebarFooter,
useSidebar,
} from "./ui/sidebar";
import {
Collapsible,
@@ -48,18 +51,42 @@ export function SidebarUI() {
const navigate = useNavigate();
const router = useRouter();
const location = router.state.location;
const { toggleSidebar, state } = useSidebar();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
// Initialize open groups based on current route
useEffect(() => {
const newOpenGroups = { ...openGroups };
let hasChanges = false;
Object.entries(groupedItems).forEach(([groupName, groupItems]) => {
if (groupItems.some((item) => location.pathname === item.url)) {
if (!newOpenGroups[groupName]) {
newOpenGroups[groupName] = true;
hasChanges = true;
}
}
});
if (hasChanges) {
setOpenGroups(newOpenGroups);
}
}, [location.pathname]);
return (
<>
<SidebarHeader className="border-b border-sidebar-border">
<div className="flex items-center gap-2 px-2 py-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<div className="flex items-center gap-2">
<div
onClick={toggleSidebar}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground cursor-pointer hover:bg-sidebar-primary/90 transition-colors"
>
<Container className="h-4 w-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Dock-Dploy</span>
<div className="grid flex-1 text-left text-sm leading-tight group-data-[state=collapsed]:hidden">
<div className="flex items-baseline gap-1 truncate">
<span className="font-semibold">Setup Tools</span>
<span className="text-xs text-sidebar-foreground/70 truncate">v0.1.0</span>
</div>
<span className="truncate text-xs text-sidebar-foreground/70">
Setup Tools
</span>
</div>
</div>
@@ -67,14 +94,13 @@ export function SidebarUI() {
<SidebarContent>
{Object.entries(groupedItems).map(([groupName, groupItems]) => {
const isGroupOpen = groupItems.some(
(item) => location.pathname === item.url
);
const isOpen = state === "collapsed" ? true : (openGroups[groupName] || false);
return (
<Collapsible
key={groupName}
defaultOpen={isGroupOpen}
open={isOpen}
onOpenChange={(open) => setOpenGroups((prev) => ({ ...prev, [groupName]: open }))}
className="group/collapsible"
>
<SidebarGroup>
@@ -118,9 +144,9 @@ export function SidebarUI() {
})}
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border p-2">
<div className="px-2 py-1.5 text-xs text-sidebar-foreground/70">
© {new Date().getFullYear()} Dock-Dploy
<SidebarFooter className="p-4 border-t border-border/50">
<div className="flex flex-col gap-1 text-xs text-muted-foreground group-data-[state=collapsed]:hidden">
<p>© 2025 Dock-Dploy</p>
</div>
</SidebarFooter>
</>

View File

@@ -0,0 +1,185 @@
import { Label } from "../../components/ui/label";
import { Input } from "../../components/ui/input";
import { Toggle } from "../../components/ui/toggle";
import type { NetworkConfig } from "../../types/compose";
interface NetworkFormProps {
network: NetworkConfig;
onUpdate: (field: keyof NetworkConfig, value: any) => void;
}
export function NetworkForm({ network, onUpdate }: NetworkFormProps) {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-2 pb-3 border-b border-border/50">
<div className="h-8 w-1 bg-primary rounded-full"></div>
<h2 className="font-bold text-lg text-foreground">Network Configuration</h2>
</div>
{/* Basic Settings */}
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Network Name</Label>
<Input
value={network.name || ""}
onChange={(e) => onUpdate("name", e.target.value)}
placeholder="e.g. frontend-network"
className="shadow-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Driver</Label>
<Input
value={network.driver || ""}
onChange={(e) => onUpdate("driver", e.target.value)}
placeholder="e.g. bridge, overlay"
className="shadow-sm"
/>
<p className="text-xs text-muted-foreground">Common: bridge (default), overlay, host, none</p>
</div>
</div>
{/* Advanced Options */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-foreground/90 flex items-center gap-2">
<div className="h-4 w-0.5 bg-primary/50 rounded-full"></div>
Options
</h3>
<div className="grid grid-cols-2 gap-3">
<Toggle
pressed={!!network.attachable}
onPressedChange={(v) => onUpdate("attachable", v)}
aria-label="Attachable"
className="border rounded-md px-3 py-2 h-auto justify-center data-[state=on]:bg-primary/10 data-[state=on]:border-primary transition-all"
>
<span className="select-none text-sm">Attachable</span>
</Toggle>
<Toggle
pressed={!!network.internal}
onPressedChange={(v) => onUpdate("internal", v)}
aria-label="Internal"
className="border rounded-md px-3 py-2 h-auto justify-center data-[state=on]:bg-primary/10 data-[state=on]:border-primary transition-all"
>
<span className="select-none text-sm">Internal</span>
</Toggle>
<Toggle
pressed={!!network.enable_ipv6}
onPressedChange={(v) => onUpdate("enable_ipv6", v)}
aria-label="Enable IPv6"
className="border rounded-md px-3 py-2 h-auto justify-center data-[state=on]:bg-primary/10 data-[state=on]:border-primary transition-all"
>
<span className="select-none text-sm">IPv6</span>
</Toggle>
<Toggle
pressed={!!network.external}
onPressedChange={(v) => onUpdate("external", v)}
aria-label="External"
className="border rounded-md px-3 py-2 h-auto justify-center data-[state=on]:bg-primary/10 data-[state=on]:border-primary transition-all"
>
<span className="select-none text-sm">External</span>
</Toggle>
</div>
{network.external && (
<div className="space-y-2 pl-4 border-l-2 border-primary/30">
<Label className="text-sm font-medium">External Network Name</Label>
<Input
value={network.name_external || ""}
onChange={(e) => onUpdate("name_external", e.target.value)}
placeholder="Existing network name"
className="shadow-sm"
/>
<p className="text-xs text-muted-foreground">Reference an existing network</p>
</div>
)}
</div>
{/* IPAM Configuration */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-foreground/90 flex items-center gap-2">
<div className="h-4 w-0.5 bg-primary/50 rounded-full"></div>
IPAM (IP Address Management)
</h3>
<div className="space-y-2">
<Label className="text-sm font-medium">IPAM Driver</Label>
<Input
value={network.ipam?.driver || ""}
onChange={(e) => {
const updated = { ...network.ipam, driver: e.target.value };
onUpdate("ipam", updated);
}}
placeholder="default (leave empty for default)"
className="shadow-sm"
/>
<p className="text-xs text-muted-foreground">
Usually leave empty for default driver
</p>
</div>
<div className="space-y-3">
<Label className="text-sm font-medium">IP Configurations</Label>
{network.ipam?.config?.map((cfg, idx) => (
<div key={idx} className="flex gap-2 items-start p-3 border rounded-md bg-card/50">
<div className="flex-1 space-y-2">
<Input
value={cfg.subnet || ""}
onChange={(e) => {
const newConfig = [...(network.ipam?.config || [])];
newConfig[idx] = { ...newConfig[idx], subnet: e.target.value };
onUpdate("ipam", { ...network.ipam, config: newConfig });
}}
placeholder="Subnet (e.g. 192.168.1.0/24)"
className="shadow-sm text-sm"
/>
<Input
value={cfg.gateway || ""}
onChange={(e) => {
const newConfig = [...(network.ipam?.config || [])];
newConfig[idx] = { ...newConfig[idx], gateway: e.target.value };
onUpdate("ipam", { ...network.ipam, config: newConfig });
}}
placeholder="Gateway (e.g. 192.168.1.1)"
className="shadow-sm text-sm"
/>
</div>
<button
type="button"
onClick={() => {
const newConfig = network.ipam?.config?.filter((_, i) => i !== idx) || [];
onUpdate("ipam", { ...network.ipam, config: newConfig });
}}
className="text-destructive hover:text-destructive/80 p-1.5 rounded hover:bg-destructive/10 transition-colors"
title="Remove IP config"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
<button
type="button"
onClick={() => {
const newConfig = [...(network.ipam?.config || []), { subnet: "", gateway: "" }];
onUpdate("ipam", { ...network.ipam, config: newConfig });
}}
className="w-full py-2 px-3 border border-dashed rounded-md text-sm text-muted-foreground hover:text-foreground hover:border-primary/50 transition-colors"
>
+ Add IP Configuration
</button>
<p className="text-xs text-muted-foreground">
For ipvlan networks, define the subnet and gateway for IP allocation
</p>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
import { Button } from "../ui/button";
import { Card } from "../ui/card";
import { Separator } from "../ui/separator";
import type { ServiceConfig } from "../../types/compose";
import type { UseTemplateStoreReturn } from "../../hooks/useTemplateStore";
import { TemplateStoreModal } from "../templates/TemplateStoreModal";
interface ServiceListSidebarProps {
services: ServiceConfig[];
selectedIdx: number | null;
selectedType: "service" | "network" | "volume";
onSelectService: (idx: number) => void;
onAddService: () => void;
onRemoveService: (idx: number) => void;
templateStore: UseTemplateStoreReturn;
}
export function ServiceListSidebar({
services,
selectedIdx,
selectedType,
onSelectService,
onAddService,
onRemoveService,
templateStore,
}: ServiceListSidebarProps) {
return (
<aside className="h-full flex flex-col bg-card p-4 gap-4 overflow-y-auto">
{/* Header Section */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-base text-foreground/90">Services</h2>
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{services.filter(s => s.name && s.name.trim()).length}
</span>
</div>
<Button
size="sm"
onClick={onAddService}
className="w-full shadow-sm hover:shadow-md transition-all"
>
+ Add Service
</Button>
<Button
variant="outline"
size="sm"
onClick={() => templateStore.setTemplateStoreOpen(true)}
className="w-full shadow-sm hover:shadow-md transition-all"
>
Browse Templates
</Button>
</div>
<Separator />
{/* Services List */}
<div className="flex-1 flex flex-col gap-2 overflow-y-auto">
{services.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="text-muted-foreground text-sm">
No services yet
</div>
<div className="text-xs text-muted-foreground mt-1">
Add a service or browse templates
</div>
</div>
) : (
services.map((svc, idx) => (
<Card
key={`${svc.name}-${idx}`}
className={`group relative p-3 cursor-pointer transition-all duration-200 hover:shadow-md ${
selectedType === "service" && selectedIdx === idx
? "border-primary border-2 bg-primary/5 shadow-sm"
: "border-border hover:border-primary/50 hover:bg-accent/50"
}`}
onClick={() => {
onSelectService(idx);
}}
>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full flex-shrink-0 ${
svc.name && svc.image ? "bg-green-500" : "bg-amber-500"
}`} />
<div className="font-medium text-sm truncate flex-1">
{svc.name || (
<span className="text-muted-foreground italic">(unnamed service)</span>
)}
</div>
</div>
<div className="text-xs text-muted-foreground truncate pl-4">
{svc.image || <span className="italic">no image specified</span>}
</div>
{svc.ports && svc.ports.length > 0 && (
<div className="flex items-center gap-1 pl-4 mt-0.5">
<span className="text-xs text-muted-foreground">
{svc.ports.length} port{svc.ports.length !== 1 ? 's' : ''}
</span>
</div>
)}
</div>
<Button
size="icon"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onRemoveService(idx);
}}
className="absolute top-1 right-1 h-6 w-6 opacity-0 group-hover:opacity-100 hover:bg-destructive/20 hover:text-destructive transition-all"
type="button"
aria-label={`Remove service ${svc.name || "unnamed"}`}
>
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</Button>
</Card>
))
)}
</div>
<TemplateStoreModal
open={templateStore.templateStoreOpen}
onOpenChange={templateStore.setTemplateStoreOpen}
templates={templateStore.templates}
loading={templateStore.templateLoading}
error={templateStore.templateError}
cacheTimestamp={templateStore.templateCacheTimestamp}
onRefresh={templateStore.refreshTemplateStore}
onTemplateSelect={async (template) => {
try {
const details = await templateStore.fetchTemplateDetails(template.id);
templateStore.setSelectedTemplate(details);
templateStore.setTemplateDetailOpen(true);
} catch (error: any) {
templateStore.setTemplateError(
`Failed to load template: ${error.message}`
);
}
}}
/>
</aside>
);
}

View File

@@ -0,0 +1,193 @@
import { Label } from "../../components/ui/label";
import { Input } from "../../components/ui/input";
import { Button } from "../../components/ui/button";
import { Toggle } from "../../components/ui/toggle";
import type { VolumeConfig } from "../../types/compose";
interface VolumeFormProps {
volume: VolumeConfig;
onUpdate: (field: keyof VolumeConfig, value: any) => void;
}
export function VolumeForm({ volume, onUpdate }: VolumeFormProps) {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-2 pb-3 border-b border-border/50">
<div className="h-8 w-1 bg-primary rounded-full"></div>
<h2 className="font-bold text-lg text-foreground">Volume Configuration</h2>
</div>
{/* Basic Settings */}
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Volume Name</Label>
<Input
value={volume.name || ""}
onChange={(e) => onUpdate("name", e.target.value)}
placeholder="e.g. app-data"
className="shadow-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Driver</Label>
<Input
value={volume.driver || ""}
onChange={(e) => onUpdate("driver", e.target.value)}
placeholder="e.g. local"
className="shadow-sm"
/>
<p className="text-xs text-muted-foreground">Default: local</p>
</div>
</div>
{/* Driver Options */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-foreground/90 flex items-center gap-2">
<div className="h-4 w-0.5 bg-primary/50 rounded-full"></div>
Driver Options
</h3>
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-sm">Type</Label>
<Input
value={volume.driver_opts_type || ""}
onChange={(e) => onUpdate("driver_opts_type", e.target.value)}
placeholder="e.g. none, nfs"
className="shadow-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-sm">Device</Label>
<Input
value={volume.driver_opts_device || ""}
onChange={(e) => onUpdate("driver_opts_device", e.target.value)}
placeholder="e.g. /path/to/device or nfs-server:/path"
className="shadow-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-sm">Mount Options</Label>
<Input
value={volume.driver_opts_o || ""}
onChange={(e) => onUpdate("driver_opts_o", e.target.value)}
placeholder="e.g. bind, ro"
className="shadow-sm"
/>
</div>
</div>
</div>
{/* Labels */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground/90 flex items-center gap-2">
<div className="h-4 w-0.5 bg-primary/50 rounded-full"></div>
Labels
</h3>
<Button
size="sm"
variant="outline"
onClick={() =>
onUpdate("labels", [
...(volume.labels || []),
{ key: "", value: "" },
])
}
className="h-7 text-xs"
>
+ Add
</Button>
</div>
<div className="space-y-2">
{volume.labels && volume.labels.length > 0 ? (
volume.labels.map((label, idx) => (
<div key={idx} className="flex gap-2 items-center p-2 bg-muted/30 rounded-md">
<Input
value={label.key}
onChange={(e) => {
const newLabels = [...(volume.labels || [])];
newLabels[idx] = {
...newLabels[idx],
key: e.target.value,
};
onUpdate("labels", newLabels);
}}
placeholder="Key"
className="flex-1 shadow-sm"
/>
<Input
value={label.value}
onChange={(e) => {
const newLabels = [...(volume.labels || [])];
newLabels[idx] = {
...newLabels[idx],
value: e.target.value,
};
onUpdate("labels", newLabels);
}}
placeholder="Value"
className="flex-1 shadow-sm"
/>
<Button
size="icon"
variant="ghost"
onClick={() => {
const newLabels = [...(volume.labels || [])];
newLabels.splice(idx, 1);
onUpdate("labels", newLabels);
}}
className="h-8 w-8 hover:bg-destructive/20 hover:text-destructive"
>
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</Button>
</div>
))
) : (
<div className="text-xs text-muted-foreground text-center py-4">
No labels added
</div>
)}
</div>
</div>
{/* External Volume */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-foreground/90 flex items-center gap-2">
<div className="h-4 w-0.5 bg-primary/50 rounded-full"></div>
External Volume
</h3>
<Toggle
pressed={!!volume.external}
onPressedChange={(v) => onUpdate("external", v)}
aria-label="External"
className="border rounded-md px-3 py-2 h-auto w-full justify-center data-[state=on]:bg-primary/10 data-[state=on]:border-primary transition-all"
>
<span className="select-none text-sm">Use External Volume</span>
</Toggle>
{volume.external && (
<div className="space-y-2 pl-4 border-l-2 border-primary/30">
<Label className="text-sm font-medium">External Volume Name</Label>
<Input
value={volume.name_external || ""}
onChange={(e) => onUpdate("name_external", e.target.value)}
placeholder="Existing volume name"
className="shadow-sm"
/>
<p className="text-xs text-muted-foreground">Reference an existing volume</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,771 @@
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Separator } from "../ui/separator";
import { Checkbox } from "../ui/checkbox";
import {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from "../ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Alert, AlertTitle, AlertDescription } from "../ui/alert";
import { Shield, ChevronDown, ChevronRight, AlertCircle } from "lucide-react";
import type { VPNConfig } from "../../types/vpn-configs";
import type { ServiceConfig, NetworkConfig } from "../../types/compose";
interface VpnConfigSectionProps {
vpnConfig: VPNConfig;
vpnConfigOpen: boolean;
setVpnConfigOpen: (open: boolean) => void;
updateVpnType: (type: VPNConfig["type"] | null) => void;
updateTailscaleConfig: (updates: Partial<VPNConfig["tailscale"]>) => void;
updateNewtConfig: (updates: Partial<VPNConfig["newt"]>) => void;
updateCloudflaredConfig: (updates: Partial<VPNConfig["cloudflared"]>) => void;
updateWireguardConfig: (updates: Partial<VPNConfig["wireguard"]>) => void;
updateZerotierConfig: (updates: Partial<VPNConfig["zerotier"]>) => void;
updateNetbirdConfig: (updates: Partial<VPNConfig["netbird"]>) => void;
updateServicesUsingVpn: (services: string[]) => void;
updateVpnNetworks: (networks: string[]) => void;
services: ServiceConfig[];
networks: NetworkConfig[];
}
export function VpnConfigSection({
vpnConfig,
vpnConfigOpen,
setVpnConfigOpen,
updateVpnType,
updateTailscaleConfig,
updateNewtConfig,
updateCloudflaredConfig,
updateWireguardConfig,
updateZerotierConfig,
updateNetbirdConfig,
updateServicesUsingVpn,
updateVpnNetworks,
services,
networks,
}: VpnConfigSectionProps) {
return (
<>
<Collapsible open={vpnConfigOpen} onOpenChange={setVpnConfigOpen}>
<div className="flex items-center justify-between mb-2 w-full box-border">
<CollapsibleTrigger asChild>
<Button
variant="outline"
className="font-bold text-md w-full justify-between"
>
<div className="flex items-center gap-2">
<Shield className="h-4 w-4" />
<span>VPN Configuration</span>
</div>
{vpnConfigOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className="flex flex-col gap-3 w-full box-border">
<div>
<Label className="mb-1 block text-sm">VPN Type</Label>
<Select
value={vpnConfig?.type || "none"}
onValueChange={(value) => {
updateVpnType(value === "none" ? null : (value as VPNConfig["type"]));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select VPN type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="tailscale">Tailscale</SelectItem>
<SelectItem value="newt">Newt</SelectItem>
<SelectItem value="cloudflared">Cloudflared</SelectItem>
<SelectItem value="wireguard">Wireguard</SelectItem>
<SelectItem value="zerotier">ZeroTier</SelectItem>
<SelectItem value="netbird">Netbird</SelectItem>
</SelectContent>
</Select>
</div>
{vpnConfig &&
vpnConfig.enabled &&
vpnConfig.type === "tailscale" &&
vpnConfig.tailscale && (
<div className="flex flex-col gap-3">
<div>
<Label className="mb-1 block text-sm">Auth Key</Label>
<Input
value={vpnConfig.tailscale.authKey}
onChange={(e) =>
updateTailscaleConfig({ authKey: e.target.value })
}
placeholder="${TS_AUTHKEY}"
/>
<p className="text-xs text-muted-foreground mt-1">
Get from Tailscale admin console
</p>
</div>
<div>
<Label className="mb-1 block text-sm">Hostname</Label>
<Input
value={vpnConfig.tailscale.hostname}
onChange={(e) =>
updateTailscaleConfig({ hostname: e.target.value })
}
placeholder="my-service"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={vpnConfig.tailscale.acceptDns}
onCheckedChange={(checked) =>
updateTailscaleConfig({ acceptDns: checked === true })
}
/>
<Label
className="text-sm cursor-pointer"
onClick={() => {
if (!vpnConfig.tailscale) return;
updateTailscaleConfig({
acceptDns: !vpnConfig.tailscale.acceptDns,
});
}}
>
Accept DNS
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={vpnConfig.tailscale.authOnce}
onCheckedChange={(checked) =>
updateTailscaleConfig({ authOnce: checked === true })
}
/>
<Label
className="text-sm cursor-pointer"
onClick={() => {
if (!vpnConfig.tailscale) return;
updateTailscaleConfig({
authOnce: !vpnConfig.tailscale.authOnce,
});
}}
>
Auth Once
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={vpnConfig.tailscale.userspace}
onCheckedChange={(checked) =>
updateTailscaleConfig({ userspace: checked === true })
}
/>
<Label
className="text-sm cursor-pointer"
onClick={() => {
if (!vpnConfig.tailscale) return;
updateTailscaleConfig({
userspace: !vpnConfig.tailscale.userspace,
});
}}
>
Userspace
</Label>
</div>
<div>
<Label className="mb-1 block text-sm">
Exit Node (optional)
</Label>
<Input
value={vpnConfig.tailscale.exitNode}
onChange={(e) =>
updateTailscaleConfig({ exitNode: e.target.value })
}
placeholder="Exit node IP or hostname"
/>
</div>
{vpnConfig.tailscale.exitNode && (
<div className="flex items-center gap-2">
<Checkbox
checked={vpnConfig.tailscale.exitNodeAllowLan}
onCheckedChange={(checked) =>
updateTailscaleConfig({
exitNodeAllowLan: checked === true,
})
}
/>
<Label
className="text-sm cursor-pointer"
onClick={() => {
if (!vpnConfig.tailscale) return;
updateTailscaleConfig({
exitNodeAllowLan:
!vpnConfig.tailscale.exitNodeAllowLan,
});
}}
>
Allow LAN Access
</Label>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
checked={vpnConfig.tailscale.enableServe}
onCheckedChange={(checked) =>
updateTailscaleConfig({ enableServe: checked === true })
}
/>
<Label
className="text-sm cursor-pointer"
onClick={() => {
if (!vpnConfig.tailscale) return;
updateTailscaleConfig({
enableServe: !vpnConfig.tailscale.enableServe,
});
}}
>
Enable Serve (TCP/HTTPS)
</Label>
</div>
{vpnConfig.tailscale.enableServe && (
<div className="flex flex-col gap-3 pl-4 border-l-2">
<div>
<Label className="mb-1 block text-sm">
Target Service
</Label>
<Select
value={vpnConfig.tailscale.serveTargetService}
onValueChange={(value) =>
updateTailscaleConfig({
serveTargetService: value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select service..." />
</SelectTrigger>
<SelectContent>
{services
.filter((s) => s.name)
.map((s) => (
<SelectItem key={s.name} value={s.name}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1 block text-sm">
External Port
</Label>
<Input
value={vpnConfig.tailscale.serveExternalPort}
onChange={(e) =>
updateTailscaleConfig({
serveExternalPort: e.target.value,
})
}
placeholder="443"
/>
</div>
<div>
<Label className="mb-1 block text-sm">
Internal Port
</Label>
<Input
value={vpnConfig.tailscale.serveInternalPort}
onChange={(e) =>
updateTailscaleConfig({
serveInternalPort: e.target.value,
})
}
placeholder="8080"
/>
</div>
<div>
<Label className="mb-1 block text-sm">Path</Label>
<Input
value={vpnConfig.tailscale.servePath}
onChange={(e) =>
updateTailscaleConfig({
servePath: e.target.value,
})
}
placeholder="/"
/>
</div>
<div>
<Label className="mb-1 block text-sm">Protocol</Label>
<Select
value={vpnConfig.tailscale.serveProtocol}
onValueChange={(value) =>
updateTailscaleConfig({
serveProtocol: value as "HTTPS" | "HTTP",
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HTTPS">HTTPS</SelectItem>
<SelectItem value="HTTP">HTTP</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1 block text-sm">
Inside Protocol
</Label>
<Select
value={vpnConfig.tailscale.serveInsideProtocol || "http"}
onValueChange={(value) =>
updateTailscaleConfig({
serveInsideProtocol: value as "http" | "https" | "https+insecure",
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="https">HTTPS</SelectItem>
<SelectItem value="https+insecure">HTTPS (insecure)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
Protocol used to connect to your internal service
</p>
</div>
<div>
<Label className="mb-1 block text-sm">
Cert Domain (optional)
</Label>
<Input
value={vpnConfig.tailscale.certDomain}
onChange={(e) =>
updateTailscaleConfig({
certDomain: e.target.value,
})
}
placeholder="${TS_CERT_DOMAIN}"
/>
</div>
</div>
)}
</div>
)}
{vpnConfig &&
vpnConfig.enabled &&
vpnConfig.type === "newt" &&
vpnConfig.newt && (
<div className="flex flex-col gap-3">
<div>
<Label className="mb-1 block text-sm">Endpoint</Label>
<Input
value={vpnConfig.newt.endpoint}
onChange={(e) =>
updateNewtConfig({ endpoint: e.target.value })
}
placeholder="https://app.pangolin.net"
/>
</div>
<div>
<Label className="mb-1 block text-sm">Newt ID</Label>
<Input
value={vpnConfig.newt.newtId}
onChange={(e) =>
updateNewtConfig({ newtId: e.target.value })
}
placeholder="${NEWT_ID}"
/>
</div>
<div>
<Label className="mb-1 block text-sm">Newt Secret</Label>
<Input
value={vpnConfig.newt.newtSecret}
onChange={(e) =>
updateNewtConfig({ newtSecret: e.target.value })
}
placeholder="${NEWT_SECRET}"
type="password"
/>
</div>
<div>
<Label className="mb-1 block text-sm">Network Name</Label>
<Input
value={vpnConfig.newt.networkName}
onChange={(e) =>
updateNewtConfig({ networkName: e.target.value })
}
placeholder="newt"
/>
</div>
</div>
)}
{vpnConfig &&
vpnConfig.enabled &&
vpnConfig.type === "cloudflared" &&
vpnConfig.cloudflared && (
<div className="flex flex-col gap-3">
<div>
<Label className="mb-1 block text-sm">Tunnel Token</Label>
<Input
value={vpnConfig.cloudflared.tunnelToken}
onChange={(e) =>
updateCloudflaredConfig({
tunnelToken: e.target.value,
})
}
placeholder="${TUNNEL_TOKEN}"
type="password"
/>
<p className="text-xs text-muted-foreground mt-1">
Get from Cloudflare dashboard
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={vpnConfig.cloudflared.noAutoupdate}
onCheckedChange={(checked) =>
updateCloudflaredConfig({
noAutoupdate: checked === true,
})
}
/>
<Label
className="text-sm cursor-pointer"
onClick={() => {
if (!vpnConfig.cloudflared) return;
updateCloudflaredConfig({
noAutoupdate:
!vpnConfig.cloudflared.noAutoupdate,
});
}}
>
No Auto-update
</Label>
</div>
</div>
)}
{vpnConfig &&
vpnConfig.enabled &&
vpnConfig.type === "wireguard" &&
vpnConfig.wireguard && (
<div className="flex flex-col gap-3">
<div>
<Label className="mb-1 block text-sm">Config Path</Label>
<Input
value={vpnConfig.wireguard.configPath}
onChange={(e) =>
updateWireguardConfig({
configPath: e.target.value,
})
}
placeholder="/etc/wireguard/wg0.conf"
/>
</div>
<div>
<Label className="mb-1 block text-sm">Interface Name</Label>
<Input
value={vpnConfig.wireguard.interfaceName}
onChange={(e) =>
updateWireguardConfig({
interfaceName: e.target.value,
})
}
placeholder="wg0"
/>
</div>
</div>
)}
{vpnConfig &&
vpnConfig.enabled &&
vpnConfig.type === "zerotier" &&
vpnConfig.zerotier && (
<div className="flex flex-col gap-3">
<div>
<Label className="mb-1 block text-sm">Network ID</Label>
<Input
value={vpnConfig.zerotier.networkId}
onChange={(e) =>
updateZerotierConfig({
networkId: e.target.value,
})
}
placeholder="${ZT_NETWORK_ID}"
/>
</div>
<div>
<Label className="mb-1 block text-sm">Identity Path</Label>
<Input
value={vpnConfig.zerotier.identityPath}
onChange={(e) =>
updateZerotierConfig({
identityPath: e.target.value,
})
}
placeholder="/var/lib/zerotier-one"
/>
</div>
</div>
)}
{vpnConfig &&
vpnConfig.enabled &&
vpnConfig.type === "netbird" &&
vpnConfig.netbird && (
<div className="flex flex-col gap-3">
<div>
<Label className="mb-1 block text-sm">Setup Key</Label>
<Input
value={vpnConfig.netbird.setupKey}
onChange={(e) =>
updateNetbirdConfig({
setupKey: e.target.value,
})
}
placeholder="${NETBIRD_SETUP_KEY}"
type="password"
/>
</div>
<div>
<Label className="mb-1 block text-sm">
Management URL (optional)
</Label>
<Input
value={vpnConfig.netbird.managementUrl}
onChange={(e) =>
updateNetbirdConfig({
managementUrl: e.target.value,
})
}
placeholder="https://api.netbird.io"
/>
</div>
</div>
)}
{vpnConfig && vpnConfig.enabled && (
<>
{(() => {
let hasErrors = false;
let errorMessage = "";
if (!vpnConfig) return null;
if (
vpnConfig.type === "tailscale" &&
vpnConfig.tailscale
) {
if (!vpnConfig.tailscale.authKey) {
hasErrors = true;
errorMessage = "Tailscale Auth Key is required";
}
if (
vpnConfig.tailscale.enableServe &&
!vpnConfig.tailscale.serveTargetService
) {
hasErrors = true;
errorMessage =
"Target service is required when Serve is enabled";
}
} else if (
vpnConfig.type === "newt" &&
vpnConfig.newt
) {
if (
!vpnConfig.newt.newtId ||
!vpnConfig.newt.newtSecret
) {
hasErrors = true;
errorMessage = "Newt ID and Secret are required";
}
} else if (
vpnConfig.type === "cloudflared" &&
vpnConfig.cloudflared
) {
if (!vpnConfig.cloudflared.tunnelToken) {
hasErrors = true;
errorMessage =
"Cloudflared Tunnel Token is required";
}
} else if (
vpnConfig.type === "zerotier" &&
vpnConfig.zerotier
) {
if (!vpnConfig.zerotier.networkId) {
hasErrors = true;
errorMessage = "ZeroTier Network ID is required";
}
} else if (
vpnConfig.type === "netbird" &&
vpnConfig.netbird
) {
if (!vpnConfig.netbird.setupKey) {
hasErrors = true;
errorMessage = "Netbird Setup Key is required";
}
}
if (vpnConfig.servicesUsingVpn.length === 0) {
hasErrors = true;
errorMessage =
"At least one service must be selected to use VPN";
}
return hasErrors ? (
<Alert className="mb-2">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Configuration Warning</AlertTitle>
<AlertDescription className="text-xs">
{errorMessage}
</AlertDescription>
</Alert>
) : null;
})()}
<div className="flex flex-col gap-2">
<Label className="text-sm font-semibold">
Services Using VPN
</Label>
{services.filter((s) => s.name).length === 0 ? (
<p className="text-xs text-muted-foreground">
Add services first
</p>
) : (
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
{services
.filter((s) => s.name)
.map((svc) => (
<div
key={svc.name}
className="flex items-center gap-2"
>
<Checkbox
checked={vpnConfig.servicesUsingVpn.includes(
svc.name
)}
onCheckedChange={(checked) => {
const newServices = checked
? [
...vpnConfig.servicesUsingVpn,
svc.name,
]
: vpnConfig.servicesUsingVpn.filter(
(n) => n !== svc.name
);
updateServicesUsingVpn(newServices);
}}
/>
<Label
htmlFor={`vpn-service-${svc.name}`}
className="text-sm cursor-pointer flex-1"
onClick={() => {
const isChecked =
vpnConfig.servicesUsingVpn.includes(
svc.name
);
const newServices = !isChecked
? [
...vpnConfig.servicesUsingVpn,
svc.name,
]
: vpnConfig.servicesUsingVpn.filter(
(n) => n !== svc.name
);
updateServicesUsingVpn(newServices);
}}
>
{svc.name}
</Label>
{vpnConfig.type &&
["tailscale", "cloudflared"].includes(
vpnConfig.type
) &&
vpnConfig.servicesUsingVpn.includes(
svc.name
) && (
<span className="text-xs text-muted-foreground ml-auto">
(network_mode)
</span>
)}
</div>
))}
</div>
)}
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm font-semibold">
Networks
</Label>
{networks.filter((n) => n.name).length === 0 ? (
<p className="text-xs text-muted-foreground">
Add networks first
</p>
) : (
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
{networks
.filter((n) => n.name)
.map((net) => (
<div
key={net.name}
className="flex items-center gap-2"
>
<Checkbox
checked={vpnConfig.networks?.includes(net.name) || false}
onCheckedChange={(checked) => {
const currentNetworks = vpnConfig.networks || [];
const newNetworks = checked
? [...currentNetworks, net.name]
: currentNetworks.filter((n) => n !== net.name);
updateVpnNetworks(newNetworks);
}}
/>
<Label
htmlFor={`vpn-network-${net.name}`}
className="text-sm cursor-pointer flex-1"
onClick={() => {
const currentNetworks = vpnConfig.networks || [];
const isChecked = currentNetworks.includes(net.name);
const newNetworks = !isChecked
? [...currentNetworks, net.name]
: currentNetworks.filter((n) => n !== net.name);
updateVpnNetworks(newNetworks);
}}
>
{net.name}
</Label>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
</CollapsibleContent>
</Collapsible>
<Separator className="my-2" />
</>
);
}

View File

@@ -0,0 +1,97 @@
import { Settings } from "lucide-react";
import { Card, CardContent } from "../ui/card";
import { Button } from "../ui/button";
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/50 flex flex-col w-full h-full bg-card/50 hover:bg-card border-border/50"
onClick={onClick}
>
<CardContent className="p-5 flex flex-col gap-4 flex-1 min-h-0">
{/* Header with logo and name */}
<div className="flex items-start gap-4">
{logo ? (
<img
src={logo}
alt={name}
className="w-12 h-12 object-contain flex-shrink-0 rounded-lg bg-background/50 p-1"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
<div className="w-12 h-12 bg-muted/50 rounded-lg 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 space-y-1">
<div className="flex items-start justify-between gap-2">
<h3 className="font-bold text-lg leading-tight break-words tracking-tight">
{name}
</h3>
</div>
{version && (
<p className="text-xs text-muted-foreground font-mono">
{version}
</p>
)}
</div>
</div>
{/* Description */}
{description && (
<p className="text-sm text-muted-foreground line-clamp-3 flex-1 leading-relaxed">
{description}
</p>
)}
{/* Tags */}
<div className="mt-auto space-y-4">
{tags && tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-[10px] uppercase tracking-wider font-semibold bg-red-500/10 text-red-400 rounded border border-red-500/20"
>
{tag}
</span>
))}
{tags.length > 3 && (
<span className="px-2 py-0.5 text-[10px] font-semibold bg-muted text-muted-foreground rounded border border-border">
+{tags.length - 3}
</span>
)}
</div>
)}
<Button
variant="secondary"
className="w-full bg-secondary/50 hover:bg-secondary group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
size="sm"
>
View Details
</Button>
</div>
</CardContent>
</Card>
);
}

View File

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

View File

@@ -0,0 +1,317 @@
import { useState, useMemo, useEffect } 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 {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { RefreshCw, Package, AlertCircle, X, ChevronDown } from "lucide-react";
import { TemplateCard } from "./TemplateCard";
import { useSearchParams } from "../../hooks/use-search-params";
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 [searchParams, setSearchParams] = useSearchParams();
const [searchQuery, setSearchQuery] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [filteredTemplates, setFilteredTemplates] = useState<Template[]>(templates);
// Get all unique tags, sorted with selected ones first
const uniqueTags = useMemo(() => {
if (!templates || templates.length === 0) return [];
const allTags = Array.from(
new Set(templates.flatMap((template) => template.tags || []))
).sort();
if (selectedTags.length === 0) return allTags;
const selected = allTags.filter(tag => selectedTags.includes(tag));
const unselected = allTags.filter(tag => !selectedTags.includes(tag));
return [...selected, ...unselected];
}, [templates, selectedTags]);
// Initialize search query from URL params
useEffect(() => {
const queryFromUrl = searchParams.get("q") || "";
if (queryFromUrl !== searchQuery) {
setSearchQuery(queryFromUrl);
}
}, [searchParams]);
// Apply filters
useEffect(() => {
if (templates) {
const filtered = templates.filter((template) => {
const searchTerm = searchQuery.toLowerCase();
const matchesSearch =
template.name?.toLowerCase().includes(searchTerm) ||
template.description?.toLowerCase().includes(searchTerm);
const matchesTags =
selectedTags.length === 0 ||
selectedTags.every((tag) => template.tags?.includes(tag));
return matchesSearch && matchesTags;
});
setFilteredTemplates(filtered);
}
}, [templates, searchQuery, selectedTags]);
// Update URL params when search query changes
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setSearchQuery(newQuery);
if (newQuery) {
setSearchParams({ q: newQuery });
} else {
searchParams.delete("q");
setSearchParams(searchParams);
}
};
const toggleTag = (tag: string) => {
setSelectedTags((prev) =>
prev.includes(tag)
? prev.filter((t) => t !== tag)
: [...prev, tag]
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false} className="sm:max-w-7xl w-[95vw] max-h-[90vh] flex flex-col gap-0 p-0 bg-background/95 backdrop-blur-xl border-border/50">
<DialogHeader className="px-6 pt-6 pb-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<DialogTitle className="text-2xl font-bold tracking-tight">
Template Marketplace
</DialogTitle>
<DialogDescription className="text-base text-muted-foreground">
Browse and import templates with pre-configured Docker Compose files.
{cacheTimestamp && (
<span className="ml-2 text-xs text-muted-foreground/60">
(Cached {Math.round((Date.now() - cacheTimestamp) / 60000)}m ago)
</span>
)}
</DialogDescription>
<div className="text-sm text-muted-foreground">
Templates from{" "}
<a
href="https://github.com/hhftechnology/Marketplace"
target="_blank"
rel="noopener noreferrer"
className="text-red-400 hover:underline"
>
hhftechnology/Marketplace
</a>{" "}
repository.
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={onRefresh}
disabled={loading}
className="flex-shrink-0 gap-2"
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => onOpenChange(false)}
className="h-9 w-9"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
</div>
{/* Search and Filter Bar */}
<div className="flex flex-col gap-4 pt-2">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Available Templates
</span>
<span className="text-sm font-bold">
{filteredTemplates.length}
</span>
</div>
<div className="flex gap-2">
<Input
placeholder="Search templates..."
value={searchQuery}
onChange={handleSearchChange}
className="flex-1 h-10 bg-muted/50 border-border/50"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2 h-10">
Tags
{selectedTags.length > 0 && (
<span className="bg-primary/10 text-primary px-1.5 py-0.5 rounded text-[10px]">
{selectedTags.length}
</span>
)}
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px] max-h-[300px] overflow-y-auto">
<DropdownMenuLabel>Filter by Tags</DropdownMenuLabel>
<DropdownMenuSeparator />
{uniqueTags.map((tag) => (
<DropdownMenuCheckboxItem
key={tag}
checked={selectedTags.includes(tag)}
onCheckedChange={() => toggleTag(tag)}
>
{tag}
</DropdownMenuCheckboxItem>
))}
{uniqueTags.length === 0 && (
<div className="p-2 text-sm text-muted-foreground text-center">
No tags available
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</DialogHeader>
{/* Content Area */}
<div className="flex-1 overflow-y-auto px-6 pb-6 min-h-0">
{loading ? (
<TemplateGridSkeleton />
) : error ? (
<div className="flex items-center justify-center py-12 h-full">
<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 h-full">
<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 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4">
{filteredTemplates.map((template) => (
<TemplateCard
key={template.id}
{...template}
logo={
template.logo
? `https://raw.githubusercontent.com/hhftechnology/Marketplace/main/compose-files/${template.id}/${template.logo}`
: undefined
}
onClick={() => onTemplateSelect(template)}
/>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
function TemplateGridSkeleton() {
return (
<div className="grid gap-4 py-4 auto-rows-fr template-grid">
{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 flex-shrink-0" />
<div className="flex-1 min-w-0 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 flex-wrap">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-16" />
</div>
</div>
))}
</div>
);
}

View File

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

View File

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

View File

@@ -68,7 +68,7 @@ function SidebarProvider({
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// Read sidebar state from cookie on mount
// Read sidebar state from cookie on mount, but respect defaultOpen if no cookie exists
const [_open, _setOpen] = React.useState(() => {
if (typeof document === "undefined") return defaultOpen;
const cookies = document.cookie.split("; ");
@@ -261,24 +261,31 @@ function SidebarTrigger({
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
const { toggleSidebar, open } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-8", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon className="h-4 w-4" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{open ? "Close sidebar" : "Open sidebar"} (Ctrl/Cmd+B)</p>
</TooltipContent>
</Tooltip>
);
}
@@ -340,7 +347,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
className={cn("flex flex-col gap-2 h-12 px-2 py-2 justify-center", className)}
{...props}
/>
);

View File

@@ -0,0 +1,91 @@
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}
/>
)
}

108
src/components/ui/toast.tsx Normal file
View File

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

View File

@@ -0,0 +1,26 @@
import { useState, useEffect, useCallback } from 'react';
export function useSearchParams() {
const [searchParams, setSearchParamsState] = useState(
new URLSearchParams(window.location.search)
);
useEffect(() => {
const handlePopState = () => {
setSearchParamsState(new URLSearchParams(window.location.search));
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const setSearchParams = useCallback((newParams: Record<string, string> | URLSearchParams) => {
const nextParams = new URLSearchParams(
newParams instanceof URLSearchParams ? newParams : newParams
);
const newUrl = `${window.location.pathname}?${nextParams.toString()}`;
window.history.pushState({}, '', newUrl);
setSearchParamsState(nextParams);
}, []);
return [searchParams, setSearchParams] as const;
}

View File

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

View File

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

View File

@@ -0,0 +1,202 @@
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;
bulkAddNetworks: (networks: NetworkConfig[]) => 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;
bulkAddVolumes: (volumes: VolumeConfig[]) => 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]);
// Bulk add methods for template imports
const bulkAddNetworks = useCallback((newNetworks: NetworkConfig[]) => {
setNetworks((prev) => {
const existingNames = new Set(prev.map((n) => n.name));
const uniqueNetworks = newNetworks.filter((n) => !existingNames.has(n.name));
return [...prev, ...uniqueNetworks];
});
}, []);
const bulkAddVolumes = useCallback((newVolumes: VolumeConfig[]) => {
setVolumes((prev) => {
const existingNames = new Set(prev.map((v) => v.name));
const uniqueVolumes = newVolumes.filter((v) => !existingNames.has(v.name));
return [...prev, ...uniqueVolumes];
});
}, []);
return {
networks,
selectedNetworkIdx,
setSelectedNetworkIdx,
addNetwork,
bulkAddNetworks,
updateNetwork,
removeNetwork,
volumes,
selectedVolumeIdx,
setSelectedVolumeIdx,
addVolume,
bulkAddVolumes,
updateVolume,
removeVolume,
};
}

View File

@@ -0,0 +1,64 @@
import { useState, useCallback } from "react";
export interface UseSelectionStateReturn {
selectedIdx: number | null;
selectedType: "service" | "network" | "volume";
selectedNetworkIdx: number | null;
selectedVolumeIdx: number | null;
setSelectedIdx: (idx: number | null) => void;
setSelectedType: (type: "service" | "network" | "volume") => void;
setSelectedNetworkIdx: (idx: number | null) => void;
setSelectedVolumeIdx: (idx: number | null) => void;
selectService: (idx: number | null) => void;
selectNetwork: (idx: number | null) => void;
selectVolume: (idx: number | null) => void;
}
export function useSelectionState(): UseSelectionStateReturn {
const [selectedIdx, setSelectedIdx] = useState<number | null>(0);
const [selectedType, setSelectedType] = useState<
"service" | "network" | "volume"
>("service");
const [selectedNetworkIdx, setSelectedNetworkIdx] = useState<number | null>(
null
);
const [selectedVolumeIdx, setSelectedVolumeIdx] = useState<number | null>(
null
);
const selectService = useCallback((idx: number | null) => {
setSelectedIdx(idx);
setSelectedType("service");
setSelectedNetworkIdx(null);
setSelectedVolumeIdx(null);
}, []);
const selectNetwork = useCallback((idx: number | null) => {
setSelectedNetworkIdx(idx);
setSelectedType("network");
setSelectedIdx(null);
setSelectedVolumeIdx(null);
}, []);
const selectVolume = useCallback((idx: number | null) => {
setSelectedVolumeIdx(idx);
setSelectedType("volume");
setSelectedIdx(null);
setSelectedNetworkIdx(null);
}, []);
return {
selectedIdx,
selectedType,
selectedNetworkIdx,
selectedVolumeIdx,
setSelectedIdx,
setSelectedType,
setSelectedNetworkIdx,
setSelectedVolumeIdx,
selectService,
selectNetwork,
selectVolume,
};
}

View File

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

View File

@@ -0,0 +1,398 @@
import { useState, useCallback } from "react";
import type { ServiceConfig, Healthcheck } from "../types/compose";
import { defaultService } from "../utils/default-configs";
export function useServiceUpdater(
initialServices: ServiceConfig[] = [defaultService()]
) {
const [services, setServices] = useState<ServiceConfig[]>(initialServices);
// Helper to get new services array with validation
const getNewServices = useCallback(
(selectedIdx: number | null): [ServiceConfig[], number] | null => {
if (typeof selectedIdx !== "number") return null;
return [[...services], selectedIdx];
},
[services]
);
// Service field updates
const updateServiceField = useCallback(
(selectedIdx: number | null, field: keyof ServiceConfig, value: any) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, idx] = result;
(newServices[idx] as any)[field] = value;
setServices(newServices);
},
[getNewServices]
);
// List field updates (for environment, etc.)
const updateListField = useCallback(
(
selectedIdx: number | null,
field: keyof ServiceConfig,
idx: number,
value: any
) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, sIdx] = result;
(newServices[sIdx][field] as any[])[idx] = value;
setServices(newServices);
},
[getNewServices]
);
const addListField = useCallback(
(selectedIdx: number | null, field: keyof ServiceConfig) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, idx] = result;
if (field === "environment") {
newServices[idx].environment.push({ key: "", value: "" });
} else {
(newServices[idx][field] as any[]).push("");
}
setServices(newServices);
},
[getNewServices]
);
const removeListField = useCallback(
(selectedIdx: number | null, field: keyof ServiceConfig, idx: number) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, sIdx] = result;
(newServices[sIdx][field] as any[]).splice(idx, 1);
setServices(newServices);
},
[getNewServices]
);
// String array field updates
const updateStringArrayField = useCallback(
(
selectedIdx: number | null,
field: keyof ServiceConfig,
idx: number,
value: string
) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, sIdx] = result;
const service = newServices[sIdx];
const arrayField = (service as any)[field] as string[] | undefined;
if (!arrayField) {
(service as any)[field] = [];
}
((service as any)[field] as string[])[idx] = value;
setServices(newServices);
},
[getNewServices]
);
const addStringArrayField = useCallback(
(selectedIdx: number | null, field: keyof ServiceConfig) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, idx] = result;
const service = newServices[idx];
const arrayField = (service as any)[field] as string[] | undefined;
if (!arrayField) {
(service as any)[field] = [];
}
((service as any)[field] as string[]).push("");
setServices(newServices);
},
[getNewServices]
);
const removeStringArrayField = useCallback(
(selectedIdx: number | null, field: keyof ServiceConfig, idx: number) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, sIdx] = result;
const service = newServices[sIdx];
const arrayField = (service as any)[field] as string[] | undefined;
if (arrayField) {
arrayField.splice(idx, 1);
setServices(newServices);
}
},
[getNewServices]
);
// Service management
const addService = useCallback(() => {
const newServices = [...services, defaultService()];
setServices(newServices);
return services.length; // Return new index
}, [services]);
const removeService = useCallback((idx: number) => {
const newServices = services.filter((_, i) => i !== idx);
const finalServices =
newServices.length === 0 ? [defaultService()] : newServices;
setServices(finalServices);
return finalServices.length - 1; // Return safe index
}, [services]);
// Port field updates
const updatePortField = useCallback(
(
selectedIdx: number | null,
idx: number,
field: "host" | "container" | "protocol",
value: string
) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, sIdx] = result;
if (field === "protocol") {
newServices[sIdx].ports[idx][field] = value;
} else {
newServices[sIdx].ports[idx][field] = value.replace(/[^0-9]/g, "");
}
setServices(newServices);
},
[getNewServices]
);
const addPortField = useCallback(
(selectedIdx: number | null) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, idx] = result;
newServices[idx].ports.push({
host: "",
container: "",
protocol: "none",
});
setServices(newServices);
},
[getNewServices]
);
const removePortField = useCallback(
(selectedIdx: number | null, idx: number) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, sIdx] = result;
newServices[sIdx].ports.splice(idx, 1);
setServices(newServices);
},
[getNewServices]
);
// Volume field updates
const updateVolumeField = useCallback(
(
selectedIdx: number | null,
idx: number,
field: "host" | "container" | "read_only",
value: string | boolean
) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, sIdx] = result;
(newServices[sIdx].volumes[idx] as any)[field] = value;
setServices(newServices);
},
[getNewServices]
);
const addVolumeField = useCallback(
(selectedIdx: number | null) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, idx] = result;
newServices[idx].volumes.push({
host: "",
container: "",
read_only: false,
});
setServices(newServices);
},
[getNewServices]
);
const removeVolumeField = useCallback(
(selectedIdx: number | null, idx: number) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, sIdx] = result;
newServices[sIdx].volumes.splice(idx, 1);
setServices(newServices);
},
[getNewServices]
);
// Healthcheck updates
const updateHealthcheckField = useCallback(
(
selectedIdx: number | null,
field: keyof Healthcheck,
value: string
) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, idx] = result;
if (!newServices[idx].healthcheck)
newServices[idx].healthcheck = {
test: "",
interval: "",
timeout: "",
retries: "",
start_period: "",
start_interval: "",
};
newServices[idx].healthcheck![field] = value;
setServices(newServices);
},
[getNewServices]
);
// Sysctl updates
const updateSysctl = useCallback(
(
selectedIdx: number | null,
idx: number,
field: "key" | "value",
value: string
) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, sIdx] = result;
if (!newServices[sIdx].sysctls) newServices[sIdx].sysctls = [];
newServices[sIdx].sysctls![idx] = {
...newServices[sIdx].sysctls![idx],
[field]: value,
};
setServices(newServices);
},
[getNewServices]
);
const addSysctl = useCallback(
(selectedIdx: number | null) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, idx] = result;
if (!newServices[idx].sysctls) newServices[idx].sysctls = [];
newServices[idx].sysctls!.push({ key: "", value: "" });
setServices(newServices);
},
[getNewServices]
);
const removeSysctl = useCallback(
(selectedIdx: number | null, idx: number) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, sIdx] = result;
newServices[sIdx].sysctls!.splice(idx, 1);
setServices(newServices);
},
[getNewServices]
);
// Ulimit updates
const updateUlimit = useCallback(
(
selectedIdx: number | null,
idx: number,
field: "name" | "soft" | "hard",
value: string
) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, sIdx] = result;
if (!newServices[sIdx].ulimits) newServices[sIdx].ulimits = [];
newServices[sIdx].ulimits![idx] = {
...newServices[sIdx].ulimits![idx],
[field]: value,
};
setServices(newServices);
},
[getNewServices]
);
const addUlimit = useCallback(
(selectedIdx: number | null) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, idx] = result;
if (!newServices[idx].ulimits) newServices[idx].ulimits = [];
newServices[idx].ulimits!.push({ name: "", soft: "", hard: "" });
setServices(newServices);
},
[getNewServices]
);
const removeUlimit = useCallback(
(selectedIdx: number | null, idx: number) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, sIdx] = result;
newServices[sIdx].ulimits!.splice(idx, 1);
setServices(newServices);
},
[getNewServices]
);
// Resource field updates
const updateResourceField = useCallback(
(
selectedIdx: number | null,
type: "limits" | "reservations",
field: "cpus" | "memory",
value: string
) => {
const result = getNewServices(selectedIdx);
if (!result) return;
const [newServices, idx] = result;
if (!newServices[idx].deploy) {
newServices[idx].deploy = { resources: {} };
}
if (!newServices[idx].deploy!.resources) {
newServices[idx].deploy!.resources = {};
}
if (!newServices[idx].deploy!.resources![type]) {
newServices[idx].deploy!.resources![type] = {};
}
newServices[idx].deploy!.resources![type]![field] = value;
setServices(newServices);
},
[getNewServices]
);
return {
services,
setServices,
updateServiceField,
updateListField,
addListField,
removeListField,
updateStringArrayField,
addStringArrayField,
removeStringArrayField,
addService,
removeService,
updatePortField,
addPortField,
removePortField,
updateVolumeField,
addVolumeField,
removeVolumeField,
updateHealthcheckField,
updateSysctl,
addSysctl,
removeSysctl,
updateUlimit,
addUlimit,
removeUlimit,
updateResourceField,
};
}

View File

@@ -0,0 +1,202 @@
import { useState, useEffect, useCallback } from "react";
export interface UseTemplateStoreReturn {
templateStoreOpen: boolean;
setTemplateStoreOpen: (open: boolean) => void;
templates: any[];
setTemplates: React.Dispatch<React.SetStateAction<any[]>>;
templateLoading: boolean;
setTemplateLoading: (loading: boolean) => void;
templateError: string | null;
setTemplateError: (error: string | null) => void;
templateSearch: string;
setTemplateSearch: (search: string) => void;
selectedTemplate: any;
setSelectedTemplate: (template: any) => void;
templateDetailOpen: boolean;
setTemplateDetailOpen: (open: boolean) => void;
templateDetailTab: "overview" | "compose";
setTemplateDetailTab: (tab: "overview" | "compose") => void;
templateCache: any[];
templateCacheTimestamp: number | null;
fetchTemplatesFromGitHub: (backgroundUpdate?: boolean) => Promise<void>;
fetchTemplateDetails: (templateId: string) => Promise<any>;
refreshTemplateStore: () => void;
}
const GITHUB_OWNER = "hhftechnology";
const GITHUB_REPO = "Marketplace";
const GITHUB_BRANCH = "main";
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
export function useTemplateStore(): UseTemplateStoreReturn {
const [templateStoreOpen, setTemplateStoreOpen] = useState(false);
const [templates, setTemplates] = useState<any[]>([]);
const [templateLoading, setTemplateLoading] = useState(false);
const [templateError, setTemplateError] = useState<string | null>(null);
const [templateSearch, setTemplateSearch] = useState("");
const [selectedTemplate, setSelectedTemplate] = useState<any>(null);
const [templateDetailOpen, setTemplateDetailOpen] = useState(false);
const [templateDetailTab, setTemplateDetailTab] = useState<
"overview" | "compose"
>("overview");
const [templateCache, setTemplateCache] = useState<any[]>(() => {
const cached = localStorage.getItem("templateStoreCache");
return cached ? JSON.parse(cached) : [];
});
const [templateCacheTimestamp, setTemplateCacheTimestamp] = useState<
number | null
>(() => {
const cached = localStorage.getItem("templateStoreCacheTimestamp");
return cached ? parseInt(cached) : null;
});
const fetchTemplatesFromGitHub = useCallback(
async (backgroundUpdate: boolean = false) => {
if (!backgroundUpdate) {
setTemplateLoading(true);
setTemplateError(null);
}
try {
// Fetch meta.json
const metaUrl = `${GITHUB_RAW_BASE}/${GITHUB_OWNER}/${GITHUB_REPO}/${GITHUB_BRANCH}/meta.json`;
const metaResponse = await fetch(metaUrl);
if (!metaResponse.ok) {
throw new Error(
`Failed to fetch templates: ${metaResponse.statusText}`
);
}
const templatesMeta: any[] = await metaResponse.json();
// Store templates with metadata
setTemplates(templatesMeta);
setTemplateCache(templatesMeta);
setTemplateCacheTimestamp(Date.now());
localStorage.setItem("templateStoreCache", JSON.stringify(templatesMeta));
localStorage.setItem(
"templateStoreCacheTimestamp",
String(Date.now())
);
if (!backgroundUpdate) {
setTemplateLoading(false);
}
} catch (error: any) {
console.error("Error fetching templates:", error);
if (!backgroundUpdate) {
setTemplateLoading(false);
setTemplateError(error.message || "Failed to load templates");
}
}
},
[]
);
const fetchTemplateDetails = useCallback(
async (templateId: string): Promise<any> => {
const template = templates.find((t) => t.id === templateId);
if (!template) {
throw new Error(`Template ${templateId} not found`);
}
try {
const basePath = `compose-files/${templateId}`;
// Fetch docker-compose.yml
const composeUrl = `${GITHUB_RAW_BASE}/${GITHUB_OWNER}/${GITHUB_REPO}/${GITHUB_BRANCH}/${basePath}/docker-compose.yml`;
const composeResponse = await fetch(composeUrl);
if (!composeResponse.ok) {
throw new Error(
`Failed to fetch docker-compose.yml: ${composeResponse.statusText}`
);
}
const composeContent = await composeResponse.text();
// Build logo URL if logo exists
let logoUrl = null;
if (template.logo) {
logoUrl = `${GITHUB_RAW_BASE}/${GITHUB_OWNER}/${GITHUB_REPO}/${GITHUB_BRANCH}/${basePath}/${template.logo}`;
}
return {
...template,
composeContent,
logoUrl,
};
} catch (error: any) {
console.error(
`Error fetching template details for ${templateId}:`,
error
);
throw error;
}
},
[templates]
);
const refreshTemplateStore = useCallback(() => {
setTemplateCache([]);
setTemplateCacheTimestamp(null);
localStorage.removeItem("templateStoreCache");
localStorage.removeItem("templateStoreCacheTimestamp");
fetchTemplatesFromGitHub(false);
}, [fetchTemplatesFromGitHub]);
// Initialize templates when store opens
useEffect(() => {
if (!templateStoreOpen) return;
const CACHE_DURATION = 60 * 60 * 1000; // 1 hour
const now = Date.now();
// Check if we have valid cached data
if (
templateCache.length > 0 &&
templateCacheTimestamp &&
now - templateCacheTimestamp < CACHE_DURATION
) {
setTemplates(templateCache);
setTemplateLoading(false);
setTemplateError(null);
// Still check for updates in the background
fetchTemplatesFromGitHub(true);
return;
}
fetchTemplatesFromGitHub(false);
}, [
templateStoreOpen,
templateCache,
templateCacheTimestamp,
fetchTemplatesFromGitHub,
]);
return {
templateStoreOpen,
setTemplateStoreOpen,
templates,
setTemplates,
templateLoading,
setTemplateLoading,
templateError,
setTemplateError,
templateSearch,
setTemplateSearch,
selectedTemplate,
setSelectedTemplate,
templateDetailOpen,
setTemplateDetailOpen,
templateDetailTab,
setTemplateDetailTab,
templateCache,
templateCacheTimestamp,
fetchTemplatesFromGitHub,
fetchTemplateDetails,
refreshTemplateStore,
};
}

202
src/hooks/useVpnConfig.ts Normal file
View File

@@ -0,0 +1,202 @@
import { useState, useCallback } from "react";
import type { VPNConfig } from "../types/vpn-configs";
import {
defaultVPNConfig,
defaultTailscaleConfig,
defaultNewtConfig,
defaultCloudflaredConfig,
defaultWireguardConfig,
defaultZerotierConfig,
defaultNetbirdConfig,
} from "../utils/default-configs";
export interface UseVpnConfigReturn {
vpnConfig: VPNConfig;
setVpnConfig: React.Dispatch<React.SetStateAction<VPNConfig>>;
vpnConfigOpen: boolean;
setVpnConfigOpen: (open: boolean) => void;
updateVpnType: (type: VPNConfig["type"]) => void;
updateTailscaleConfig: (updates: Partial<VPNConfig["tailscale"]>) => void;
updateNewtConfig: (updates: Partial<VPNConfig["newt"]>) => void;
updateCloudflaredConfig: (updates: Partial<VPNConfig["cloudflared"]>) => void;
updateWireguardConfig: (updates: Partial<VPNConfig["wireguard"]>) => void;
updateZerotierConfig: (updates: Partial<VPNConfig["zerotier"]>) => void;
updateNetbirdConfig: (updates: Partial<VPNConfig["netbird"]>) => void;
updateServicesUsingVpn: (services: string[]) => void;
updateVpnNetworks: (networks: string[]) => void;
}
export function useVpnConfig(
initialConfig?: VPNConfig
): UseVpnConfigReturn {
const [vpnConfig, setVpnConfig] = useState<VPNConfig>(
initialConfig || defaultVPNConfig()
);
const [vpnConfigOpen, setVpnConfigOpen] = useState(false);
const updateVpnType = useCallback((type: VPNConfig["type"] | "none") => {
setVpnConfig((prev) => {
const currentConfig = prev || defaultVPNConfig();
const newType = type === "none" ? null : (type as VPNConfig["type"]);
return {
...currentConfig,
enabled: newType !== null,
type: newType,
tailscale:
newType === "tailscale"
? currentConfig.tailscale || defaultTailscaleConfig()
: undefined,
newt:
newType === "newt"
? currentConfig.newt || defaultNewtConfig()
: undefined,
cloudflared:
newType === "cloudflared"
? currentConfig.cloudflared || defaultCloudflaredConfig()
: undefined,
wireguard:
newType === "wireguard"
? currentConfig.wireguard || defaultWireguardConfig()
: undefined,
zerotier:
newType === "zerotier"
? currentConfig.zerotier || defaultZerotierConfig()
: undefined,
netbird:
newType === "netbird"
? currentConfig.netbird || defaultNetbirdConfig()
: undefined,
};
});
}, []);
const updateTailscaleConfig = useCallback(
(updates: Partial<VPNConfig["tailscale"]>) => {
if (!updates) return;
setVpnConfig((prev) => {
const currentTailscale = prev.tailscale!;
const newUpdates = { ...updates };
// Auto-adjust port when protocol changes (unless user manually changed it)
if (currentTailscale && updates.serveProtocol && updates.serveProtocol !== currentTailscale.serveProtocol) {
const currentPort = currentTailscale.serveExternalPort;
const currentProtocol = currentTailscale.serveProtocol;
// Only auto-adjust if port is at default for current protocol
if (
(currentProtocol === "HTTPS" && currentPort === "443") ||
(currentProtocol === "HTTP" && currentPort === "80") ||
!currentPort // No port set yet
) {
newUpdates.serveExternalPort = updates.serveProtocol === "HTTPS" ? "443" : "80";
}
}
return {
...prev,
tailscale: {
...currentTailscale,
...newUpdates,
},
};
});
},
[]
);
const updateNewtConfig = useCallback(
(updates: Partial<VPNConfig["newt"]>) => {
setVpnConfig((prev) => ({
...prev,
newt: {
...prev.newt!,
...updates,
},
}));
},
[]
);
const updateCloudflaredConfig = useCallback(
(updates: Partial<VPNConfig["cloudflared"]>) => {
setVpnConfig((prev) => ({
...prev,
cloudflared: {
...prev.cloudflared!,
...updates,
},
}));
},
[]
);
const updateWireguardConfig = useCallback(
(updates: Partial<VPNConfig["wireguard"]>) => {
setVpnConfig((prev) => ({
...prev,
wireguard: {
...prev.wireguard!,
...updates,
},
}));
},
[]
);
const updateZerotierConfig = useCallback(
(updates: Partial<VPNConfig["zerotier"]>) => {
setVpnConfig((prev) => ({
...prev,
zerotier: {
...prev.zerotier!,
...updates,
},
}));
},
[]
);
const updateNetbirdConfig = useCallback(
(updates: Partial<VPNConfig["netbird"]>) => {
setVpnConfig((prev) => ({
...prev,
netbird: {
...prev.netbird!,
...updates,
},
}));
},
[]
);
const updateServicesUsingVpn = useCallback((services: string[]) => {
setVpnConfig((prev) => ({
...prev,
servicesUsingVpn: services,
}));
}, []);
const updateVpnNetworks = useCallback((networks: string[]) => {
setVpnConfig((prev) => ({
...prev,
networks,
}));
}, []);
return {
vpnConfig,
setVpnConfig,
vpnConfigOpen,
setVpnConfigOpen,
updateVpnType,
updateTailscaleConfig,
updateNewtConfig,
updateCloudflaredConfig,
updateWireguardConfig,
updateZerotierConfig,
updateNetbirdConfig,
updateServicesUsingVpn,
updateVpnNetworks,
};
}

View File

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

View File

@@ -3,6 +3,8 @@ 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,
@@ -14,14 +16,18 @@ function RootComponent() {
return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<MetaTags />
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">
<Outlet/>
</main>
{isIndexPage && <Footer />}
</div>
<ToastProvider>
<TooltipProvider>
<MetaTags />
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">
<Outlet/>
</main>
{isIndexPage && <Footer />}
</div>
</TooltipProvider>
</ToastProvider>
</ThemeProvider>
);
}

View File

@@ -124,8 +124,8 @@ function App() {
}, [updateOutput]);
return (
<SidebarProvider>
<Sidebar>
<SidebarProvider defaultOpen={true}>
<Sidebar collapsible="icon">
<SidebarUI />
</Sidebar>
<SidebarInset>

File diff suppressed because it is too large Load Diff

View File

@@ -193,8 +193,8 @@ ${serviceUnit}
}, [scheduleType, config.name]);
return (
<SidebarProvider>
<Sidebar>
<SidebarProvider defaultOpen={true}>
<Sidebar collapsible="icon">
<SidebarUI />
</Sidebar>
<SidebarInset>

130
src/types/compose.ts Normal file
View File

@@ -0,0 +1,130 @@
// 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;
}

78
src/types/vpn-configs.ts Normal file
View File

@@ -0,0 +1,78 @@
// 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";
serveInsideProtocol: "http" | "https" | "https+insecure";
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
networks?: string[]; // Networks the VPN service should attach to
}

31
src/types/vpn.ts Normal file
View File

@@ -0,0 +1,31 @@
// 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[];
}

45
src/utils/clipboard.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* 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);
});
}

240
src/utils/converters.ts Normal file
View File

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

View File

@@ -0,0 +1,178 @@
// 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",
serveInsideProtocol: "http",
// 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: "",
};
}

149
src/utils/env-generator.ts Normal file
View File

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

View File

@@ -0,0 +1,388 @@
import jsyaml from "js-yaml";
import type { ServiceConfig, NetworkConfig, VolumeConfig } from "../types/compose";
import { defaultService } from "./default-configs";
export interface ParsedComposeData {
service: ServiceConfig;
networks: NetworkConfig[];
volumes: VolumeConfig[];
}
export interface ComposeServiceInput {
name: string;
image: string;
rawService: any;
}
/**
* Parses a Docker Compose service into our ServiceConfig format
*/
export function parseComposeService(
svc: ComposeServiceInput,
allNetworks?: Record<string, any>,
allVolumes?: Record<string, any>
): ParsedComposeData {
const serviceData = svc.rawService || {};
const actualServiceData = serviceData.rawService || serviceData;
const parseCommandArray = (cmd: any): string => {
if (Array.isArray(cmd)) {
return JSON.stringify(cmd);
}
return cmd || "";
};
const newService: ServiceConfig = {
...defaultService(),
name: svc.name,
image: svc.image,
container_name: actualServiceData.container_name || "",
command: parseCommandArray(actualServiceData.command),
restart: actualServiceData.restart || "",
ports: Array.isArray(actualServiceData.ports)
? actualServiceData.ports.map((p: string) => {
// Handle format: "host:container/protocol" or "container/protocol" or just "container"
if (p.includes(":")) {
const parts = p.split(":");
const host = parts[0];
const containerWithProtocol = parts[1] || "";
const [container, protocol] = containerWithProtocol.split("/");
return {
host,
container,
protocol: protocol || "none",
};
} else {
// No colon means it's just a container port, possibly with protocol
const [container, protocol] = p.split("/");
return {
host: "",
container,
protocol: protocol || "none",
};
}
})
: [],
expose: Array.isArray(actualServiceData.expose)
? actualServiceData.expose
: actualServiceData.expose
? [String(actualServiceData.expose)]
: [],
volumes: Array.isArray(actualServiceData.volumes)
? actualServiceData.volumes.map((v: any) => {
if (typeof v === "string") {
const parts = v.split(":");
const host = parts[0];
const container = parts[1] || "";
const read_only = parts[2] === "ro";
return { host, container, read_only };
} else if (typeof v === "object" && v !== null) {
return {
host: v.source || "",
container: v.target || "",
read_only: v.read_only || false,
};
}
return { host: "", container: "", read_only: false };
})
: [],
volumes_syntax:
Array.isArray(actualServiceData.volumes) &&
actualServiceData.volumes.length > 0 &&
typeof actualServiceData.volumes[0] === "object"
? "dict"
: "array",
environment: Array.isArray(actualServiceData.environment)
? actualServiceData.environment.map((e: string) => {
const [key, ...rest] = e.split("=");
return { key, value: rest.join("=") };
})
: actualServiceData.environment &&
typeof actualServiceData.environment === "object"
? Object.entries(actualServiceData.environment).map(
([key, value]: [string, any]) => ({ key, value: String(value) })
)
: [],
environment_syntax: Array.isArray(actualServiceData.environment)
? "array"
: "dict",
healthcheck: actualServiceData.healthcheck
? {
test: parseCommandArray(actualServiceData.healthcheck.test),
interval: actualServiceData.healthcheck.interval || "",
timeout: actualServiceData.healthcheck.timeout || "",
retries: actualServiceData.healthcheck.retries
? String(actualServiceData.healthcheck.retries)
: "",
start_period: actualServiceData.healthcheck.start_period || "",
start_interval: actualServiceData.healthcheck.start_interval || "",
}
: undefined,
depends_on: Array.isArray(actualServiceData.depends_on)
? actualServiceData.depends_on
: actualServiceData.depends_on
? Object.keys(actualServiceData.depends_on)
: [],
entrypoint: parseCommandArray(actualServiceData.entrypoint),
env_file: Array.isArray(actualServiceData.env_file)
? actualServiceData.env_file.join(",")
: actualServiceData.env_file || "",
extra_hosts: Array.isArray(actualServiceData.extra_hosts)
? actualServiceData.extra_hosts
: [],
dns: Array.isArray(actualServiceData.dns) ? actualServiceData.dns : [],
networks: Array.isArray(actualServiceData.networks)
? actualServiceData.networks
: actualServiceData.networks
? Object.keys(actualServiceData.networks)
: [],
user: actualServiceData.user || "",
working_dir: actualServiceData.working_dir || "",
labels: actualServiceData.labels
? Array.isArray(actualServiceData.labels)
? actualServiceData.labels.map((l: string) => {
const [key, ...rest] = l.split("=");
return { key, value: rest.join("=") };
})
: Object.entries(actualServiceData.labels).map(
([key, value]: [string, any]) => ({ key, value: String(value) })
)
: [],
privileged:
actualServiceData.privileged !== undefined
? !!actualServiceData.privileged
: undefined,
read_only:
actualServiceData.read_only !== undefined
? !!actualServiceData.read_only
: undefined,
shm_size: actualServiceData.shm_size || "",
security_opt: Array.isArray(actualServiceData.security_opt)
? actualServiceData.security_opt
: [],
network_mode: actualServiceData.network_mode || "",
cap_add: Array.isArray(actualServiceData.cap_add)
? actualServiceData.cap_add
: [],
cap_drop: Array.isArray(actualServiceData.cap_drop)
? actualServiceData.cap_drop
: [],
sysctls:
actualServiceData.sysctls && typeof actualServiceData.sysctls === "object"
? Array.isArray(actualServiceData.sysctls)
? actualServiceData.sysctls.map((s: string) => {
const [key, value] = s.split("=");
return { key: key || "", value: value || "" };
})
: Object.entries(actualServiceData.sysctls).map(
([key, value]: [string, any]) => ({
key,
value: String(value),
})
)
: [],
devices: Array.isArray(actualServiceData.devices)
? actualServiceData.devices
: [],
tmpfs: Array.isArray(actualServiceData.tmpfs)
? actualServiceData.tmpfs
: actualServiceData.tmpfs
? Object.keys(actualServiceData.tmpfs).map(
(key) => `${key}:${actualServiceData.tmpfs[key] || ""}`
)
: [],
ulimits:
actualServiceData.ulimits &&
typeof actualServiceData.ulimits === "object"
? Object.entries(actualServiceData.ulimits).map(
([name, limit]: [string, any]) => ({
name,
soft:
limit && typeof limit === "object" && limit.soft
? String(limit.soft)
: "",
hard:
limit && typeof limit === "object" && limit.hard
? String(limit.hard)
: "",
})
)
: [],
init:
actualServiceData.init !== undefined ? !!actualServiceData.init : undefined,
stop_grace_period: actualServiceData.stop_grace_period || "",
stop_signal: actualServiceData.stop_signal || "",
tty:
actualServiceData.tty !== undefined ? !!actualServiceData.tty : undefined,
stdin_open:
actualServiceData.stdin_open !== undefined
? !!actualServiceData.stdin_open
: undefined,
hostname: actualServiceData.hostname || "",
domainname: actualServiceData.domainname || "",
mac_address: actualServiceData.mac_address || "",
ipc_mode: actualServiceData.ipc || "",
pid: actualServiceData.pid || "",
uts: actualServiceData.uts || "",
cgroup_parent: actualServiceData.cgroup_parent || "",
isolation: actualServiceData.isolation || "",
deploy: actualServiceData.deploy?.resources
? {
resources: {
limits: {
cpus:
actualServiceData.deploy.resources.limits?.cpus || undefined,
memory:
actualServiceData.deploy.resources.limits?.memory || undefined,
},
reservations: {
cpus:
actualServiceData.deploy.resources.reservations?.cpus ||
undefined,
memory:
actualServiceData.deploy.resources.reservations?.memory ||
undefined,
},
},
}
: undefined,
};
// Parse networks
const networkConfigs: NetworkConfig[] =
allNetworks && Object.keys(allNetworks).length > 0
? Object.entries(allNetworks).map(([name, config]: [string, any]) => ({
name,
driver: config.driver || "",
driver_opts: config.driver_opts
? Object.entries(config.driver_opts).map(
([key, value]: [string, any]) => ({ key, value: String(value) })
)
: [],
attachable:
config.attachable !== undefined ? !!config.attachable : false,
labels: config.labels
? Array.isArray(config.labels)
? config.labels.map((l: string) => {
const [key, ...rest] = l.split("=");
return { key, value: rest.join("=") };
})
: Object.entries(config.labels).map(
([key, value]: [string, any]) => ({
key,
value: String(value),
})
)
: [],
external: !!config.external,
name_external:
config.external && typeof config.external === "object"
? config.external.name || ""
: "",
internal: config.internal !== undefined ? !!config.internal : false,
enable_ipv6:
config.enable_ipv6 !== undefined ? !!config.enable_ipv6 : false,
ipam: {
driver: config.ipam?.driver || "",
config: config.ipam?.config || [],
options: config.ipam?.options
? Object.entries(config.ipam.options).map(
([key, value]: [string, any]) => ({
key,
value: String(value),
})
)
: [],
},
}))
: [];
// Parse volumes
const volumeConfigs: VolumeConfig[] =
allVolumes && Object.keys(allVolumes).length > 0
? Object.entries(allVolumes).map(([name, config]: [string, any]) => {
let driverOptsType = "";
let driverOptsDevice = "";
let driverOptsO = "";
if (config && config.driver_opts) {
driverOptsType = config.driver_opts.type || "";
driverOptsDevice = config.driver_opts.device || "";
driverOptsO = config.driver_opts.o || "";
}
return {
name,
driver: config && config.driver ? config.driver : "",
driver_opts:
config && config.driver_opts
? Object.entries(config.driver_opts).map(
([key, value]: [string, any]) => ({
key,
value: String(value),
})
)
: [],
labels:
config && config.labels
? Array.isArray(config.labels)
? config.labels.map((l: string) => {
const [key, ...rest] = l.split("=");
return { key, value: rest.join("=") };
})
: Object.entries(config.labels).map(
([key, value]: [string, any]) => ({
key,
value: String(value),
})
)
: [],
external: !!config?.external,
name_external:
config?.external && typeof config.external === "object"
? config.external.name || ""
: "",
driver_opts_type: driverOptsType,
driver_opts_device: driverOptsDevice,
driver_opts_o: driverOptsO,
};
})
: [];
return {
service: newService,
networks: networkConfigs,
volumes: volumeConfigs,
};
}
/**
* Parses a Docker Compose YAML template and returns all services, networks, and volumes
*/
export function parseComposeTemplate(composeContent: string): {
services: ComposeServiceInput[];
networks: Record<string, any>;
volumes: Record<string, any>;
} {
const doc = jsyaml.load(composeContent) as any;
if (!doc || !doc.services) {
throw new Error("Invalid docker-compose.yml in template");
}
// Extract all services from compose file
const servicesArray: ComposeServiceInput[] = Object.entries(
doc.services
).map(([svcName, svcObj]: [string, any]) => ({
name: svcName,
image: svcObj.image || "",
rawService: svcObj,
}));
return {
services: servicesArray,
networks: doc.networks || {},
volumes: doc.volumes || {},
};
}

148
src/utils/validation.ts Normal file
View File

@@ -0,0 +1,148 @@
// 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;
}

264
src/utils/vpn-generator.ts Normal file
View File

@@ -0,0 +1,264 @@
// 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,
insideProtocol: "http" | "https" | "https+insecure" = "http"
): string {
const config: any = {
TCP: {
[externalPort]: {
HTTPS: protocol === "HTTPS",
HTTP: protocol === "HTTP",
},
},
};
// Build proxy URL based on inside protocol
const proxyProtocol = insideProtocol === "https+insecure" ? "https" : insideProtocol;
const proxyUrl = `${proxyProtocol}://127.0.0.1:${internalPort}`;
if (protocol === "HTTPS") {
const certDomainKey = certDomain
? certDomain
: "${TS_CERT_DOMAIN}";
config.Web = {
[`${certDomainKey}:${externalPort}`]: {
Handlers: {
[path]: {
Proxy: proxyUrl,
},
},
},
};
} else {
// HTTP mode: use Web section with localhost
config.Web = {
[`localhost:${externalPort}`]: {
Handlers: {
[path]: {
Proxy: proxyUrl,
},
},
},
};
}
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",
};
// Add selected networks
if (vpnConfig.networks && vpnConfig.networks.length > 0) {
service.networks = vpnConfig.networks;
}
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 = [
...(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"]; // ZeroTier needs host networking
// If host networking is used, we can't attach to other networks
if (vpnConfig.networks && vpnConfig.networks.length > 0) {
// Warning: ZeroTier uses host networking, ignoring selected networks
delete service.networks;
service.network_mode = "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;
}

230
src/utils/yaml-comments.ts Normal file
View File

@@ -0,0 +1,230 @@
// 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");
}

616
src/utils/yaml-generator.ts Normal file
View File

@@ -0,0 +1,616 @@
// 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 || 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,
ts.serveInsideProtocol || "http"
);
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;
}