bug-fixes-optimizations

This commit is contained in:
hhftechnologies
2025-12-01 18:32:52 +05:30
parent 7364582ba9
commit 0b0b1e708b
26 changed files with 9556 additions and 3867 deletions

7
package-lock.json generated
View File

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

4823
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { Settings } from "lucide-react";
import { Card, CardContent } from "../ui/card";
import { Button } from "../ui/button";
export interface TemplateCardProps {
id: string;
@@ -21,33 +22,35 @@ export function TemplateCard({
}: TemplateCardProps) {
return (
<Card
className="group cursor-pointer transition-all duration-200 hover:shadow-lg hover:border-primary"
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-4 flex flex-col gap-3 h-full">
<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-3">
<div className="flex items-start gap-4">
{logo ? (
<img
src={logo}
alt={name}
className="w-12 h-12 object-contain flex-shrink-0 rounded"
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 rounded flex items-center justify-center flex-shrink-0">
<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">
<h3 className="font-semibold text-base leading-tight break-words">
{name}
</h3>
<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 mt-0.5">
v{version}
<p className="text-xs text-muted-foreground font-mono">
{version}
</p>
)}
</div>
@@ -55,29 +58,39 @@ export function TemplateCard({
{/* Description */}
{description && (
<p className="text-sm text-muted-foreground line-clamp-2 flex-1">
<p className="text-sm text-muted-foreground line-clamp-3 flex-1 leading-relaxed">
{description}
</p>
)}
{/* Tags */}
{tags && tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-xs bg-primary/10 text-primary rounded-md border border-primary/20"
>
{tag}
</span>
))}
{tags.length > 3 && (
<span className="px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-md">
+{tags.length - 3}
</span>
)}
</div>
)}
<div className="mt-auto space-y-4">
{tags && tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-[10px] uppercase tracking-wider font-semibold bg-red-500/10 text-red-400 rounded border border-red-500/20"
>
{tag}
</span>
))}
{tags.length > 3 && (
<span className="px-2 py-0.5 text-[10px] font-semibold bg-muted text-muted-foreground rounded border border-border">
+{tags.length - 3}
</span>
)}
</div>
)}
<Button
variant="secondary"
className="w-full bg-secondary/50 hover:bg-secondary group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
size="sm"
>
View Details
</Button>
</div>
</CardContent>
</Card>
);

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useMemo, useEffect } from "react";
import {
Dialog,
DialogContent,
@@ -10,8 +10,17 @@ import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { EmptyState } from "../ui/empty-state";
import { Skeleton } from "../ui/skeleton";
import { RefreshCw, Package, AlertCircle } from "lucide-react";
import {
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;
@@ -43,83 +52,187 @@ export function TemplateStoreModal({
onRefresh,
onTemplateSelect,
}: TemplateStoreModalProps) {
const [searchParams, setSearchParams] = useSearchParams();
const [searchQuery, setSearchQuery] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [filteredTemplates, setFilteredTemplates] = useState<Template[]>(templates);
const filteredTemplates = templates.filter(
(template) =>
template.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.tags?.some((tag) =>
tag.toLowerCase().includes(searchQuery.toLowerCase())
)
);
// 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 className="max-w-7xl max-h-[90vh] flex flex-col gap-0 p-0">
<DialogHeader className="px-6 pt-6 pb-4 space-y-2">
<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="flex-1">
<DialogTitle className="text-2xl font-bold">
<div className="space-y-1">
<DialogTitle className="text-2xl font-bold tracking-tight">
Template Marketplace
</DialogTitle>
<DialogDescription className="text-base mt-1.5">
Browse and import pre-configured Docker Compose templates.
</DialogDescription>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span>
Templates from{" "}
<a
href="https://github.com/Dokploy/templates"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Dokploy/templates
</a>
</span>
<DialogDescription className="text-base text-muted-foreground">
Browse and import templates with pre-configured Docker Compose files.
{cacheTimestamp && (
<>
<span></span>
<span>
Cached{" "}
{Math.round((Date.now() - cacheTimestamp) / 60000)}m ago
</span>
</>
<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>
<Button
size="sm"
variant="outline"
onClick={onRefresh}
disabled={loading}
className="flex-shrink-0"
>
<RefreshCw
className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<div 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 Bar */}
<div className="pt-2">
<Input
placeholder="Search templates by name, description, or tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-10"
/>
{/* 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">
<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">
<div className="flex items-center justify-center py-12 h-full">
<EmptyState
icon={AlertCircle}
title="Failed to load templates"
@@ -131,7 +244,7 @@ export function TemplateStoreModal({
/>
</div>
) : filteredTemplates.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="flex items-center justify-center py-12 h-full">
<EmptyState
icon={Package}
title={
@@ -158,14 +271,14 @@ export function TemplateStoreModal({
/>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 py-4">
<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/Dokploy/templates/main/blueprints/${template.id}/${template.logo}`
? `https://raw.githubusercontent.com/hhftechnology/Marketplace/main/compose-files/${template.id}/${template.logo}`
: undefined
}
onClick={() => onTemplateSelect(template)}
@@ -181,18 +294,18 @@ export function TemplateStoreModal({
function TemplateGridSkeleton() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 py-4">
<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" />
<div className="flex-1 space-y-2">
<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">
<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" />

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ export interface UseNetworkVolumeManagerReturn {
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
@@ -15,6 +16,7 @@ export interface UseNetworkVolumeManagerReturn {
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;
}
@@ -164,17 +166,36 @@ export function useNetworkVolumeManager({
});
}, [setServices, onSelectionChange]);
// Bulk add methods for template imports
const bulkAddNetworks = useCallback((newNetworks: NetworkConfig[]) => {
setNetworks((prev) => {
const existingNames = new Set(prev.map((n) => n.name));
const uniqueNetworks = newNetworks.filter((n) => !existingNames.has(n.name));
return [...prev, ...uniqueNetworks];
});
}, []);
const bulkAddVolumes = useCallback((newVolumes: VolumeConfig[]) => {
setVolumes((prev) => {
const existingNames = new Set(prev.map((v) => v.name));
const uniqueVolumes = newVolumes.filter((v) => !existingNames.has(v.name));
return [...prev, ...uniqueVolumes];
});
}, []);
return {
networks,
selectedNetworkIdx,
setSelectedNetworkIdx,
addNetwork,
bulkAddNetworks,
updateNetwork,
removeNetwork,
volumes,
selectedVolumeIdx,
setSelectedVolumeIdx,
addVolume,
bulkAddVolumes,
updateVolume,
removeVolume,
};

View File

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

View File

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

View File

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -16,6 +16,7 @@ export interface TailscaleConfig {
serveInternalPort: string;
servePath: string;
serveProtocol: "HTTPS" | "HTTP";
serveInsideProtocol: "http" | "https" | "https+insecure";
containerName: string;
enableHealthCheck: boolean;
healthCheckEndpoint: string;
@@ -73,4 +74,5 @@ export interface VPNConfig {
zerotier?: ZerotierConfig;
netbird?: NetbirdConfig;
servicesUsingVpn: string[]; // Service names that should use VPN
networks?: string[]; // Networks the VPN service should attach to
}

View File

@@ -32,6 +32,7 @@ export function defaultTailscaleConfig(): TailscaleConfig {
serveInternalPort: "8080",
servePath: "/",
serveProtocol: "HTTPS",
serveInsideProtocol: "http",
// ScaleTail patterns - defaults
containerName: "",
enableHealthCheck: true,

View File

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

View File

@@ -9,16 +9,21 @@ export function generateTailscaleServeConfig(
internalPort: string,
path: string,
protocol: "HTTPS" | "HTTP",
certDomain: string
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 proxyUrl = `${insideProtocol}://127.0.0.1:${internalPort}`;
if (protocol === "HTTPS") {
const certDomainKey = certDomain
? certDomain
@@ -27,17 +32,19 @@ export function generateTailscaleServeConfig(
[`${certDomainKey}:${externalPort}`]: {
Handlers: {
[path]: {
Proxy: `http://127.0.0.1:${internalPort}`,
Proxy: proxyUrl,
},
},
},
};
} else {
config.TCP[externalPort] = {
HTTP: true,
Handlers: {
[path]: {
Proxy: `http://127.0.0.1:${internalPort}`,
// HTTP mode: use Web section with localhost
config.Web = {
[`localhost:${externalPort}`]: {
Handlers: {
[path]: {
Proxy: proxyUrl,
},
},
},
};
@@ -58,6 +65,11 @@ export function generateVpnService(vpnConfig: VPNConfig | undefined): 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!;
@@ -107,7 +119,10 @@ export function generateVpnService(vpnConfig: VPNConfig | undefined): any {
NEWT_ID: newt.newtId ? "${NEWT_ID}" : undefined,
NEWT_SECRET: newt.newtSecret ? "${NEWT_SECRET}" : undefined,
};
service.networks = [newt.networkName];
service.networks = [
...(service.networks || []),
newt.networkName
];
Object.keys(service.environment).forEach(
(key) =>
service.environment[key] === undefined &&
@@ -148,7 +163,13 @@ export function generateVpnService(vpnConfig: VPNConfig | undefined): any {
const zt = vpnConfig.zerotier!;
service.image = "zerotier/zerotier:latest";
service.privileged = true;
service.networks = ["host"];
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,

View File

@@ -182,13 +182,13 @@ export function generateYaml(
: svc.networks && svc.networks.filter(Boolean).length
? svc.networks.filter(Boolean)
: undefined,
user: svc.user ? `"${svc.user}"` : 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}"`)
.map(({ key, value }) => `${key}=${value}`)
: undefined,
privileged: svc.privileged !== undefined ? svc.privileged : undefined,
read_only: svc.read_only !== undefined ? svc.read_only : undefined,
@@ -331,7 +331,8 @@ export function generateYaml(
ts.serveInternalPort,
ts.servePath,
ts.serveProtocol,
ts.certDomain
ts.certDomain,
ts.serveInsideProtocol || "http"
);
if (!compose.configs) {
@@ -373,7 +374,7 @@ export function generateYaml(
n.labels && n.labels.length
? n.labels
.filter((l) => l.key)
.map(({ key, value }) => `"${key}=${value}"`)
.map(({ key, value }) => `${key}=${value}`)
: undefined,
ipam:
n.ipam.driver || n.ipam.config.length || n.ipam.options.length
@@ -436,7 +437,7 @@ export function generateYaml(
if (v.labels && v.labels.length) {
externalVolume.labels = v.labels
.filter((l) => l.key)
.map(({ key, value }) => `"${key}=${value}"`);
.map(({ key, value }) => `${key}=${value}`);
}
compose.volumes[v.name] = externalVolume;
@@ -463,7 +464,7 @@ export function generateYaml(
v.labels && v.labels.length
? v.labels
.filter((l) => l.key)
.map(({ key, value }) => `"${key}=${value}"`)
.map(({ key, value }) => `${key}=${value}`)
: undefined,
};
}