mirror of
https://github.com/hhftechnology/Dock-Dploy.git
synced 2025-12-05 19:06:56 -06:00
bug-fixes-optimizations
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -8,7 +8,6 @@
|
||||
"dependencies": {
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
@@ -1368,12 +1367,6 @@
|
||||
"react-hook-form": "^7.55.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@iarna/toml": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz",
|
||||
"integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
|
||||
4823
pnpm-lock.yaml
generated
Normal file
4823
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
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
1395
src/components/compose-builder/ServiceForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
152
src/components/compose-builder/ServiceListSidebar.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
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
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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
26
src/hooks/use-search-params.ts
Normal file
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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
64
src/hooks/useSelectionState.ts
Normal file
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,
|
||||
};
|
||||
}
|
||||
|
||||
398
src/hooks/useServiceUpdater.ts
Normal file
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
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
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -193,8 +193,8 @@ ${serviceUnit}
|
||||
}, [scheduleType, config.name]);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarProvider defaultOpen={true}>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarUI />
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export function defaultTailscaleConfig(): TailscaleConfig {
|
||||
serveInternalPort: "8080",
|
||||
servePath: "/",
|
||||
serveProtocol: "HTTPS",
|
||||
serveInsideProtocol: "http",
|
||||
// ScaleTail patterns - defaults
|
||||
containerName: "",
|
||||
enableHealthCheck: true,
|
||||
|
||||
388
src/utils/template-import.ts
Normal file
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 || {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user