Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b95bec646 | ||
|
|
65fdced852 | ||
|
|
6827fa5f38 | ||
|
|
7b260e4460 | ||
|
|
d57b51871c | ||
|
|
0b0b1e708b | ||
|
|
7364582ba9 | ||
|
|
a97a91b9bf | ||
|
|
1a7964c297 | ||
|
|
795d9d1d05 | ||
|
|
8438763a03 | ||
|
|
826e6d1a2d | ||
|
|
ae62b1c8d8 | ||
|
|
ccc423fd98 | ||
|
|
7549943336 |
2
.github/workflows/docker-build-push.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}-
|
||||
type=sha,prefix=sha-
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image to Docker Hub
|
||||
|
||||
21
README.md
@@ -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">
|
||||
|
||||
[](https://hub.docker.com/r/hhftechnology/dock-dploy)
|
||||

|
||||
[](https://discord.gg/HDCt9MjyMJ)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](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
48
package.json
@@ -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
BIN
public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 718 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
135
src/components/ConversionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
185
src/components/compose-builder/NetworkForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
1395
src/components/compose-builder/ServiceForm.tsx
Normal file
152
src/components/compose-builder/ServiceListSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
193
src/components/compose-builder/VolumeForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
771
src/components/compose-builder/VpnConfigSection.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
97
src/components/templates/TemplateCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
309
src/components/templates/TemplateDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
317
src/components/templates/TemplateStoreModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/components/ui/empty-state.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
src/components/ui/progress.tsx
Normal 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 }
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
91
src/components/ui/tabs.tsx
Normal 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
@@ -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>
|
||||
)
|
||||
}
|
||||
26
src/hooks/use-search-params.ts
Normal 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;
|
||||
}
|
||||
86
src/hooks/useConversionDialog.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
47
src/hooks/useEditorSize.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
202
src/hooks/useNetworkVolumeManager.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
64
src/hooks/useSelectionState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
563
src/hooks/useServiceManager.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
398
src/hooks/useServiceUpdater.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
202
src/hooks/useTemplateStore.ts
Normal 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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
69
src/hooks/useYamlValidation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -124,8 +124,8 @@ function App() {
|
||||
}, [updateOutput]);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarProvider defaultOpen={true}>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarUI />
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
178
src/utils/default-configs.ts
Normal 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
@@ -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());
|
||||
}
|
||||
|
||||
388
src/utils/template-import.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||