9 Commits
Author SHA1 Message Date
HHF TechnologyandGitHub b5c0f82351 Merge pull request #16 from hhftechnology/dev-ui
Add ESLint, compose-builder, YAML utils & tests
2026-05-12 04:48:03 +05:30
hhftechnologies f81224ae52 Update package-lock.json 2026-05-12 04:45:51 +05:30
hhftechnologies 2d7a7c431b update 2026-05-12 04:44:09 +05:30
hhftechnologies 62c2fc4ab8 Bump dependencies and update UI components
Regenerate package-lock.json with multiple dependency bumps and additions (including TanStack router, Tailwind, Lucide, Radix packages, testing and ESLint tooling) and updated transitive packages. Lockfile now contains new devDependencies and updated node_modules metadata. Also apply corresponding small updates to src/components/Header.tsx, src/components/Footer.tsx and src/routes/index.tsx to accommodate dependency and routing/ui changes.
2026-05-12 04:37:44 +05:30
hhftechnologies e1b498d947 Add blueprint navigation & simplify view
Remove local view state from BlueprintBuilder and always render SetupSidebar with a fixed "blueprint" view. Update the BlueprintBuilder navigation handler to only perform route navigation (remove setView) and normalize conditionals. Add missing navigation targets to other builders so they can navigate back to /blueprint-builder (updated in config-builder, compose-builder, and scheduler-builder). This centralizes navigation behavior and prevents view-state drift in the sidebar.
2026-05-12 04:18:49 +05:30
hhftechnologies 0e550a69b0 Add VPN validation and UI updates
Introduce VPN validation and warning UI, plus related UX and wiring changes.

- Add validation logic and tests: src/utils/validation/vpn.ts and __tests__/vpn.test.ts implement VPN config validation and a compact warning helper (vpnConfigWarning).
- Integrate VPN validation into YAML pipeline (useYamlValidation.ts) so VPN errors are surfaced with other config checks.
- Expand VPN UI: VpnFields and VpnTab get new toggles, Tailscale "serve" controls (target service, ports, protocol, cert domain), network-attachment UI, and an inline configuration warning; styles for warning/serve grid added to styles.css.
- Pass named services/networks through VpnTab and wire up updateVpnNetworks; add Toggle helper component.
- Services UX and tests: hide the trash icon for unnamed/imageless placeholder rows in ServicesTab and add tests (ServicesTab.test.tsx) to cover behavior; adjust removeService in compose-builder to keep a default service row and update selection logic.
- Misc: ensure GHCR image/repo names are lowercased in the CI workflow (.github/workflows/docker-build-push.yml) and minor import/prop updates in compose-builder route.
2026-05-12 04:15:27 +05:30
hhftechnologies e0320f7921 Open templates via navigation & fix copy timer
Header: navigate to /docker/compose-builder with state { openTemplates: true } instead of relying on a setTimeout-dispatched event. Compose builder: read router state on mount, open the templates modal if openTemplates is set, then replace navigation state to clear the flag; keep the existing event listener as well. CodeEditor: introduce timerRef and useMountEffect to clear the copy timeout on unmount and to avoid overlapping timers when the Copy button is pressed repeatedly.
2026-05-11 22:32:13 +05:30
hhftechnologies 670f9e7dad Add Blueprint builder and inline validation
Introduce a new Blueprint Builder feature and validation UI across the app. Adds a /blueprint-builder route and full builder UI (src/routes/blueprint-builder.tsx) plus blueprint types (src/types/blueprint.ts), generator/validator utils and tests (src/utils/blueprint/* and __tests__), and styles for the builder. Add a shadcn-compatible ValidatedShadField component and wire validated inputs (ValidatedShadField / ValidatedInput) into Network, Volume and VPN forms to provide inline validation and accessibility hints (aria-invalid/title). Template detail modal now pre-validates compose YAML and shows a validation banner and prevents import of invalid templates. Update header, sidebar and route tree to surface the new Blueprint nav item. Also add/adjust various validation helpers and small UX/accessibility tweaks (copy/download handlers, error titles).
2026-05-11 22:19:43 +05:30
hhftechnologies 06fc45b87a Add ESLint, compose-builder, YAML utils & tests
Introduce a project-wide ESLint configuration with TypeScript/React rules (including a ban on direct useEffect and inline style object literals) and file-specific overrides. Update index.html to add theme-color meta tags and preload Google fonts. Expand package.json with new scripts (lint, typecheck, validate, extended test commands) and bump/add dev/dependencies for testing, linting, Radix UI, TanStack and tooling. Add many new components and tests (BrandMark, SetupSidebar, compose-builder UI and ServiceForm tabs, VPN tab, CodeMirror runtime, ThemeProvider, template UI and tests, icons), update existing components (CodeEditor, ConversionDialog, Header, Footer, MetaTags, SidebarUI) and hooks/routes. Add YAML generator and related utils, validation schemas, and numerous unit tests and fixtures. Misc: update tsconfig/vite config and styles.
2026-05-11 17:48:46 +05:30
103 changed files with 18658 additions and 9712 deletions
+8 -6
View File
@@ -24,27 +24,29 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: dock-dploy
id: image
run: |
IMAGE_NAME=$(echo "${{ github.repository }}" | cut -d'/' -f2)
IMAGE_NAME=$(echo "${{ github.repository }}" | cut -d'/' -f2 | tr '[:upper:]' '[:lower:]')
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
echo "name=${IMAGE_NAME}" >> $GITHUB_OUTPUT
echo "repo=${REPO_LOWER}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.dockerhub_username }}
password: ${{ secrets.dockerhub_token }}
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY_GHCR }}
username: ${{ github.actor }}
@@ -56,7 +58,7 @@ jobs:
with:
images: |
${{ env.REGISTRY_DOCKERHUB }}/${{ secrets.dockerhub_username }}/${{ steps.image.outputs.name }}
${{ env.REGISTRY_GHCR }}/${{ github.repository }}
${{ env.REGISTRY_GHCR }}/${{ steps.image.outputs.repo }}
tags: |
type=ref,event=branch
type=ref,event=pr
+1 -1
View File
@@ -1,5 +1,5 @@
# Build stage
FROM node:18-alpine AS builder
FROM node:22-alpine AS builder
WORKDIR /app
+132
View File
@@ -0,0 +1,132 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
const NO_USE_EFFECT_MESSAGE =
"Direct use of useEffect is banned. Use derived state, event handlers, key-resets, or useMountEffect() instead. See docs/no-useeffect.md.";
const NO_INLINE_STYLE_MESSAGE =
"Inline style={{...}} object literals are banned. Use design tokens via classes or var(--token) in src/styles.css.";
export default tseslint.config(
{
ignores: [
"dist/**",
"node_modules/**",
"src/routeTree.gen.ts",
"scripts/**",
".claude/**",
".cursor/**",
"**/*.cjs",
],
},
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
// Catch direct named imports of useEffect specifically. We don't
// include this rule in `no-restricted-imports` because namespace imports
// (`import * as React from "react"`) reach `useEffect` through the
// namespace and produce false positives — the call-expression rule below
// catches the real offence.
"no-restricted-syntax": [
"error",
{
selector:
"ImportDeclaration[source.value='react'] > ImportSpecifier[imported.name='useEffect']",
message: NO_USE_EFFECT_MESSAGE,
},
{
selector: "CallExpression[callee.name='useEffect']",
message: NO_USE_EFFECT_MESSAGE,
},
{
selector:
"CallExpression[callee.object.name='React'][callee.property.name='useEffect']",
message: NO_USE_EFFECT_MESSAGE,
},
{
selector:
"JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression",
message: NO_INLINE_STYLE_MESSAGE,
},
],
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "warn",
},
},
{
files: ["src/hooks/useMountEffect.ts"],
rules: {
"no-restricted-imports": "off",
"no-restricted-syntax": "off",
},
},
{
// shadcn/ui primitives are vendored from upstream templates that use a
// small set of idiomatic inline styles. We accept them rather than
// diverging from the upstream output.
files: ["src/components/ui/**/*.{ts,tsx}"],
rules: {
"no-restricted-syntax": "off",
"@typescript-eslint/no-empty-object-type": "off",
},
},
{
// The CodeMirror runtime + lazy wrapper deliberately use style={{}} for
// dynamically-computed editor sizing.
files: [
"src/components/CodeEditor.tsx",
"src/components/CodeMirrorRuntime.tsx",
],
rules: {
"no-restricted-syntax": "off",
},
},
{
// Marketplace card + VPN provider tiles apply token-valued backgrounds
// (CSS variables) via inline style — the value is data-driven per tile.
files: [
"src/components/templates/TemplateCard.tsx",
"src/components/compose-builder/VpnTab/VpnProviderTiles.tsx",
],
rules: {
"no-restricted-syntax": "off",
},
},
{
files: [
"**/*.test.{ts,tsx}",
"**/*.spec.{ts,tsx}",
"src/__tests__/**/*.{ts,tsx}",
],
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
);
+14 -2
View File
@@ -4,10 +4,22 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<meta name="theme-color" content="#000000" />
<meta name="theme-color" content="#faf9f5" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#181715" media="(prefers-color-scheme: dark)" />
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="manifest" href="/manifest.json" />
<!-- Editorial typography: Instrument Serif (display) + Inter (body) + JetBrains Mono (code) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- DNS prefetch is a no-op when preconnect is honored, but covers older browsers -->
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
<link rel="dns-prefetch" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<!-- Primary Meta Tags -->
<title>Dock-Dploy - Build Docker Compose Files Without the Hassle</title>
<meta name="title" content="Dock-Dploy - Build Docker Compose Files Without the Hassle" />
+2612 -2242
View File
File diff suppressed because it is too large Load Diff
+43 -15
View File
@@ -1,13 +1,26 @@
{
"name": "Dock-Dploy",
"private": true,
"description": "A simple deployment tool for Docker containers",
"author": {
"name": "HHF Technology",
"email": "discourse@hhf.technology"
},
"type": "module",
"version": "0.1.0",
"scripts": {
"dev": "vite --port 3000",
"start": "vite --port 3000",
"build": "vite build && tsc",
"build": "vite build && tsc --noEmit",
"serve": "vite preview",
"test": "vitest run"
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"lint:no-magic-css": "node scripts/check-no-magic-css.mjs",
"typecheck": "tsc --noEmit",
"validate": "pnpm typecheck && pnpm lint && pnpm lint:no-magic-css && pnpm test"
},
"dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
@@ -17,15 +30,20 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-router": "^1.139.14",
"@tanstack/react-router-devtools": "^1.139.14",
"@tanstack/router-plugin": "^1.139.14",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-router": "^1.169.2",
"@tanstack/react-router-devtools": "^1.166.13",
"@tanstack/router-plugin": "^1.167.35",
"@uiw/codemirror-extensions-hyper-link": "^4.25.3",
"@uiw/codemirror-theme-monokai-dimmed": "^4.25.3",
"@uiw/react-codemirror": "^4.25.3",
@@ -33,28 +51,38 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"js-yaml": "^4.1.0",
"lucide-react": "^0.556.0",
"lucide-react": "^1.14.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.6",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.0.2"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@eslint/js": "^10.0.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.0.10",
"@types/node": "^25.6.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"jsdom": "^26.0.0",
"typescript": "^5.7.2",
"vite": "^6.1.0",
"vitest": "^3.0.5",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"@vitest/ui": "^4.1.5",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.11",
"vitest": "^4.1.5",
"web-vitals": "^4.2.4"
}
}
+1946 -1315
View File
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* Hard rule enforcement: no hardcoded CSS values in components/routes.
* Allowed locations: src/styles.css, *.css files, SVG `viewBox`/`d` attrs.
*
* Bans (in src/**\/*.{ts,tsx}, excluding tests + generated files):
* - Hex color literals: #fff, #cc785c, #181715, #ff112233 etc.
* - Tailwind arbitrary lengths: w-[340px], h-[72px], text-[14px]
* - Tailwind arbitrary colors: bg-[#cc785c], text-[#181715]
* - Inline style={{ ... }} object literals (also caught by ESLint, double-checked here)
*
* Exit non-zero on any hit. Used in `pnpm validate`.
*/
import { readFile, readdir, stat } from "node:fs/promises";
import { join, sep } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("../src", import.meta.url));
const SKIP_FILES = new Set([
"routeTree.gen.ts",
]);
const SKIP_PATH_FRAGMENTS = [
`${sep}__tests__${sep}`,
`${sep}icons${sep}BrandIcons.tsx`,
// shadcn/ui primitives are vendored upstream — they use a fixed set of
// arbitrary Tailwind values (ring-[3px], top-[50%], w-[8rem]) that are
// idiomatic to shadcn. We accept them rather than diverging from the
// upstream templates. Our own components must still comply.
`${sep}components${sep}ui${sep}`,
];
const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g;
const TW_ARB_PX = /\b\w+-\[\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw|svh|svw)\]/g;
const TW_ARB_COLOR = /\b\w+-\[#[0-9a-fA-F]{3,8}\]/g;
const INLINE_STYLE = /style=\{\{/g;
const violations = [];
async function walk(dir) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full);
continue;
}
if (!/\.(ts|tsx)$/.test(entry.name)) continue;
if (SKIP_FILES.has(entry.name)) continue;
if (SKIP_PATH_FRAGMENTS.some((frag) => full.includes(frag))) continue;
await scan(full);
}
}
async function scan(file) {
const src = await readFile(file, "utf8");
const lines = src.split(/\r?\n/);
lines.forEach((line, i) => {
// Respect explicit per-line opt-outs for genuinely-dynamic values
// (e.g. lazy-loaded CodeMirror inner height passed via style prop):
// `style={{ width: w }} // check-no-magic-css-allow`
if (line.includes("check-no-magic-css-allow")) return;
// Skip lines that look like SVG path data ("d=") or viewBox attributes,
// and tolerate hex colors in pure SVG fill/stroke attributes if any creep
// into icon files. We deny everywhere else.
const stripped = line
.replace(/d="[^"]*"/g, "")
.replace(/viewBox="[^"]*"/g, "");
const hexMatches = stripped.match(HEX_COLOR);
if (hexMatches) {
for (const m of hexMatches) {
violations.push({ file, line: i + 1, kind: "hex-color", value: m });
}
}
const arbPxMatches = stripped.match(TW_ARB_PX);
if (arbPxMatches) {
for (const m of arbPxMatches) {
violations.push({ file, line: i + 1, kind: "tw-arb-length", value: m });
}
}
const arbColorMatches = stripped.match(TW_ARB_COLOR);
if (arbColorMatches) {
for (const m of arbColorMatches) {
violations.push({ file, line: i + 1, kind: "tw-arb-color", value: m });
}
}
const inlineStyleMatches = stripped.match(INLINE_STYLE);
if (inlineStyleMatches) {
for (const _m of inlineStyleMatches) {
violations.push({
file,
line: i + 1,
kind: "inline-style",
value: "style={{...}}",
});
}
}
});
}
try {
await stat(ROOT);
} catch {
console.error(`[check-no-magic-css] src/ not found at ${ROOT}`);
process.exit(1);
}
await walk(ROOT);
if (violations.length === 0) {
console.log("[check-no-magic-css] OK — no hardcoded CSS literals in src/");
process.exit(0);
}
console.error(
`[check-no-magic-css] FAILED — ${violations.length} hardcoded CSS literal(s) found:\n`,
);
for (const v of violations) {
const rel = v.file.replace(ROOT, "src");
console.error(` ${rel}:${v.line} [${v.kind}] ${v.value}`);
}
console.error(
"\nFix: move tokens into src/styles.css and reference via classes / var(--token).",
);
process.exit(1);
+331
View File
@@ -0,0 +1,331 @@
// Canonical fixtures used by golden tests.
// These are deliberately exhaustive so any decomposition of the YAML / converter
// pipeline that drifts output by a single byte fails immediately.
import type {
ServiceConfig,
NetworkConfig,
VolumeConfig,
} from "../types/compose";
import type { VPNConfig } from "../types/vpn-configs";
import {
defaultService,
defaultNetwork,
defaultVolume,
defaultVPNConfig,
defaultTailscaleConfig,
defaultWireguardConfig,
defaultCloudflaredConfig,
defaultNewtConfig,
defaultZerotierConfig,
defaultNetbirdConfig,
} from "../utils/default-configs";
export interface Scenario {
name: string;
services: ServiceConfig[];
networks: NetworkConfig[];
volumes: VolumeConfig[];
vpn?: VPNConfig;
}
function svc(overrides: Partial<ServiceConfig>): ServiceConfig {
return { ...defaultService(), ...overrides };
}
function net(overrides: Partial<NetworkConfig>): NetworkConfig {
return { ...defaultNetwork(), ...overrides };
}
function vol(overrides: Partial<VolumeConfig>): VolumeConfig {
return { ...defaultVolume(), ...overrides };
}
const minimal: Scenario = {
name: "minimal-single-service",
services: [
svc({ name: "web", image: "nginx:latest", restart: "unless-stopped" }),
],
networks: [],
volumes: [],
};
const portsAndEnvArray: Scenario = {
name: "ports-and-env-array",
services: [
svc({
name: "api",
image: "node:22-alpine",
restart: "always",
ports: [
{ host: "3000", container: "3000", protocol: "tcp" },
{ host: "9229", container: "9229", protocol: "tcp" },
],
environment: [
{ key: "NODE_ENV", value: "production" },
{ key: "PORT", value: "3000" },
{ key: "DATABASE_URL", value: "${DATABASE_URL}" },
],
environment_syntax: "array",
}),
],
networks: [],
volumes: [],
};
const envDictWithVolumes: Scenario = {
name: "env-dict-with-named-volume",
services: [
svc({
name: "db",
image: "postgres:16",
restart: "unless-stopped",
environment: [
{ key: "POSTGRES_USER", value: "app" },
{ key: "POSTGRES_PASSWORD", value: "${POSTGRES_PASSWORD}" },
{ key: "POSTGRES_DB", value: "app" },
],
environment_syntax: "dict",
volumes: [
{ host: "pg-data", container: "/var/lib/postgresql/data" },
{ host: "./init.sql", container: "/docker-entrypoint-initdb.d/init.sql", read_only: true },
],
volumes_syntax: "array",
}),
],
networks: [],
volumes: [
vol({ name: "pg-data", driver: "local" }),
],
};
const multiServiceWithDeps: Scenario = {
name: "multi-service-with-deps-and-networks",
services: [
svc({
name: "web",
image: "nginx:1.27-alpine",
restart: "unless-stopped",
ports: [{ host: "80", container: "80", protocol: "tcp" }],
depends_on: ["api"],
networks: ["frontend"],
}),
svc({
name: "api",
image: "node:22-alpine",
restart: "unless-stopped",
depends_on: ["db"],
environment: [{ key: "DATABASE_URL", value: "${DATABASE_URL}" }],
environment_syntax: "array",
networks: ["frontend", "backend"],
}),
svc({
name: "db",
image: "postgres:16",
restart: "unless-stopped",
environment: [
{ key: "POSTGRES_USER", value: "app" },
{ key: "POSTGRES_PASSWORD", value: "${POSTGRES_PASSWORD}" },
],
environment_syntax: "array",
volumes: [{ host: "pg-data", container: "/var/lib/postgresql/data" }],
networks: ["backend"],
}),
],
networks: [
net({ name: "frontend", driver: "bridge" }),
net({ name: "backend", driver: "bridge", internal: true }),
],
volumes: [vol({ name: "pg-data", driver: "local" })],
};
const withHealthcheckAndDeploy: Scenario = {
name: "healthcheck-and-deploy-resources",
services: [
svc({
name: "api",
image: "ghcr.io/example/api:1.0.0",
restart: "unless-stopped",
ports: [{ host: "8080", container: "8080", protocol: "tcp" }],
healthcheck: {
test: "CMD curl -f http://localhost:8080/health || exit 1",
interval: "30s",
timeout: "5s",
retries: "3",
start_period: "10s",
start_interval: "5s",
},
deploy: {
resources: {
limits: { cpus: "1.0", memory: "512M" },
reservations: { cpus: "0.25", memory: "128M" },
},
},
labels: [
{ key: "traefik.enable", value: "true" },
{ key: "traefik.http.routers.api.rule", value: "Host(`api.example.com`)" },
],
}),
],
networks: [],
volumes: [],
};
const withCapsAndSecurity: Scenario = {
name: "caps-security-and-lifecycle",
services: [
svc({
name: "tailscale-sidecar",
image: "tailscale/tailscale:stable",
restart: "unless-stopped",
cap_add: ["NET_ADMIN", "NET_RAW"],
cap_drop: ["ALL"],
privileged: false,
read_only: false,
tty: true,
stdin_open: true,
init: true,
stop_grace_period: "10s",
stop_signal: "SIGTERM",
hostname: "ts-host",
sysctls: [{ key: "net.ipv4.ip_forward", value: "1" }],
devices: ["/dev/net/tun:/dev/net/tun"],
tmpfs: ["/tmp"],
ulimits: [{ name: "nofile", soft: "65535", hard: "65535" }],
security_opt: ["no-new-privileges:true"],
}),
],
networks: [],
volumes: [],
};
function vpnTailscaleScenario(): Scenario {
return {
name: "vpn-tailscale-attached",
services: [
svc({
name: "web",
image: "nginx:latest",
restart: "unless-stopped",
}),
],
networks: [],
volumes: [],
vpn: {
...defaultVPNConfig(),
enabled: true,
type: "tailscale",
tailscale: {
...defaultTailscaleConfig(),
authKey: "${TS_AUTHKEY}",
hostname: "edge",
},
servicesUsingVpn: ["web"],
},
};
}
function vpnWireguardScenario(): Scenario {
return {
name: "vpn-wireguard-attached",
services: [
svc({ name: "torrent", image: "linuxserver/qbittorrent:latest", restart: "unless-stopped" }),
],
networks: [],
volumes: [],
vpn: {
...defaultVPNConfig(),
enabled: true,
type: "wireguard",
wireguard: defaultWireguardConfig(),
servicesUsingVpn: ["torrent"],
},
};
}
function vpnCloudflaredScenario(): Scenario {
return {
name: "vpn-cloudflared-tunnel",
services: [
svc({ name: "app", image: "ghcr.io/example/app:latest", restart: "unless-stopped" }),
],
networks: [],
volumes: [],
vpn: {
...defaultVPNConfig(),
enabled: true,
type: "cloudflared",
cloudflared: { ...defaultCloudflaredConfig(), tunnelToken: "${TUNNEL_TOKEN}" },
servicesUsingVpn: [],
},
};
}
function vpnNewtScenario(): Scenario {
return {
name: "vpn-newt-tunnel",
services: [
svc({ name: "app", image: "ghcr.io/example/app:latest", restart: "unless-stopped" }),
],
networks: [],
volumes: [],
vpn: {
...defaultVPNConfig(),
enabled: true,
type: "newt",
newt: { ...defaultNewtConfig(), newtId: "${NEWT_ID}", newtSecret: "${NEWT_SECRET}" },
servicesUsingVpn: ["app"],
},
};
}
function vpnZerotierScenario(): Scenario {
return {
name: "vpn-zerotier-network",
services: [
svc({ name: "node", image: "ghcr.io/example/node:latest", restart: "unless-stopped" }),
],
networks: [],
volumes: [],
vpn: {
...defaultVPNConfig(),
enabled: true,
type: "zerotier",
zerotier: { ...defaultZerotierConfig(), networkId: "${ZT_NETWORK_ID}" },
servicesUsingVpn: ["node"],
},
};
}
function vpnNetbirdScenario(): Scenario {
return {
name: "vpn-netbird-mesh",
services: [
svc({ name: "agent", image: "ghcr.io/example/agent:latest", restart: "unless-stopped" }),
],
networks: [],
volumes: [],
vpn: {
...defaultVPNConfig(),
enabled: true,
type: "netbird",
netbird: { ...defaultNetbirdConfig(), setupKey: "${NB_SETUP_KEY}" },
servicesUsingVpn: ["agent"],
},
};
}
export const SCENARIOS: Scenario[] = [
minimal,
portsAndEnvArray,
envDictWithVolumes,
multiServiceWithDeps,
withHealthcheckAndDeploy,
withCapsAndSecurity,
vpnTailscaleScenario(),
vpnWireguardScenario(),
vpnCloudflaredScenario(),
vpnNewtScenario(),
vpnZerotierScenario(),
vpnNetbirdScenario(),
];
+26
View File
@@ -0,0 +1,26 @@
import "@testing-library/jest-dom/vitest";
// matchMedia polyfill for jsdom
if (!window.matchMedia) {
window.matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
});
}
// ResizeObserver polyfill for Radix primitives
if (!("ResizeObserver" in window)) {
class RO {
observe() {}
unobserve() {}
disconnect() {}
}
// @ts-expect-error jsdom polyfill
window.ResizeObserver = RO;
}
+32
View File
@@ -0,0 +1,32 @@
// Editorial brand mark — abstract dock/anchor stack.
// Original (not a copy of any third-party mark).
// All colors come from CSS tokens via currentColor; size is the only literal.
import type { SVGProps } from "react";
type Tone = "ink" | "on-dark";
interface BrandMarkProps extends Omit<SVGProps<SVGSVGElement>, "fill"> {
size?: number;
tone?: Tone;
}
export function BrandMark({ size = 28, tone = "ink", ...rest }: BrandMarkProps) {
const fillClass = tone === "ink" ? "brand-mark--ink" : "brand-mark--on-dark";
return (
<svg
width={size}
height={size}
viewBox="0 0 32 32"
fill="none"
aria-hidden="true"
className={fillClass}
{...rest}
>
<rect className="brand-mark__accent" x="4" y="6" width="24" height="4" rx="1" />
<rect className="brand-mark__bar" x="6" y="13" width="20" height="4" rx="1" opacity="0.85" />
<rect className="brand-mark__bar" x="9" y="20" width="14" height="4" rx="1" opacity="0.55" />
<circle className="brand-mark__accent" cx="16" cy="28" r="1.6" />
</svg>
);
}
+133 -118
View File
@@ -1,131 +1,146 @@
import React, { useState, useMemo } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { hyperLink } from '@uiw/codemirror-extensions-hyper-link';
import {yaml} from "@codemirror/lang-yaml";
import { Button } from "./ui/button";
import { monokaiDimmed } from '@uiw/codemirror-theme-monokai-dimmed';
import { useTheme } from "./ThemeProvider";
import { Suspense, lazy, useMemo, useState, useRef } from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "./ui/button";
import { useTheme } from "./ThemeProvider";
import { useMountEffect } from "../hooks/useMountEffect";
/**
* CodeMirror is heavy (~500KB before tree-shaking) and not needed on the
* landing page or any read-only surface. We lazy-load it so the initial bundle
* stays slim. The fallback shows a plain <pre> until the editor module loads.
*/
const LazyCodeMirror = lazy(() => import("./CodeMirrorRuntime"));
interface CodeEditorProps {
content: string;
onContentChange: (value: string) => void;
width?: number | string;
height?: number | string;
editable?: boolean;
showCopyButton?: boolean;
minHeight?: number;
maxHeight?: number;
content: string;
onContentChange: (value: string) => void;
width?: number | string;
height?: number | string;
editable?: boolean;
showCopyButton?: boolean;
minHeight?: number;
maxHeight?: number;
}
export const CodeEditor: React.FC<CodeEditorProps> = ({
content,
onContentChange,
width,
height,
editable = false,
showCopyButton = true,
minHeight = 200,
maxHeight,
}) => {
const [copied, setCopied] = useState(false);
const { theme } = useTheme();
export function CodeEditor({
content,
onContentChange,
width,
height,
editable = false,
showCopyButton = true,
minHeight = 200,
maxHeight,
}: CodeEditorProps) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<number | null>(null);
const { theme } = useTheme();
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
setCopied(false);
}
useMountEffect(() => {
return () => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
}
};
});
// Determine which theme to use
const isDark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setCopied(false);
timerRef.current = null;
}, 2000);
} catch {
setCopied(false);
}
};
// For light mode, we'll use a custom style approach
const editorTheme = monokaiDimmed;
const isDark =
theme === "dark" ||
(theme === "system" &&
typeof window !== "undefined" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
// Calculate responsive height based on content
const calculatedHeight = useMemo(() => {
if (height !== undefined) {
return typeof height === 'number' ? `${height}px` : height;
}
// Memoized so we don't recompute on every keystroke.
const sizing = useMemo(() => {
const h =
height !== undefined
? typeof height === "number"
? `${height}px`
: height
: (() => {
const lines = content.split("\n").length;
const lineHeight = 24;
const px = Math.max(
minHeight,
Math.min(lines * lineHeight + 40, maxHeight || 800),
);
return `${px}px`;
})();
const w =
width !== undefined
? typeof width === "number"
? `${width}px`
: width
: "100%";
return { h, w };
}, [content, height, width, minHeight, maxHeight]);
// Auto-calculate based on line count
const lines = content.split('\n').length;
const lineHeight = 24; // approximate line height in pixels
const calculatedPx = Math.max(minHeight, Math.min(lines * lineHeight + 40, maxHeight || 800));
// CSS custom properties (not inline values) — set on the element via style,
// but the *values* are CSS-typed strings already computed, not magic numbers
// baked into JSX. These are necessary because the editor's height is
// genuinely dynamic per-instance.
const containerStyle = {
width: sizing.w,
height: sizing.h,
minHeight: `${minHeight}px`,
maxHeight: maxHeight ? `${maxHeight}px` : undefined,
} as const;
return `${calculatedPx}px`;
}, [content, height, minHeight, maxHeight]);
const calculatedWidth = useMemo(() => {
if (width !== undefined) {
return typeof width === 'number' ? `${width}px` : width;
}
return '100%';
}, [width]);
return (
<div
className="relative flex flex-col overflow-hidden rounded-lg border bg-sidebar"
style={{
width: calculatedWidth,
height: calculatedHeight,
minHeight: `${minHeight}px`,
maxHeight: maxHeight ? `${maxHeight}px` : undefined,
}}
return (
// eslint-disable-next-line no-restricted-syntax
<div className="code-editor-shell" style={containerStyle}> {/* check-no-magic-css-allow */}
{showCopyButton && (
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="code-editor-copy"
title={copied ? "Copied!" : "Copy to clipboard"}
aria-label="Copy code"
type="button"
>
{/* Copy button in top right */}
{showCopyButton && (
<Button
variant="secondary"
size="sm"
onClick={handleCopy}
className="absolute top-2 right-2 z-10 h-8 px-3 shadow-md"
title={copied ? "Copied!" : "Copy to clipboard"}
aria-label="Copy code"
type="button"
>
{copied ? (
<>
<Check className="h-3.5 w-3.5 mr-1.5" />
Copied
</>
) : (
<>
<Copy className="h-3.5 w-3.5 mr-1.5" />
Copy
</>
)}
</Button>
)}
<div className={`flex-1 overflow-auto ${isDark ? "" : "cm-light-theme"}`}>
<CodeMirror
value={content}
height="100%"
width="100%"
theme={editorTheme}
editable={editable}
extensions={[
yaml(),
hyperLink,
]}
onChange={(value: string) => onContentChange(value)}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: editable,
highlightActiveLine: editable,
foldGutter: true,
}}
style={{
fontSize: 14,
fontFamily: 'monospace',
}}
/>
</div>
</div>
);
};
{copied ? (
<>
<Check size={14} />
Copied
</>
) : (
<>
<Copy size={14} />
Copy
</>
)}
</Button>
)}
<div className={"code-editor-inner" + (isDark ? "" : " cm-light-theme")}>
<Suspense
fallback={
<pre className="code-editor-fallback">Loading editor…</pre>
}
>
<LazyCodeMirror
content={content}
onContentChange={onContentChange}
editable={editable}
/>
</Suspense>
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
// Heavy CodeMirror runtime — split into its own chunk so the landing bundle
// and other read-only surfaces don't pull this in. Loaded via React.lazy from
// CodeEditor.tsx.
import CodeMirror from "@uiw/react-codemirror";
import { hyperLink } from "@uiw/codemirror-extensions-hyper-link";
import { yaml } from "@codemirror/lang-yaml";
import { monokaiDimmed } from "@uiw/codemirror-theme-monokai-dimmed";
interface CodeMirrorRuntimeProps {
content: string;
onContentChange: (value: string) => void;
editable?: boolean;
}
export default function CodeMirrorRuntime({
content,
onContentChange,
editable = false,
}: CodeMirrorRuntimeProps) {
return (
<CodeMirror
value={content}
height="100%"
width="100%"
theme={monokaiDimmed}
editable={editable}
extensions={[yaml(), hyperLink]}
onChange={(value: string) => onContentChange(value)}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: editable,
highlightActiveLine: editable,
foldGutter: true,
}}
// eslint-disable-next-line no-restricted-syntax
style={{ fontSize: 14, fontFamily: "var(--mono)" }} // check-no-magic-css-allow
/>
);
}
+1 -1
View File
@@ -95,7 +95,7 @@ export function ConversionDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
<DialogContent className="max-w-4xl dialog-h-medium flex flex-col">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
+80 -17
View File
@@ -1,26 +1,89 @@
import { Button } from "./ui/button";
import { Separator } from "./ui/separator";
import { BrandMark } from "./BrandMark";
interface FooterCol {
heading: string;
links: { label: string; href: string }[];
}
const COLS: FooterCol[] = [
{
heading: "Product",
links: [
{ label: "Compose Builder", href: "/docker/compose-builder" },
{ label: "Config Builder", href: "/config-builder" },
{ label: "Scheduler", href: "/scheduler-builder" },
{ label: "Templates", href: "/" },
],
},
{
heading: "Resources",
links: [
{ label: "Documentation", href: "https://github.com/hhftechnology/Dock-Dploy#readme" },
{ label: "Changelog", href: "https://github.com/hhftechnology/Dock-Dploy/releases" },
{ label: "Roadmap", href: "https://github.com/hhftechnology/Dock-Dploy/issues" },
],
},
{
heading: "Community",
links: [
{ label: "GitHub", href: "https://github.com/hhftechnology/Dock-Dploy" },
{ label: "Discord", href: "https://discord.gg/HDCt9MjyMJ" },
{ label: "Contribute", href: "https://github.com/hhftechnology/Dock-Dploy/blob/main/CONTRIBUTING.md" },
],
},
{
heading: "Legal",
links: [
{ label: "License (AGPL-3.0)", href: "https://www.gnu.org/licenses/agpl-3.0.txt" },
{ label: "Privacy", href: "https://forum.hhf.technology/privacy" },
{ label: "Terms", href: "https://forum.hhf.technology/tos" },
],
},
];
export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="border-t bg-background">
<div className="container flex h-16 items-center justify-between px-4">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>
© {new Date().getFullYear()} Dock-Dploy. All rights reserved.
</span>
<footer className="app-footer">
<div className="footer-inner">
<div className="footer-brand">
<BrandMark size={28} tone="on-dark" />
<div>
<span className="footer-name">
Dock<span className="brand-dot">·</span>Dploy
</span>
<p className="footer-sub">
A friendlier surface for Docker Compose, configs, and schedulers.
Open source under AGPL-3.0.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button>
Privacy
</Button>
<Separator orientation="vertical" className="h-4" />
<Button>
Terms of Service
</Button>
<div className="footer-cols">
{COLS.map((col) => (
<div key={col.heading} className="footer-col">
<div className="footer-col-h">{col.heading}</div>
<ul>
{col.links.map((link) => (
<li key={link.label}>
<a
href={link.href}
target={link.href.startsWith("http") ? "_blank" : undefined}
rel={link.href.startsWith("http") ? "noreferrer" : undefined}
>
{link.label}
</a>
</li>
))}
</ul>
</div>
))}
</div>
</div>
<div className="footer-rule" />
<div className="footer-bottom">
<span>© {year} Dock-Dploy · HHF Technology</span>
<span className="footer-mono">v0.1.0 · AGPL-3.0</span>
</div>
</footer>
);
}
+95 -69
View File
@@ -1,87 +1,113 @@
import { useNavigate, useRouterState } from "@tanstack/react-router";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "./ThemeProvider";
import { Moon, Sun, Github } from "lucide-react";
import { Button } from "./ui/button";
import { BrandMark } from "./BrandMark";
import { GithubIcon, DiscordIcon } from "./icons/BrandIcons";
export function Header() {
interface HeaderProps {
onOpenMarket?: () => void;
}
const NAV_ITEMS = [
{ label: "Compose", id: "compose", to: "/docker/compose-builder" },
{ label: "Config", id: "config", to: "/config-builder" },
{ label: "Scheduler", id: "scheduler", to: "/scheduler-builder" },
{ label: "Blueprint", id: "blueprint", to: "/blueprint-builder" },
// "templates" handled inline — opens the marketplace modal
] as const;
export function Header({ onOpenMarket }: HeaderProps) {
const { theme, setTheme } = useTheme();
const navigate = useNavigate();
const routerState = useRouterState();
const path = routerState.location.pathname;
const toggleTheme = () => {
if (theme === "dark") {
setTheme("light");
} else {
setTheme("dark");
}
};
const isActive = (to: string) => path.startsWith(to);
const onTemplatesView = path === "/templates";
const toggleTheme = () => setTheme(theme === "dark" ? "light" : "dark");
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center justify-between px-4">
<div className="flex items-center gap-2">
<img
src="/logo.svg"
alt="Dock-Dploy"
className="h-8 w-8"
onError={(e) => {
// Fallback to text if logo doesn't load
const target = e.target as HTMLImageElement;
target.style.display = "none";
if (!target.nextElementSibling) {
const text = document.createElement("span");
text.className = "font-bold text-lg";
text.textContent = "Dock-Dploy";
target.parentElement?.appendChild(text);
<header className="app-header">
<div className="header-inner">
<button className="brand" onClick={() => navigate({ to: "/" })} aria-label="Home">
<BrandMark size={28} />
<span className="brand-text">
<span className="brand-name">
Dock<span className="brand-dot">·</span>Dploy
</span>
<span className="brand-sub">by HHF Technology</span>
</span>
</button>
<nav className="top-nav">
{NAV_ITEMS.map((item) => (
<button
key={item.id}
className={"nav-link" + (isActive(item.to) ? " active" : "")}
onClick={() => navigate({ to: item.to })}
>
{item.label}
</button>
))}
<button
className={"nav-link" + (onTemplatesView ? " active" : "")}
onClick={() => {
if (onOpenMarket) {
onOpenMarket();
return;
}
const dispatch = () =>
window.dispatchEvent(new Event("dockdploy:open-templates"));
if (path.startsWith("/docker/compose-builder")) {
dispatch();
} else {
navigate({
to: "/docker/compose-builder",
state: { openTemplates: true },
} as any);
}
}}
/>
<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
variant="ghost"
size="icon"
onClick={() =>
window.open("https://discord.gg/HDCt9MjyMJ", "_blank")
}
title="Discord"
>
Templates
</button>
</nav>
<div className="header-actions">
<a
className="icon-btn"
href="https://discord.gg/HDCt9MjyMJ"
target="_blank"
rel="noreferrer"
aria-label="Discord"
title="Discord"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027A19.5 19.5 0 0 0 .05 17.29a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054a19.594 19.594 0 0 0-3.6-12.9a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" />
</svg>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() =>
window.open("https://github.com/hhftechnology/Dock-Dploy", "_blank")
}
title="GitHub"
<DiscordIcon width={18} height={18} />
</a>
<a
className="icon-btn"
href="https://github.com/hhftechnologies/Dock-Dploy"
target="_blank"
rel="noreferrer"
aria-label="GitHub"
title="GitHub"
>
<Github className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
<GithubIcon width={18} height={18} />
</a>
<button
className="icon-btn"
onClick={toggleTheme}
title={
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
}
aria-label="Toggle theme"
title="Toggle theme"
>
{theme === "dark" ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
<button
className="btn btn-primary header-cta"
onClick={() => navigate({ to: "/docker/compose-builder" })}
>
Launch Builder
</button>
</div>
</div>
</header>
+19 -23
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useLayoutEffect } from "react";
import { useRouterState } from "@tanstack/react-router";
import { updateMetaTags } from "../lib/meta-tags";
import type { MetaTagsConfig } from "../lib/meta-tags";
@@ -10,57 +10,53 @@ interface MetaTagsProps {
type?: string;
}
export function MetaTags({
title,
description,
image,
type,
}: MetaTagsProps) {
/**
* Updates document.title and meta tags after the DOM is updated.
*
* We use useLayoutEffect instead of pure render phase mutation to prevent
* inconsistent behavior with concurrent rendering and Suspense.
*/
export function MetaTags({ title, description, image, type }: MetaTagsProps) {
const router = useRouterState();
const pathname = router.location.pathname;
useEffect(() => {
// Get route-specific meta tags or use provided props
useLayoutEffect(() => {
const routeMeta = routeMetaTags[pathname];
const config: MetaTagsConfig = {
title: title || routeMeta?.title,
description: description || routeMeta?.description,
image: image || routeMeta?.image,
type: type || routeMeta?.type,
url: typeof window !== "undefined" ? window.location.href : undefined,
url: window.location.href,
};
updateMetaTags(config);
}, [pathname, title, description, image, type]);
}, [title, description, image, type, pathname]);
return null;
}
// Route-specific meta tag configurations
export const routeMetaTags: Record<string, MetaTagsConfig> = {
"/": {
title: "Dock-Dploy - Build Docker Compose Files Without the Hassle",
title: "Dock-Dploy — Build Docker Compose Without the Hassle",
description:
"A powerful web-based tool for building and managing Docker Compose files, configurations, and schedulers. All in one place, completely free.",
"Calm, visual surface for Docker Compose, configs, and schedulers. Open source, AGPL-3.0.",
image: "/og-image.png",
},
"/docker/compose-builder": {
title: "Docker Compose Builder - Dock-Dploy",
title: "Compose Builder — Dock-Dploy",
description:
"Build and manage Docker Compose files with an intuitive interface. Validate, reformat, and convert to various formats.",
"Build and manage Docker Compose files with a tabbed editor. Validate, reformat, and export.",
image: "/og-image.png",
},
"/config-builder": {
title: "Config Builder - Dock-Dploy",
description:
"Create configuration files for popular self-hosted tools like Homepage.dev and more.",
title: "Config Builder — Dock-Dploy",
description: "Generate configs for self-hosted tools like Homepage.dev.",
image: "/og-image.png",
},
"/scheduler-builder": {
title: "Scheduler Builder - Dock-Dploy",
description:
"Generate schedulers for Cron, GitHub Actions, Systemd timers, and more with a simple form.",
title: "Scheduler Builder — Dock-Dploy",
description: "Generate cron, GitHub Actions, and systemd timer schedules.",
image: "/og-image.png",
},
};
+115
View File
@@ -0,0 +1,115 @@
// Editorial icon-rail sidebar.
// Defaults to 64px. Hovers expand to 240px (CSS-only). Click brand mark to pin.
import { useState, type ReactNode } from "react";
import { BrandMark } from "./BrandMark";
export type BuilderView = "compose" | "config" | "scheduler" | "blueprint";
interface RailItem {
id: BuilderView;
group: string;
label: string;
icon: ReactNode;
}
const RAIL_ITEMS: RailItem[] = [
{
id: "compose",
group: "Docker",
label: "Compose Builder",
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
),
},
{
id: "config",
group: "Builders",
label: "Config Builder",
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M5 21V5a2 2 0 0 1 2-2h7l5 5v13a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2z" />
</svg>
),
},
{
id: "scheduler",
group: "Builders",
label: "Scheduler Builder",
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="12" r="9" />
<polyline points="12 7 12 12 15 14" />
</svg>
),
},
{
id: "blueprint",
group: "Pangolin",
label: "Blueprint Builder",
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M3 7l9-4 9 4-9 4-9-4z" />
<path d="M3 12l9 4 9-4" />
<path d="M3 17l9 4 9-4" />
</svg>
),
},
];
interface SetupSidebarProps {
view: BuilderView;
onNav: (view: BuilderView) => void;
}
export function SetupSidebar({ view, onNav }: SetupSidebarProps) {
const [pinned, setPinned] = useState(false);
return (
<aside className={"setup-sidebar" + (pinned ? " pinned" : "")}>
<button
type="button"
className="rail-brand"
onClick={() => setPinned((p) => !p)}
title={pinned ? "Unpin sidebar" : "Pin sidebar"}
>
<BrandMark size={20} />
<span className="rail-brand-label">Setup Tools</span>
</button>
<div className="rail-items">
{RAIL_ITEMS.map((item) => (
<button
key={item.id}
type="button"
className={"rail-item" + (view === item.id ? " active" : "")}
onClick={() => onNav(item.id)}
title={item.label}
>
<span className="rail-icon">{item.icon}</span>
<span className="rail-label">
<span className="rail-label-group">{item.group}</span>
<span className="rail-label-text">{item.label}</span>
</span>
</button>
))}
</div>
<div className="rail-spacer" />
<button
type="button"
className="rail-pin"
onClick={() => setPinned((p) => !p)}
title={pinned ? "Unpin" : "Pin"}
>
<span className="rail-pin-icon" aria-hidden>
{pinned ? "◉" : "○"}
</span>
<span className="rail-label-text">{pinned ? "Unpin" : "Pin"}</span>
</button>
</aside>
);
}
+23 -46
View File
@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { ChevronDown, Container, FileText, Clock } from "lucide-react";
import { useNavigate, useRouter } from "@tanstack/react-router";
import {
@@ -21,24 +20,9 @@ import {
} from "./ui/collapsible";
const items = [
{
title: "Compose Builder",
url: "/docker/compose-builder",
icon: Container,
group: "Docker",
},
{
title: "Config Builder",
url: "/config-builder",
icon: FileText,
group: "Builders",
},
{
title: "Scheduler Builder",
url: "/scheduler-builder",
icon: Clock,
group: "Builders",
},
{ title: "Compose Builder", url: "/docker/compose-builder", icon: Container, group: "Docker" },
{ title: "Config Builder", url: "/config-builder", icon: FileText, group: "Builders" },
{ title: "Scheduler Builder", url: "/scheduler-builder", icon: Clock, group: "Builders" },
];
const groupedItems = items.reduce<Record<string, typeof items>>((acc, item) => {
@@ -52,30 +36,19 @@ export function SidebarUI() {
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]);
// Track only explicit user overrides. Whether a group is open is derived from
// the current route + the user's last interaction — no effect needed.
const [userOverride, setUserOverride] = useState<Record<string, boolean>>({});
const isGroupActive = (groupItems: typeof items) =>
groupItems.some((item) => location.pathname === item.url);
return (
<>
<SidebarHeader className="border-b border-sidebar-border">
<div className="flex items-center gap-2">
<div
<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"
>
@@ -86,21 +59,28 @@ export function SidebarUI() {
<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">
</span>
<span className="truncate text-xs text-sidebar-foreground/70" />
</div>
</div>
</SidebarHeader>
<SidebarContent>
{Object.entries(groupedItems).map(([groupName, groupItems]) => {
const isOpen = state === "collapsed" ? true : (openGroups[groupName] || false);
const activeFromRoute = isGroupActive(groupItems);
const isOpen =
state === "collapsed"
? true
: userOverride[groupName] !== undefined
? userOverride[groupName]
: activeFromRoute;
return (
<Collapsible
key={groupName}
open={isOpen}
onOpenChange={(open) => setOpenGroups((prev) => ({ ...prev, [groupName]: open }))}
onOpenChange={(open) =>
setUserOverride((prev) => ({ ...prev, [groupName]: open }))
}
className="group/collapsible"
>
<SidebarGroup>
@@ -117,16 +97,13 @@ export function SidebarUI() {
<SidebarMenu>
{groupItems.map((item) => {
const isActive = location.pathname === item.url;
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={isActive}
onClick={() => {
if (!isActive) {
navigate({ to: item.url });
}
if (!isActive) navigate({ to: item.url });
}}
>
<item.icon className="h-4 w-4" />
+49 -28
View File
@@ -1,4 +1,5 @@
import { createContext, useContext, useEffect, useState } from "react";
import { createContext, useCallback, useContext, useState } from "react";
import { useMountEffect } from "../hooks/useMountEffect";
type Theme = "dark" | "light" | "system";
@@ -20,41 +21,62 @@ const initialState: ThemeProviderState = {
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
function readStoredTheme(storageKey: string, fallback: Theme): Theme {
if (typeof window === "undefined") return fallback;
try {
return (localStorage.getItem(storageKey) as Theme) || fallback;
} catch {
return fallback;
}
}
function resolveTheme(theme: Theme): "light" | "dark" {
if (theme !== "system") return theme;
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function applyThemeClass(theme: Theme) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(resolveTheme(theme));
// Also keep body in sync for editorial `body.dark` selectors in styles.css
document.body.classList.remove("light", "dark");
document.body.classList.add(resolveTheme(theme));
}
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
const [theme, setThemeState] = useState<Theme>(() =>
readStoredTheme(storageKey, defaultTheme),
);
useEffect(() => {
const root = window.document.documentElement;
// Apply theme on mount once — covers initial paint synchronization.
useMountEffect(() => {
applyThemeClass(theme);
});
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
const setTheme = useCallback(
(next: Theme) => {
try {
localStorage.setItem(storageKey, next);
} catch {
// localStorage may be disabled (private mode). Best-effort write.
}
applyThemeClass(next);
setThemeState(next);
},
};
[storageKey],
);
const value = { theme, setTheme };
return (
<ThemeProviderContext.Provider {...props} value={value}>
@@ -63,11 +85,10 @@ export function ThemeProvider({
);
}
// eslint-disable-next-line react-refresh/only-export-components
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { BrandMark } from "../BrandMark";
describe("BrandMark", () => {
it("renders an inline SVG", () => {
const { container } = render(<BrandMark />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
it("respects size prop", () => {
const { container } = render(<BrandMark size={64} />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("width")).toBe("64");
expect(svg?.getAttribute("height")).toBe("64");
});
it("toggles tone class for dark surfaces", () => {
const { container, rerender } = render(<BrandMark tone="ink" />);
expect(container.querySelector(".brand-mark--ink")).toBeTruthy();
rerender(<BrandMark tone="on-dark" />);
expect(container.querySelector(".brand-mark--on-dark")).toBeTruthy();
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ServicesTab } from "../compose-builder/ConfigColumn/ServicesTab";
import { defaultService } from "../../utils/default-configs";
describe("ServicesTab", () => {
it("hides the trash icon on an unnamed service", () => {
render(
<ServicesTab
services={[defaultService()]}
selectedIdx={0}
selectedType="service"
onSelectService={() => {}}
onAddService={() => {}}
onBrowseTemplates={() => {}}
onRemoveService={() => {}}
/>,
);
expect(
screen.queryByRole("button", { name: /remove service/i }),
).not.toBeInTheDocument();
});
it("shows the trash icon once a service is named", () => {
const svc = { ...defaultService(), name: "web", image: "nginx" };
render(
<ServicesTab
services={[svc]}
selectedIdx={0}
selectedType="service"
onSelectService={() => {}}
onAddService={() => {}}
onBrowseTemplates={() => {}}
onRemoveService={() => {}}
/>,
);
expect(
screen.getByRole("button", { name: /remove service/i }),
).toBeInTheDocument();
});
it("fires onRemoveService when the trash is clicked", async () => {
const onRemove = vi.fn();
const svc = { ...defaultService(), name: "web", image: "nginx" };
render(
<ServicesTab
services={[svc]}
selectedIdx={0}
selectedType="service"
onSelectService={() => {}}
onAddService={() => {}}
onBrowseTemplates={() => {}}
onRemoveService={onRemove}
/>,
);
await userEvent.click(
screen.getByRole("button", { name: /remove service/i }),
);
expect(onRemove).toHaveBeenCalledWith(0);
});
});
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { SetupSidebar } from "../SetupSidebar";
describe("SetupSidebar", () => {
it("renders all three rail items", () => {
render(<SetupSidebar view="compose" onNav={() => {}} />);
expect(screen.getByTitle("Compose Builder")).toBeTruthy();
expect(screen.getByTitle("Config Builder")).toBeTruthy();
expect(screen.getByTitle("Scheduler Builder")).toBeTruthy();
});
it("marks the active item with .active", () => {
const { container } = render(<SetupSidebar view="scheduler" onNav={() => {}} />);
const active = container.querySelector(".rail-item.active");
expect(active).toBeTruthy();
expect(active?.getAttribute("title")).toBe("Scheduler Builder");
});
it("fires onNav with the right view id when clicked", () => {
const onNav = vi.fn();
render(<SetupSidebar view="compose" onNav={onNav} />);
fireEvent.click(screen.getByTitle("Config Builder"));
expect(onNav).toHaveBeenCalledWith("config");
});
it("toggles pinned state via the rail-brand click", () => {
const { container } = render(<SetupSidebar view="compose" onNav={() => {}} />);
const sidebar = container.querySelector(".setup-sidebar");
expect(sidebar?.className).not.toContain("pinned");
const brand = container.querySelector(".rail-brand") as HTMLElement;
fireEvent.click(brand);
expect(sidebar?.className).toContain("pinned");
fireEvent.click(brand);
expect(sidebar?.className).not.toContain("pinned");
});
});
@@ -0,0 +1,235 @@
import { useMemo, useState } from "react";
import { Check, Copy, CheckCircle2, AlertCircle } from "lucide-react";
import type {
NetworkConfig,
ServiceConfig,
VolumeConfig,
} from "../../types/compose";
import type { VPNConfig } from "../../types/vpn-configs";
import {
convertToDockerRun,
convertToSystemd,
generateKomodoToml,
generateEnvFile,
} from "../../utils/converters";
import { copyToClipboard } from "../../utils/clipboard";
type FormatId = "compose" | "docker-run" | "systemd" | "env" | "komodo";
interface FormatTab {
id: FormatId;
label: string;
filename: string;
}
const FORMATS: FormatTab[] = [
{ id: "compose", label: "compose.yml", filename: "compose.yml" },
{ id: "docker-run", label: "docker run", filename: "run.sh" },
{ id: "systemd", label: "systemd", filename: "container.service" },
{ id: "env", label: ".env", filename: ".env" },
{ id: "komodo", label: "komodo.toml", filename: "komodo.toml" },
];
export interface CodePanelProps {
yaml: string;
services: ServiceConfig[];
networks: NetworkConfig[];
volumes: VolumeConfig[];
vpn: VPNConfig;
validationError: string | null;
validationSuccess: boolean;
onValidate: () => void;
}
export function CodePanel({
yaml,
services,
vpn,
validationError,
validationSuccess: _validationSuccess,
onValidate,
}: CodePanelProps) {
const [format, setFormat] = useState<FormatId>("compose");
const [copied, setCopied] = useState(false);
const output = useMemo(() => {
switch (format) {
case "compose":
return yaml;
case "docker-run":
return services.map((s) => convertToDockerRun(s)).join("\n\n");
case "systemd":
return services.map((s) => convertToSystemd(s)).join("\n\n");
case "env":
return generateEnvFile(services, vpn);
case "komodo":
return generateKomodoToml(yaml);
default:
return "";
}
}, [format, yaml, services, vpn]);
const filename =
FORMATS.find((f) => f.id === format)?.filename || "compose.yml";
const lineCount = useMemo(
() => (output ? output.split("\n").length : 0),
[output],
);
const handleCopy = async () => {
try {
await copyToClipboard(output);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// ignore — browser may have denied clipboard access
}
};
const statusKind: "ok" | "err" = validationError ? "err" : "ok";
return (
<section className="code-col">
<div className="code-head">
<h2 className="code-title">
<span className="col-bar" aria-hidden />
Output
</h2>
<button
type="button"
className="btn btn-secondary code-validate"
onClick={onValidate}
title="Validate & reformat"
>
{validationError ? (
<>
<AlertCircle size={14} />
Re-validate
</>
) : (
<>
<CheckCircle2 size={14} />
Validate
</>
)}
</button>
</div>
<div className="code-tabs" role="tablist">
{FORMATS.map((f) => (
<button
key={f.id}
type="button"
role="tab"
aria-selected={format === f.id}
className={"code-tab" + (format === f.id ? " active" : "")}
onClick={() => setFormat(f.id)}
>
{f.label}
</button>
))}
</div>
<div className="code-window">
<button
type="button"
className="code-copy"
onClick={handleCopy}
aria-label="Copy to clipboard"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? "Copied" : "Copy"}
</button>
<CodeView text={output} />
<div className="code-statusbar">
<span
className={"status-tag" + (validationError ? " err" : "")}
aria-live="polite"
>
<span className={"status-dot " + statusKind} aria-hidden />
{validationError ? validationError : "Valid"}
</span>
<span className="status-mono">
{lineCount} {lineCount === 1 ? "line" : "lines"} · UTF-8 · LF
</span>
<span className="status-mono right">{filename}</span>
</div>
</div>
</section>
);
}
/**
* Lightweight syntax-aware preview. Uses simple regex to highlight YAML keys
* and values without pulling in CodeMirror (which is reserved for the
* editable surfaces — TemplateDetailModal and the convert dialog).
*/
function CodeView({ text }: { text: string }) {
const lines = useMemo(() => text.split("\n"), [text]);
return (
<pre className="code-pre">
{lines.map((line, idx) => {
const tokens = tokenize(line);
return (
<div className="code-line" key={idx}>
<span className="ln-no">{idx + 1}</span>
<span>
{tokens.map((tok, j) => (
<span key={j} className={tok.cls}>
{tok.text}
</span>
))}
</span>
</div>
);
})}
</pre>
);
}
interface Token {
text: string;
cls: string;
}
function tokenize(line: string): Token[] {
if (!line) return [{ text: " ", cls: "tk-val" }];
// Comment line
const commentMatch = line.match(/^(\s*)(#.*)$/);
if (commentMatch) {
return [
{ text: commentMatch[1], cls: "tk-val" },
{ text: commentMatch[2], cls: "tk-cmt" },
];
}
// Key: value
const kv = line.match(/^(\s*)([a-zA-Z0-9_.-]+)(:)(.*)$/);
if (kv) {
return [
{ text: kv[1], cls: "tk-val" },
{ text: kv[2], cls: "tk-key" },
{ text: kv[3], cls: "tk-punct" },
{ text: kv[4], cls: classifyValue(kv[4]) },
];
}
// List item
const list = line.match(/^(\s*)(-)(\s+)(.*)$/);
if (list) {
return [
{ text: list[1], cls: "tk-val" },
{ text: list[2], cls: "tk-punct" },
{ text: list[3], cls: "tk-val" },
{ text: list[4], cls: classifyValue(list[4]) },
];
}
return [{ text: line, cls: "tk-val" }];
}
function classifyValue(s: string): string {
const t = s.trim();
if (!t) return "tk-val";
if (/^[0-9]+$/.test(t)) return "tk-num";
if (/^".*"$/.test(t) || /^'.*'$/.test(t)) return "tk-str";
return "tk-val";
}
@@ -0,0 +1,87 @@
import { Plus, Trash2 } from "lucide-react";
import type { NetworkConfig } from "../../../types/compose";
interface NetworksTabProps {
networks: NetworkConfig[];
selectedIdx: number | null;
selectedType: "service" | "network" | "volume";
onSelectNetwork: (idx: number) => void;
onAddNetwork: () => void;
onRemoveNetwork: (idx: number) => void;
}
export function NetworksTab({
networks,
selectedIdx,
selectedType,
onSelectNetwork,
onAddNetwork,
onRemoveNetwork,
}: NetworksTabProps) {
return (
<div className="tab-content">
<div className="tab-actions">
<button
type="button"
className="btn btn-primary btn-block"
onClick={onAddNetwork}
>
<Plus size={14} />
Add Network
</button>
</div>
{networks.length === 0 ? (
<div className="tab-empty">
<h3 className="tab-empty-title">No networks</h3>
<p className="tab-empty-sub">
Top-level networks let multiple services share a bridge. Defaults are
usually fine.
</p>
</div>
) : (
<div className="service-list">
{networks.map((n, idx) => {
const isActive =
selectedType === "network" && selectedIdx === idx;
return (
<div
key={idx}
className={"service-row" + (isActive ? " active" : "")}
onClick={() => onSelectNetwork(idx)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelectNetwork(idx);
}}
>
<span className="status-dot ok" aria-hidden />
<span className="service-row-text">
<span className="service-row-name">
{n.name?.trim() ? n.name : <em>(unnamed network)</em>}
</span>
<span className="service-row-meta">
{n.driver?.trim() || "default driver"}
{n.external ? " · external" : ""}
{n.internal ? " · internal" : ""}
</span>
</span>
<button
type="button"
className="icon-trash"
onClick={(e) => {
e.stopPropagation();
onRemoveNetwork(idx);
}}
aria-label={`Remove network ${n.name || idx + 1}`}
title="Remove network"
>
<Trash2 size={14} />
</button>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,114 @@
import { ChevronRight, Plus, Trash2 } from "lucide-react";
import type { ServiceConfig } from "../../../types/compose";
interface ServicesTabProps {
services: ServiceConfig[];
selectedIdx: number | null;
selectedType: "service" | "network" | "volume";
onSelectService: (idx: number) => void;
onAddService: () => void;
onBrowseTemplates: () => void;
onRemoveService: (idx: number) => void;
}
export function ServicesTab({
services,
selectedIdx,
selectedType,
onSelectService,
onAddService,
onBrowseTemplates,
onRemoveService,
}: ServicesTabProps) {
return (
<div className="tab-content">
<div className="tab-actions">
<button
type="button"
className="btn btn-primary btn-block"
onClick={onAddService}
>
<Plus size={14} />
Add Service
</button>
<button
type="button"
className="btn btn-secondary btn-block"
onClick={onBrowseTemplates}
>
Browse Templates
</button>
</div>
{services.length === 0 ? (
<div className="tab-empty">
<h3 className="tab-empty-title">No services yet</h3>
<p className="tab-empty-sub">
Click "Add Service" to start, or import from a template.
</p>
</div>
) : (
<div className="service-list">
{services.map((svc, idx) => {
const isActive =
selectedType === "service" && selectedIdx === idx;
const displayName = svc.name?.trim() || "";
const imageName = svc.image?.trim() || "";
const meta = imageName || "no image specified";
// Unnamed-and-imageless rows are the placeholder slot; the user
// can't usefully delete it (we'd just regenerate one). Hide the
// trash icon to keep that affordance honest.
const showTrash = Boolean(displayName || imageName);
return (
<div
key={idx}
className={"service-row" + (isActive ? " active" : "")}
onClick={() => onSelectService(idx)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelectService(idx);
}}
>
<span
className={
"status-dot " +
(svc.name && svc.image ? "ok" : "warn")
}
aria-hidden
/>
<span className="service-row-text">
<span className="service-row-name">
{displayName ? (
displayName
) : (
<em>(unnamed service)</em>
)}
</span>
<span className="service-row-meta">{meta}</span>
</span>
<div className="flex items-center gap-2">
{showTrash && (
<button
type="button"
className="icon-trash"
onClick={(e) => {
e.stopPropagation();
onRemoveService(idx);
}}
aria-label={`Remove service ${displayName || idx + 1}`}
title="Remove service"
>
<Trash2 size={14} />
</button>
)}
<ChevronRight size={16} className="service-row-chev" />
</div>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,86 @@
import { Plus, Trash2 } from "lucide-react";
import type { VolumeConfig } from "../../../types/compose";
interface VolumesTabProps {
volumes: VolumeConfig[];
selectedIdx: number | null;
selectedType: "service" | "network" | "volume";
onSelectVolume: (idx: number) => void;
onAddVolume: () => void;
onRemoveVolume: (idx: number) => void;
}
export function VolumesTab({
volumes,
selectedIdx,
selectedType,
onSelectVolume,
onAddVolume,
onRemoveVolume,
}: VolumesTabProps) {
return (
<div className="tab-content">
<div className="tab-actions">
<button
type="button"
className="btn btn-primary btn-block"
onClick={onAddVolume}
>
<Plus size={14} />
Add Volume
</button>
</div>
{volumes.length === 0 ? (
<div className="tab-empty">
<h3 className="tab-empty-title">No named volumes</h3>
<p className="tab-empty-sub">
Named volumes survive container restarts and are shared across
services.
</p>
</div>
) : (
<div className="service-list">
{volumes.map((v, idx) => {
const isActive =
selectedType === "volume" && selectedIdx === idx;
return (
<div
key={idx}
className={"service-row" + (isActive ? " active" : "")}
onClick={() => onSelectVolume(idx)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelectVolume(idx);
}}
>
<span className="status-dot ok" aria-hidden />
<span className="service-row-text">
<span className="service-row-name">
{v.name?.trim() ? v.name : <em>(unnamed volume)</em>}
</span>
<span className="service-row-meta">
{v.driver?.trim() || "local"}
{v.external ? " · external" : ""}
</span>
</span>
<button
type="button"
className="icon-trash"
onClick={(e) => {
e.stopPropagation();
onRemoveVolume(idx);
}}
aria-label={`Remove volume ${v.name || idx + 1}`}
title="Remove volume"
>
<Trash2 size={14} />
</button>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,145 @@
import { useState } from "react";
import type { NetworkConfig, ServiceConfig, VolumeConfig } from "../../../types/compose";
import type { VPNConfig } from "../../../types/vpn-configs";
import { ServicesTab } from "./ServicesTab";
import { NetworksTab } from "./NetworksTab";
import { VolumesTab } from "./VolumesTab";
import { VpnTab, type VpnTabProps } from "../VpnTab";
type TabId = "services" | "networks" | "volumes" | "vpn";
export interface ConfigColumnProps {
services: ServiceConfig[];
networks: NetworkConfig[];
volumes: VolumeConfig[];
vpn: VPNConfig;
selectedIdx: number | null;
selectedType: "service" | "network" | "volume";
onSelectService: (idx: number) => void;
onAddService: () => void;
onBrowseTemplates: () => void;
onRemoveService: (idx: number) => void;
onSelectNetwork: (idx: number) => void;
onAddNetwork: () => void;
onRemoveNetwork: (idx: number) => void;
onSelectVolume: (idx: number) => void;
onAddVolume: () => void;
onRemoveVolume: (idx: number) => void;
vpnApi: Omit<VpnTabProps, "vpn" | "services">;
}
export function ConfigColumn(props: ConfigColumnProps) {
const [tab, setTab] = useState<TabId>("services");
const counts: Record<TabId, number> = {
services: props.services.length,
networks: props.networks.length,
volumes: props.volumes.length,
vpn: props.vpn.enabled && props.vpn.type ? 1 : 0,
};
return (
<section className="config-col">
<div className="col-head">
<h2 className="col-head-title">
<span className="col-bar" aria-hidden />
Configuration
</h2>
</div>
<div className="config-tabs">
<TabButton
id="services"
label="Services"
count={counts.services}
active={tab}
onClick={setTab}
/>
<TabButton
id="networks"
label="Networks"
count={counts.networks}
active={tab}
onClick={setTab}
/>
<TabButton
id="volumes"
label="Volumes"
count={counts.volumes}
active={tab}
onClick={setTab}
/>
<TabButton id="vpn" label="VPN" active={tab} onClick={setTab} />
</div>
<div className="config-tab-body">
{tab === "services" && (
<ServicesTab
services={props.services}
selectedIdx={props.selectedIdx}
selectedType={props.selectedType}
onSelectService={props.onSelectService}
onAddService={props.onAddService}
onBrowseTemplates={props.onBrowseTemplates}
onRemoveService={props.onRemoveService}
/>
)}
{tab === "networks" && (
<NetworksTab
networks={props.networks}
selectedIdx={props.selectedIdx}
selectedType={props.selectedType}
onSelectNetwork={props.onSelectNetwork}
onAddNetwork={props.onAddNetwork}
onRemoveNetwork={props.onRemoveNetwork}
/>
)}
{tab === "volumes" && (
<VolumesTab
volumes={props.volumes}
selectedIdx={props.selectedIdx}
selectedType={props.selectedType}
onSelectVolume={props.onSelectVolume}
onAddVolume={props.onAddVolume}
onRemoveVolume={props.onRemoveVolume}
/>
)}
{tab === "vpn" && (
<VpnTab vpn={props.vpn} services={props.services} {...props.vpnApi} />
)}
</div>
</section>
);
}
function TabButton({
id,
label,
count,
active,
onClick,
}: {
id: TabId;
label: string;
count?: number;
active: TabId;
onClick: (next: TabId) => void;
}) {
return (
<button
type="button"
className={"config-tab" + (active === id ? " active" : "")}
onClick={() => onClick(id)}
>
{label}
{typeof count === "number" && count > 0 && (
<span className="tab-count">{count}</span>
)}
</button>
);
}
+32 -25
View File
@@ -1,7 +1,14 @@
import { Label } from "../../components/ui/label";
import { Input } from "../../components/ui/input";
import { Toggle } from "../../components/ui/toggle";
import { ValidatedShadField } from "../../components/ui/validated-field";
import type { NetworkConfig } from "../../types/compose";
import {
validateIpAddress,
validateIpv4Cidr,
validateNetworkDriver,
validateNetworkName,
} from "../../utils/validation";
interface NetworkFormProps {
network: NetworkConfig;
@@ -19,26 +26,22 @@ export function NetworkForm({ network, onUpdate }: NetworkFormProps) {
{/* 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>
<ValidatedShadField
label="Network Name"
value={network.name || ""}
onChange={(v) => onUpdate("name", v)}
validate={validateNetworkName}
placeholder="e.g. frontend-network"
/>
<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>
<ValidatedShadField
label="Driver"
value={network.driver || ""}
onChange={(v) => onUpdate("driver", v)}
validate={validateNetworkDriver}
hint="Common: bridge (default), overlay, host, none"
placeholder="e.g. bridge, overlay"
/>
</div>
{/* Advanced Options */}
@@ -128,23 +131,27 @@ export function NetworkForm({ network, onUpdate }: NetworkFormProps) {
{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
<ValidatedShadField
label=""
value={cfg.subnet || ""}
onChange={(e) => {
onChange={(v) => {
const newConfig = [...(network.ipam?.config || [])];
newConfig[idx] = { ...newConfig[idx], subnet: e.target.value };
newConfig[idx] = { ...newConfig[idx], subnet: v };
onUpdate("ipam", { ...network.ipam, config: newConfig });
}}
validate={validateIpv4Cidr}
placeholder="Subnet (e.g. 192.168.1.0/24)"
className="shadow-sm text-sm"
/>
<Input
<ValidatedShadField
label=""
value={cfg.gateway || ""}
onChange={(e) => {
onChange={(v) => {
const newConfig = [...(network.ipam?.config || [])];
newConfig[idx] = { ...newConfig[idx], gateway: e.target.value };
newConfig[idx] = { ...newConfig[idx], gateway: v };
onUpdate("ipam", { ...network.ipam, config: newConfig });
}}
validate={validateIpAddress}
placeholder="Gateway (e.g. 192.168.1.1)"
className="shadow-sm text-sm"
/>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
import {
validateContainerName,
validateImage,
validateServiceName,
validateUser,
} from "../../../utils/validation";
import { useServiceFormApi } from "../service-form-api";
import { Field, ValidatedInput } from "./Field";
export function BasicsTab() {
const { service, restartOptions, updateServiceField } = useServiceFormApi();
return (
<div className="svc-tab">
<ValidatedInput
label="Service Name"
required
value={service.name}
onChange={(v) => updateServiceField("name", v)}
validate={validateServiceName}
placeholder="e.g. app, database, proxy"
/>
<ValidatedInput
label="Image"
required
value={service.image}
onChange={(v) => updateServiceField("image", v)}
validate={(v) => (v ? validateImage(v) : "Image is required")}
placeholder="e.g. nginx:latest, mysql:8.0"
/>
<ValidatedInput
label="Container Name"
hint="Optional — auto-generated if empty"
value={service.container_name || ""}
onChange={(v) => updateServiceField("container_name", v)}
validate={validateContainerName}
placeholder="my-app-container"
/>
<div className="svc-grid-2">
<Field label="Command">
<input
className="input"
value={service.command}
onChange={(e) => updateServiceField("command", e.target.value)}
placeholder="npm start"
/>
</Field>
<Field label="Restart Policy">
<div className="select">
<select
value={service.restart}
onChange={(e) => updateServiceField("restart", e.target.value)}
>
{restartOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<span className="select-chev" aria-hidden>
▾
</span>
</div>
</Field>
</div>
<Field label="Entrypoint" hint="Overrides the image's default entrypoint">
<input
className="input"
value={service.entrypoint || ""}
onChange={(e) => updateServiceField("entrypoint", e.target.value)}
placeholder='e.g. ["sh", "-c"]'
/>
</Field>
<div className="svc-grid-2">
<Field label="Working Directory">
<input
className="input"
value={service.working_dir || ""}
onChange={(e) =>
updateServiceField("working_dir", e.target.value)
}
placeholder="/app"
/>
</Field>
<ValidatedInput
label="User"
value={service.user || ""}
onChange={(v) => updateServiceField("user", v)}
validate={validateUser}
placeholder="1000:1000"
/>
</div>
</div>
);
}
@@ -0,0 +1,229 @@
import { validateEnvVarKey } from "../../../utils/validation";
import { useServiceFormApi } from "../service-form-api";
import { Field, SectionHead } from "./Field";
export function EnvironmentTab() {
const {
service,
updateServiceField,
updateListField,
addListField,
removeListField,
updateVolumeField,
addVolumeField,
removeVolumeField,
} = useServiceFormApi();
return (
<div className="svc-tab">
{/* Environment variables */}
<div>
<SectionHead
title="Environment variables"
sub="Key=value pairs injected at runtime. Use ${VAR} to reference env-file values."
action={
<button
type="button"
className="add-pill"
onClick={() => addListField("environment")}
>
+ Add var
</button>
}
/>
{service.environment.length === 0 ? (
<div className="empty-row">No environment variables</div>
) : (
<div className="env-list">
{service.environment.map((env, idx) => {
const keyErr = validateEnvVarKey(env.key);
return (
<div className="env-row" key={idx}>
<input
className="input"
value={env.key}
onChange={(e) =>
updateListField("environment", idx, {
...env,
key: e.target.value,
})
}
placeholder="KEY"
aria-label={`Env key ${idx + 1}`}
aria-invalid={keyErr ? true : undefined}
title={keyErr ?? undefined}
/>
<span className="eq" aria-hidden>
=
</span>
<input
className="input"
value={env.value}
onChange={(e) =>
updateListField("environment", idx, {
...env,
value: e.target.value,
})
}
placeholder="value or ${VAR}"
aria-label={`Env value ${idx + 1}`}
/>
<button
type="button"
className="icon-trash"
onClick={() => removeListField("environment", idx)}
aria-label={`Remove env var ${idx + 1}`}
title="Remove"
>
×
</button>
</div>
);
})}
</div>
)}
</div>
<Field label="env_file" hint="Comma-separated list of .env files">
<input
className="input"
value={service.env_file || ""}
onChange={(e) => updateServiceField("env_file", e.target.value)}
placeholder=".env, .env.production"
/>
</Field>
{/* Volumes */}
<div>
<SectionHead
title="Volumes"
sub="Bind paths and named volumes mounted into the container."
action={
<button
type="button"
className="add-pill"
onClick={addVolumeField}
>
+ Add volume
</button>
}
/>
{service.volumes.length === 0 ? (
<div className="empty-row">No volumes — click "Add volume"</div>
) : (
<div className="port-list">
<div className="port-head">
<span>Source / host</span>
<span aria-hidden />
<span>Target / container</span>
<span>RO</span>
<span aria-hidden />
</div>
{service.volumes.map((v, idx) => (
<div className="port-row" key={idx}>
<input
className="input"
value={v.host}
onChange={(e) =>
updateVolumeField(idx, "host", e.target.value)
}
placeholder="./data or named-volume"
/>
<span className="port-arrow" aria-hidden>
→
</span>
<input
className="input"
value={v.container}
onChange={(e) =>
updateVolumeField(idx, "container", e.target.value)
}
placeholder="/var/data"
/>
<label className="check-row" title="Read-only">
<input
type="checkbox"
checked={!!v.read_only}
onChange={(e) =>
updateVolumeField(idx, "read_only", e.target.checked)
}
/>
</label>
<button
type="button"
className="icon-trash"
onClick={() => removeVolumeField(idx)}
aria-label={`Remove volume ${idx + 1}`}
title="Remove"
>
×
</button>
</div>
))}
</div>
)}
</div>
{/* Labels */}
<div>
<SectionHead
title="Labels"
sub="Compose / orchestrator metadata. Common: traefik.*, com.docker.*"
action={
<button
type="button"
className="add-pill"
onClick={() => addListField("labels")}
>
+ Add label
</button>
}
/>
{(service.labels || []).length === 0 ? (
<div className="empty-row">No labels</div>
) : (
<div className="env-list">
{(service.labels || []).map((label, idx) => (
<div className="env-row" key={idx}>
<input
className="input"
value={label.key}
onChange={(e) =>
updateListField("labels", idx, {
...label,
key: e.target.value,
})
}
placeholder="traefik.enable"
/>
<span className="eq" aria-hidden>
=
</span>
<input
className="input"
value={label.value}
onChange={(e) =>
updateListField("labels", idx, {
...label,
value: e.target.value,
})
}
placeholder="true"
/>
<button
type="button"
className="icon-trash"
onClick={() => removeListField("labels", idx)}
aria-label={`Remove label ${idx + 1}`}
title="Remove"
>
×
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,89 @@
import { useState, type InputHTMLAttributes, type ReactNode } from "react";
interface FieldProps {
label: string;
hint?: string;
children: ReactNode;
required?: boolean;
error?: string | null;
}
export function Field({ label, hint, children, required, error }: FieldProps) {
return (
<div className={"svc-field" + (error ? " svc-field--error" : "")}>
<label className="field-label">
{required && <span className="bar" aria-hidden />}
{label}
</label>
{children}
{error ? (
<p className="field-error" role="alert">
{error}
</p>
) : (
hint && <p className="field-hint">{hint}</p>
)}
</div>
);
}
interface ValidatedInputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "onChange"> {
value: string;
onChange: (next: string) => void;
validate?: (value: string) => string | null;
label: string;
hint?: string;
required?: boolean;
}
/**
* Field + input wrapper that runs a validator on blur (and clears on re-edit
* once the user has typed something correct). Errors surface inline via the
* Field's `error` prop and as `aria-invalid` on the input.
*/
export function ValidatedInput({
value,
onChange,
validate,
label,
hint,
required,
...inputProps
}: ValidatedInputProps) {
const [touched, setTouched] = useState(false);
const error = touched && validate ? validate(value) : null;
return (
<Field label={label} hint={hint} required={required} error={error}>
<input
{...inputProps}
className={inputProps.className ?? "input"}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={(e) => {
setTouched(true);
inputProps.onBlur?.(e);
}}
aria-invalid={error ? true : undefined}
/>
</Field>
);
}
interface SectionHeadProps {
title: string;
sub?: string;
action?: ReactNode;
}
export function SectionHead({ title, sub, action }: SectionHeadProps) {
return (
<div className="svc-section-head">
<div>
<h3 className="svc-section-title">{title}</h3>
{sub && <p className="svc-section-sub">{sub}</p>}
</div>
{action}
</div>
);
}
@@ -0,0 +1,207 @@
import {
validateCpuValue,
validateDuration,
validateMemoryValue,
validateStopSignal,
} from "../../../utils/validation";
import { useServiceFormApi } from "../service-form-api";
import { Field, SectionHead, ValidatedInput } from "./Field";
function validateRetries(value: string): string | null {
if (!value) return null;
if (!/^\d+$/.test(value)) return "Retries must be a non-negative integer";
return null;
}
export function HealthTab() {
const {
service,
updateServiceField,
updateHealthcheckField,
updateResourceField,
} = useServiceFormApi();
const hc = service.healthcheck || {
test: "",
interval: "",
timeout: "",
retries: "",
start_period: "",
start_interval: "",
};
const limits = service.deploy?.resources?.limits || {};
const reservations = service.deploy?.resources?.reservations || {};
return (
<div className="svc-tab">
{/* Healthcheck */}
<div>
<SectionHead
title="Healthcheck"
sub="Tells Docker how to verify the service is alive."
/>
<Field label="Test command" hint='e.g. CMD curl -f http://localhost:8080/health || exit 1'>
<input
className="input"
value={hc.test}
onChange={(e) => updateHealthcheckField("test", e.target.value)}
placeholder='CMD curl -f http://localhost:8080/health'
/>
</Field>
<div className="svc-grid-2">
<ValidatedInput
label="Interval"
value={hc.interval}
onChange={(v) => updateHealthcheckField("interval", v)}
validate={validateDuration}
placeholder="30s"
/>
<ValidatedInput
label="Timeout"
value={hc.timeout}
onChange={(v) => updateHealthcheckField("timeout", v)}
validate={validateDuration}
placeholder="5s"
/>
</div>
<div className="svc-grid-3">
<ValidatedInput
label="Retries"
value={hc.retries}
onChange={(v) => updateHealthcheckField("retries", v)}
validate={validateRetries}
placeholder="3"
/>
<ValidatedInput
label="Start period"
value={hc.start_period}
onChange={(v) => updateHealthcheckField("start_period", v)}
validate={validateDuration}
placeholder="10s"
/>
<ValidatedInput
label="Start interval"
value={hc.start_interval}
onChange={(v) => updateHealthcheckField("start_interval", v)}
validate={validateDuration}
placeholder="5s"
/>
</div>
</div>
{/* Resources */}
<div>
<SectionHead
title="Resources"
sub="deploy.resources — CPU and memory limits / reservations."
/>
<div className="svc-grid-2">
<ValidatedInput
label="CPU limit"
value={limits.cpus || ""}
onChange={(v) => updateResourceField("limits", "cpus", v)}
validate={validateCpuValue}
placeholder="1.0"
/>
<ValidatedInput
label="Memory limit"
value={limits.memory || ""}
onChange={(v) => updateResourceField("limits", "memory", v)}
validate={validateMemoryValue}
placeholder="512m"
/>
</div>
<div className="svc-grid-2">
<ValidatedInput
label="CPU reservation"
value={reservations.cpus || ""}
onChange={(v) => updateResourceField("reservations", "cpus", v)}
validate={validateCpuValue}
placeholder="0.25"
/>
<ValidatedInput
label="Memory reservation"
value={reservations.memory || ""}
onChange={(v) => updateResourceField("reservations", "memory", v)}
validate={validateMemoryValue}
placeholder="128m"
/>
</div>
</div>
{/* Lifecycle */}
<div>
<SectionHead
title="Lifecycle"
sub="How the container starts, stops, and behaves under load."
/>
<div className="svc-grid-2">
<ValidatedInput
label="Stop grace period"
value={service.stop_grace_period || ""}
onChange={(v) => updateServiceField("stop_grace_period", v)}
validate={validateDuration}
placeholder="10s"
/>
<ValidatedInput
label="Stop signal"
value={service.stop_signal || ""}
onChange={(v) => updateServiceField("stop_signal", v)}
validate={validateStopSignal}
placeholder="SIGTERM"
/>
</div>
<div className="svc-grid-3">
<Field label="Privileged">
<ToggleBoolean
value={service.privileged}
onChange={(v) => updateServiceField("privileged", v)}
label="Run privileged"
/>
</Field>
<Field label="Read-only">
<ToggleBoolean
value={service.read_only}
onChange={(v) => updateServiceField("read_only", v)}
label="Read-only filesystem"
/>
</Field>
<Field label="Init">
<ToggleBoolean
value={service.init}
onChange={(v) => updateServiceField("init", v)}
label="Run init"
/>
</Field>
</div>
</div>
</div>
);
}
function ToggleBoolean({
value,
onChange,
label,
}: {
value: boolean | undefined;
onChange: (v: boolean) => void;
label: string;
}) {
return (
<label className="switch" title={label}>
<input
type="checkbox"
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
aria-label={label}
/>
<span className="switch-track">
<span className="switch-thumb" />
</span>
</label>
);
}
@@ -0,0 +1,224 @@
import {
validateExtraHost,
validateHostname,
validateIpAddress,
validateNetworkMode,
validatePort,
} from "../../../utils/validation";
import { useServiceFormApi } from "../service-form-api";
import { Field, SectionHead, ValidatedInput } from "./Field";
const PROTOCOLS = [
{ value: "none", label: "—" },
{ value: "tcp", label: "tcp" },
{ value: "udp", label: "udp" },
];
function validateCsvList(
validator: (item: string) => string | null,
): (value: string) => string | null {
return (value: string) => {
if (!value) return null;
const items = value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
for (const item of items) {
const err = validator(item);
if (err) return `"${item}": ${err}`;
}
return null;
};
}
export function NetworkTab() {
const {
service,
updateServiceField,
updatePortField,
addPortField,
removePortField,
} = useServiceFormApi();
return (
<div className="svc-tab">
{/* Port mappings */}
<div>
<SectionHead
title="Port mappings"
sub="Expose container ports to the host. Empty rows are ignored."
action={
<button
type="button"
className="add-pill"
onClick={addPortField}
>
+ Add port
</button>
}
/>
{service.ports.length === 0 ? (
<div className="empty-row">No port mappings — click "Add port"</div>
) : (
<div className="port-list">
<div className="port-head">
<span>Host</span>
<span aria-hidden />
<span>Container</span>
<span>Protocol</span>
<span aria-hidden />
</div>
{service.ports.map((p, idx) => {
const hostErr = validatePort(p.host);
const containerErr = validatePort(p.container);
return (
<div className="port-row" key={idx}>
<input
className="input"
value={p.host}
onChange={(e) =>
updatePortField(idx, "host", e.target.value)
}
placeholder="8080"
aria-label={`Host port ${idx + 1}`}
aria-invalid={hostErr ? true : undefined}
title={hostErr ?? undefined}
/>
<span className="port-arrow" aria-hidden>
→
</span>
<input
className="input"
value={p.container}
onChange={(e) =>
updatePortField(idx, "container", e.target.value)
}
placeholder="80"
aria-label={`Container port ${idx + 1}`}
aria-invalid={containerErr ? true : undefined}
title={containerErr ?? undefined}
/>
<div className="select">
<select
value={p.protocol}
onChange={(e) =>
updatePortField(idx, "protocol", e.target.value)
}
>
{PROTOCOLS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<span className="select-chev" aria-hidden>
▾
</span>
</div>
<button
type="button"
className="icon-trash"
onClick={() => removePortField(idx)}
aria-label={`Remove port ${idx + 1}`}
title="Remove"
>
×
</button>
</div>
);
})}
</div>
)}
</div>
<ValidatedInput
label="Network mode"
hint="Leave empty for the default bridge"
value={service.network_mode || ""}
onChange={(v) => updateServiceField("network_mode", v)}
validate={validateNetworkMode}
placeholder="host, none, service:other"
/>
<Field
label="Networks"
hint="Comma-separated list of network names this service joins"
>
<input
className="input"
value={(service.networks || []).join(", ")}
onChange={(e) =>
updateServiceField(
"networks",
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="frontend, backend"
/>
</Field>
<div className="svc-grid-2">
<ValidatedInput
label="Hostname"
value={service.hostname || ""}
onChange={(v) => updateServiceField("hostname", v)}
validate={validateHostname}
placeholder="my-app"
/>
<ValidatedInput
label="DNS servers"
hint="Comma-separated"
value={(service.dns || []).join(", ")}
onChange={(v) =>
updateServiceField(
"dns",
v
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
validate={validateCsvList(validateIpAddress)}
placeholder="1.1.1.1, 8.8.8.8"
/>
</div>
<ValidatedInput
label="Expose"
hint="Internal-only ports (visible to linked services). Comma-separated."
value={(service.expose || []).join(", ")}
onChange={(v) =>
updateServiceField(
"expose",
v
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
validate={validateCsvList(validatePort)}
placeholder="3000, 8080"
/>
<ValidatedInput
label="Extra hosts"
hint="host:ip pairs, comma-separated"
value={(service.extra_hosts || []).join(", ")}
onChange={(v) =>
updateServiceField(
"extra_hosts",
v
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
validate={validateCsvList(validateExtraHost)}
placeholder="api.local:192.168.1.10"
/>
</div>
);
}
@@ -0,0 +1,64 @@
import { useState } from "react";
import { ServiceFormApiProvider, type ServiceFormApi } from "../service-form-api";
import { BasicsTab } from "./BasicsTab";
import { NetworkTab } from "./NetworkTab";
import { EnvironmentTab } from "./EnvironmentTab";
import { HealthTab } from "./HealthTab";
const TABS = ["Basics", "Network", "Environment", "Health"] as const;
type TabId = (typeof TABS)[number];
interface ServiceFormProps {
api: ServiceFormApi;
}
export function ServiceForm({ api }: ServiceFormProps) {
const [tab, setTab] = useState<TabId>("Basics");
const service = api.service;
const displayName = service.name?.trim()
? service.name
: "untitled";
const isUnnamed = !service.name?.trim();
return (
<ServiceFormApiProvider api={api}>
<section className="service-col">
<div className="col-head">
<h2 className="col-head-title">
<span className="col-bar" aria-hidden />
Service Configuration
</h2>
<span className="col-head-meta">
<span
className={
"status-dot " + (isUnnamed ? "warn" : "ok")
}
aria-hidden
/>
<span className="col-head-meta-text">{displayName}</span>
</span>
</div>
<div className="config-tabs svc-tabs">
{TABS.map((t) => (
<button
key={t}
type="button"
className={"config-tab" + (tab === t ? " active" : "")}
onClick={() => setTab(t)}
>
{t}
</button>
))}
</div>
<div className="config-tab-body">
{tab === "Basics" && <BasicsTab />}
{tab === "Network" && <NetworkTab />}
{tab === "Environment" && <EnvironmentTab />}
{tab === "Health" && <HealthTab />}
</div>
</section>
</ServiceFormApiProvider>
);
}
@@ -1,152 +0,0 @@
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>
);
}
+28 -21
View File
@@ -2,7 +2,13 @@ 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 { ValidatedShadField } from "../../components/ui/validated-field";
import type { VolumeConfig } from "../../types/compose";
import {
validateLabelKey,
validateVolumeDriver,
validateVolumeName,
} from "../../utils/validation";
interface VolumeFormProps {
volume: VolumeConfig;
@@ -20,26 +26,22 @@ export function VolumeForm({ volume, onUpdate }: VolumeFormProps) {
{/* 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>
<ValidatedShadField
label="Volume Name"
value={volume.name || ""}
onChange={(v) => onUpdate("name", v)}
validate={validateVolumeName}
placeholder="e.g. app-data"
/>
<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>
<ValidatedShadField
label="Driver"
value={volume.driver || ""}
onChange={(v) => onUpdate("driver", v)}
validate={validateVolumeDriver}
hint="Default: local"
placeholder="e.g. local"
/>
</div>
{/* Driver Options */}
@@ -101,7 +103,9 @@ export function VolumeForm({ volume, onUpdate }: VolumeFormProps) {
</div>
<div className="space-y-2">
{volume.labels && volume.labels.length > 0 ? (
volume.labels.map((label, idx) => (
volume.labels.map((label, idx) => {
const keyErr = validateLabelKey(label.key);
return (
<div key={idx} className="flex gap-2 items-center p-2 bg-muted/30 rounded-md">
<Input
value={label.key}
@@ -115,6 +119,8 @@ export function VolumeForm({ volume, onUpdate }: VolumeFormProps) {
}}
placeholder="Key"
className="flex-1 shadow-sm"
aria-invalid={keyErr ? true : undefined}
title={keyErr ?? undefined}
/>
<Input
value={label.value}
@@ -151,7 +157,8 @@ export function VolumeForm({ volume, onUpdate }: VolumeFormProps) {
</svg>
</Button>
</div>
))
);
})
) : (
<div className="text-xs text-muted-foreground text-center py-4">
No labels added
@@ -1,771 +0,0 @@
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" />
</>
);
}
@@ -0,0 +1,647 @@
import { useState } from "react";
import type { NewtLogLevel, VPNConfig } from "../../../types/vpn-configs";
import type { ServiceConfig } from "../../../types/compose";
import { ChevronDown, ChevronRight } from "lucide-react";
import { Field, ValidatedInput } from "../ServiceForm/Field";
import {
validateAbsoluteUnixPath,
validateDuration,
validateHostname,
validateIpAddress,
validateNetworkName,
validatePort,
validateSecret,
validateTailscaleAuthKey,
validateUrl,
validateZerotierId,
} from "../../../utils/validation";
interface CommonProps {
vpn: VPNConfig;
services: ServiceConfig[];
updateTailscale: (p: Partial<NonNullable<VPNConfig["tailscale"]>>) => void;
updateNewt: (p: Partial<NonNullable<VPNConfig["newt"]>>) => void;
updateCloudflared: (p: Partial<NonNullable<VPNConfig["cloudflared"]>>) => void;
updateWireguard: (p: Partial<NonNullable<VPNConfig["wireguard"]>>) => void;
updateZerotier: (p: Partial<NonNullable<VPNConfig["zerotier"]>>) => void;
updateNetbird: (p: Partial<NonNullable<VPNConfig["netbird"]>>) => void;
}
export function VpnFields(p: CommonProps) {
const { vpn } = p;
if (!vpn.enabled || !vpn.type) return null;
switch (vpn.type) {
case "wireguard":
return <WireguardFields {...p} />;
case "tailscale":
return <TailscaleFields {...p} />;
case "cloudflared":
return <CloudflaredFields {...p} />;
case "newt":
return <NewtFields {...p} />;
case "zerotier":
return <ZerotierFields {...p} />;
case "netbird":
return <NetbirdFields {...p} />;
default:
return null;
}
}
function Toggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (next: boolean) => void;
}) {
// The toggle field anchors the switch to the bottom of the cell so labels
// that wrap to a second line don't push the switch out of grid alignment.
return (
<div className="svc-field svc-field--toggle">
<span className="field-label">{label}</span>
<label className="switch">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
aria-label={label}
/>
<span className="switch-track">
<span className="switch-thumb" />
</span>
</label>
</div>
);
}
function WireguardFields({ vpn, updateWireguard }: CommonProps) {
const cfg = vpn.wireguard;
if (!cfg) return null;
return (
<div className="vpn-fields">
<div className="vpn-section-label">Connection</div>
<div className="vpn-field-row">
<ValidatedInput
label="Config path"
hint="Path inside the container to the wg conf file"
value={cfg.configPath}
onChange={(v) => updateWireguard({ configPath: v })}
validate={validateAbsoluteUnixPath}
placeholder="/etc/wireguard/wg0.conf"
/>
<ValidatedInput
label="Interface"
value={cfg.interfaceName}
onChange={(v) => updateWireguard({ interfaceName: v })}
validate={validateNetworkName}
placeholder="wg0"
/>
</div>
<div className="upload-card">
<span className="upload-icon">⬆</span>
<div className="upload-text">
Drop a <code>.conf</code> file into the mounted volume — it will be
read by the sidecar at startup.
</div>
</div>
</div>
);
}
function TailscaleFields({ vpn, services, updateTailscale }: CommonProps) {
const cfg = vpn.tailscale;
if (!cfg) return null;
return (
<div className="vpn-fields">
<div className="vpn-section-label">Connection</div>
<ValidatedInput
label="Auth key"
hint="Use ${TS_AUTHKEY} to read from .env. Treated as a secret."
className="input input-secret"
value={cfg.authKey}
onChange={(v) => updateTailscale({ authKey: v })}
validate={validateTailscaleAuthKey}
placeholder="tskey-..."
/>
<div className="vpn-field-row">
<ValidatedInput
label="Hostname"
value={cfg.hostname}
onChange={(v) => updateTailscale({ hostname: v })}
validate={validateHostname}
placeholder="edge"
/>
<ValidatedInput
label="Exit node"
value={cfg.exitNode}
onChange={(v) => updateTailscale({ exitNode: v })}
validate={validateIpAddress}
placeholder="100.x.y.z"
/>
</div>
<div className="vpn-section-label">Behaviour</div>
<div className="svc-grid-3">
<Toggle
label="Accept DNS"
checked={cfg.acceptDns}
onChange={(v) => updateTailscale({ acceptDns: v })}
/>
<Toggle
label="Auth once"
checked={cfg.authOnce}
onChange={(v) => updateTailscale({ authOnce: v })}
/>
<Toggle
label="Userspace networking"
checked={cfg.userspace}
onChange={(v) => updateTailscale({ userspace: v })}
/>
</div>
{cfg.exitNode && (
<Toggle
label="Exit node — allow LAN access"
checked={cfg.exitNodeAllowLan}
onChange={(v) => updateTailscale({ exitNodeAllowLan: v })}
/>
)}
<div className="vpn-section-label">Serve (HTTPS / TCP)</div>
<Toggle
label="Enable serve"
checked={cfg.enableServe}
onChange={(v) => updateTailscale({ enableServe: v })}
/>
{cfg.enableServe && (
<div className="vpn-serve-grid">
<Field label="Target service">
<div className="select">
<select
value={cfg.serveTargetService}
onChange={(e) =>
updateTailscale({ serveTargetService: e.target.value })
}
>
<option value="">Select service…</option>
{services.map((s) => (
<option key={s.name} value={s.name}>
{s.name}
</option>
))}
</select>
<span className="select-chev" aria-hidden>
▾
</span>
</div>
</Field>
<div className="svc-grid-2">
<ValidatedInput
label="External port"
value={cfg.serveExternalPort}
onChange={(v) => updateTailscale({ serveExternalPort: v })}
validate={(v) => (v ? validatePort(v) : null)}
placeholder="443"
/>
<ValidatedInput
label="Internal port"
value={cfg.serveInternalPort}
onChange={(v) => updateTailscale({ serveInternalPort: v })}
validate={(v) => (v ? validatePort(v) : null)}
placeholder="8080"
/>
</div>
<ValidatedInput
label="Path"
value={cfg.servePath}
onChange={(v) => updateTailscale({ servePath: v })}
placeholder="/"
/>
<div className="svc-grid-2">
<Field label="Protocol">
<div className="select">
<select
value={cfg.serveProtocol}
onChange={(e) =>
updateTailscale({
serveProtocol: e.target.value as "HTTP" | "HTTPS",
})
}
>
<option value="HTTPS">HTTPS</option>
<option value="HTTP">HTTP</option>
</select>
<span className="select-chev" aria-hidden>
▾
</span>
</div>
</Field>
<Field label="Inside protocol">
<div className="select">
<select
value={cfg.serveInsideProtocol}
onChange={(e) =>
updateTailscale({
serveInsideProtocol: e.target.value as
| "http"
| "https"
| "https+insecure",
})
}
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="https+insecure">HTTPS (insecure)</option>
</select>
<span className="select-chev" aria-hidden>
▾
</span>
</div>
</Field>
</div>
<ValidatedInput
label="Cert domain"
hint="Optional — leave empty to use the Tailscale-issued certificate"
value={cfg.certDomain}
onChange={(v) => updateTailscale({ certDomain: v })}
placeholder="${TS_CERT_DOMAIN}"
/>
</div>
)}
</div>
);
}
function CloudflaredFields({ vpn, updateCloudflared }: CommonProps) {
const cfg = vpn.cloudflared;
if (!cfg) return null;
return (
<div className="vpn-fields">
<div className="vpn-section-label">Connection</div>
<ValidatedInput
label="Tunnel token"
hint="From Cloudflare Zero Trust dashboard"
className="input input-secret"
value={cfg.tunnelToken}
onChange={(v) => updateCloudflared({ tunnelToken: v })}
validate={validateSecret}
placeholder="${TUNNEL_TOKEN}"
/>
<Toggle
label="No auto-update"
checked={cfg.noAutoupdate}
onChange={(v) => updateCloudflared({ noAutoupdate: v })}
/>
</div>
);
}
function NewtFields({ vpn, updateNewt }: CommonProps) {
const cfg = vpn.newt;
const [advancedOpen, setAdvancedOpen] = useState(false);
if (!cfg) return null;
const optionalPort = (v: string) => (v ? validatePort(v) : null);
const optionalDuration = (v: string) => (v ? validateDuration(v) : null);
const optionalAbsPath = (v: string) =>
v ? validateAbsoluteUnixPath(v) : null;
return (
<div className="vpn-fields">
<div className="vpn-section-label">Connection</div>
<ValidatedInput
label="Endpoint"
hint="Pangolin server websocket endpoint"
value={cfg.endpoint}
onChange={(v) => updateNewt({ endpoint: v })}
validate={validateUrl}
placeholder="https://app.pangolin.net"
/>
<div className="vpn-field-row">
<ValidatedInput
label="Newt ID"
className="input input-secret"
value={cfg.newtId}
onChange={(v) => updateNewt({ newtId: v })}
validate={validateSecret}
placeholder="${NEWT_ID}"
/>
<ValidatedInput
label="Newt secret"
className="input input-secret"
value={cfg.newtSecret}
onChange={(v) => updateNewt({ newtSecret: v })}
validate={validateSecret}
placeholder="${NEWT_SECRET}"
/>
</div>
<ValidatedInput
label="Network name"
hint="Top-level Docker network the sidecar attaches to"
value={cfg.networkName}
onChange={(v) => updateNewt({ networkName: v })}
validate={validateNetworkName}
placeholder="newt"
/>
<div className="vpn-section-label">Site</div>
<div className="vpn-field-row">
<ValidatedInput
label="Site name"
hint="Optional — Pangolin assigns one if blank. Supports {{env.VAR}}."
value={cfg.name}
onChange={(v) => updateNewt({ name: v })}
placeholder="my-edge-site"
/>
<ValidatedInput
label="Region"
hint="Resource attribute for telemetry"
value={cfg.region}
onChange={(v) => updateNewt({ region: v })}
placeholder="us-west-2"
/>
</div>
<ValidatedInput
label="Provisioning key"
hint="One-time exchange for site credentials. Persists into the config file."
className="input input-secret"
value={cfg.provisioningKey}
onChange={(v) => updateNewt({ provisioningKey: v })}
placeholder="${NEWT_PROVISIONING_KEY}"
/>
<div className="vpn-section-label">Networking</div>
<div className="svc-grid-3">
<ValidatedInput
label="Port"
hint="Static peer port (default: random)"
value={cfg.port}
onChange={(v) => updateNewt({ port: v })}
validate={optionalPort}
placeholder="34534"
/>
<ValidatedInput
label="MTU"
hint="Default 1280"
value={cfg.mtu}
onChange={(v) => updateNewt({ mtu: v })}
validate={(v) =>
!v ? null : /^\d+$/.test(v) && Number(v) >= 576 && Number(v) <= 9000
? null
: "MTU must be 576–9000"
}
placeholder="1280"
/>
<ValidatedInput
label="DNS"
hint="Endpoint resolver (default 9.9.9.9)"
value={cfg.dns}
onChange={(v) => updateNewt({ dns: v })}
validate={(v) => (v ? validateIpAddress(v) : null)}
placeholder="9.9.9.9"
/>
</div>
<div className="vpn-field-row">
<ValidatedInput
label="Interface name"
hint="Default newt"
value={cfg.interfaceName}
onChange={(v) => updateNewt({ interfaceName: v })}
validate={(v) => (v ? validateNetworkName(v) : null)}
placeholder="newt"
/>
<ValidatedInput
label="Prefer endpoint"
hint="Override the server-provided endpoint"
value={cfg.preferEndpoint}
onChange={(v) => updateNewt({ preferEndpoint: v })}
validate={(v) => (v ? validateUrl(v) : null)}
placeholder="https://preferred.example.com"
/>
</div>
<div className="vpn-section-label">Health & telemetry</div>
<div className="svc-grid-2">
<ValidatedInput
label="Ping interval"
hint="Default 3s"
value={cfg.pingInterval}
onChange={(v) => updateNewt({ pingInterval: v })}
validate={optionalDuration}
placeholder="3s"
/>
<ValidatedInput
label="Ping timeout"
hint="Default 5s"
value={cfg.pingTimeout}
onChange={(v) => updateNewt({ pingTimeout: v })}
validate={optionalDuration}
placeholder="5s"
/>
</div>
<div className="vpn-field-row">
<ValidatedInput
label="Health file"
hint="Created when connection ok; removed otherwise"
value={cfg.healthFile}
onChange={(v) => updateNewt({ healthFile: v })}
validate={optionalAbsPath}
placeholder="/tmp/healthy"
/>
<Field label="Log level">
<div className="select">
<select
value={cfg.logLevel}
onChange={(e) =>
updateNewt({ logLevel: e.target.value as NewtLogLevel })
}
>
{(["DEBUG", "INFO", "WARN", "ERROR", "FATAL"] as const).map(
(l) => (
<option key={l} value={l}>
{l}
</option>
),
)}
</select>
<span className="select-chev" aria-hidden>
▾
</span>
</div>
</Field>
</div>
<div className="svc-grid-3">
<Toggle
label="Metrics"
checked={cfg.metrics}
onChange={(v) => updateNewt({ metrics: v })}
/>
<Toggle
label="OTLP exporter"
checked={cfg.otlp}
onChange={(v) => updateNewt({ otlp: v })}
/>
<Toggle
label="Enforce HC cert"
checked={cfg.enforceHcCert}
onChange={(v) => updateNewt({ enforceHcCert: v })}
/>
</div>
<ValidatedInput
label="Metrics admin address"
hint="Default 127.0.0.1:2112"
value={cfg.metricsAdminAddr}
onChange={(v) => updateNewt({ metricsAdminAddr: v })}
placeholder="127.0.0.1:2112"
/>
<button
type="button"
className="vpn-advanced-toggle"
onClick={() => setAdvancedOpen((v) => !v)}
>
{advancedOpen ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
Advanced (provisioning, blueprints, mTLS, Docker integration)
</button>
{advancedOpen && (
<div className="vpn-advanced">
<div className="vpn-field-row">
<ValidatedInput
label="Config file"
value={cfg.configFile}
onChange={(v) => updateNewt({ configFile: v })}
placeholder="/var/newt.json"
/>
<ValidatedInput
label="Blueprint file"
hint="Declarative — keeps reapplying"
value={cfg.blueprintFile}
onChange={(v) => updateNewt({ blueprintFile: v })}
placeholder="/path/to/blueprint.yaml"
/>
</div>
<ValidatedInput
label="Provisioning blueprint file"
hint="One-time imperative bootstrap"
value={cfg.provisioningBlueprintFile}
onChange={(v) => updateNewt({ provisioningBlueprintFile: v })}
placeholder="/path/to/bootstrap.yaml"
/>
<div className="vpn-field-row">
<ValidatedInput
label="Docker socket"
value={cfg.dockerSocket}
onChange={(v) => updateNewt({ dockerSocket: v })}
placeholder="/var/run/docker.sock"
/>
<ValidatedInput
label="Updown script"
value={cfg.updown}
onChange={(v) => updateNewt({ updown: v })}
placeholder="/path/to/updown.sh"
/>
</div>
<div className="svc-grid-3">
<Toggle
label="Docker network validation"
checked={cfg.dockerEnforceNetworkValidation}
onChange={(v) =>
updateNewt({ dockerEnforceNetworkValidation: v })
}
/>
<Toggle
label="No cloud failover"
checked={cfg.noCloud}
onChange={(v) => updateNewt({ noCloud: v })}
/>
<Toggle
label="Disable clients"
checked={cfg.disableClients}
onChange={(v) => updateNewt({ disableClients: v })}
/>
</div>
<div className="vpn-field-row">
<ValidatedInput
label="mTLS client cert"
value={cfg.tlsClientCertFile}
onChange={(v) => updateNewt({ tlsClientCertFile: v })}
placeholder="/path/to/client.crt"
/>
<ValidatedInput
label="mTLS client key"
value={cfg.tlsClientKey}
onChange={(v) => updateNewt({ tlsClientKey: v })}
placeholder="/path/to/client.key"
/>
</div>
<ValidatedInput
label="mTLS CA"
value={cfg.tlsClientCa}
onChange={(v) => updateNewt({ tlsClientCa: v })}
placeholder="/path/to/ca.crt"
/>
</div>
)}
</div>
);
}
function ZerotierFields({ vpn, updateZerotier }: CommonProps) {
const cfg = vpn.zerotier;
if (!cfg) return null;
return (
<div className="vpn-fields">
<div className="vpn-section-label">Connection</div>
<Field label="Network ID">
<input
className="input input-secret"
value={cfg.networkId}
onChange={(e) => updateZerotier({ networkId: e.target.value })}
placeholder="${ZT_NETWORK_ID}"
aria-invalid={
cfg.networkId && validateZerotierId(cfg.networkId)
? true
: undefined
}
title={validateZerotierId(cfg.networkId) ?? undefined}
/>
</Field>
<ValidatedInput
label="Identity path"
value={cfg.identityPath}
onChange={(v) => updateZerotier({ identityPath: v })}
validate={validateAbsoluteUnixPath}
placeholder="/var/lib/zerotier-one"
/>
</div>
);
}
function NetbirdFields({ vpn, updateNetbird }: CommonProps) {
const cfg = vpn.netbird;
if (!cfg) return null;
return (
<div className="vpn-fields">
<div className="vpn-section-label">Connection</div>
<ValidatedInput
label="Setup key"
className="input input-secret"
value={cfg.setupKey}
onChange={(v) => updateNetbird({ setupKey: v })}
validate={validateSecret}
placeholder="${NB_SETUP_KEY}"
/>
<ValidatedInput
label="Management URL"
value={cfg.managementUrl}
onChange={(v) => updateNetbird({ managementUrl: v })}
validate={validateUrl}
placeholder="https://api.netbird.io"
/>
</div>
);
}
@@ -0,0 +1,134 @@
import { useState } from "react";
import type { VPNConfig } from "../../../types/vpn-configs";
const ICON_BASE = "https://cdn.jsdelivr.net/gh/selfhst/icons/png";
export interface ProviderTile {
id: NonNullable<VPNConfig["type"]> | "none";
name: string;
blurb: string;
monogram: string;
// CSS variable name (no hex literals in component code).
tint: string;
iconUrl?: string;
}
export const PROVIDER_TILES: ProviderTile[] = [
{
id: "wireguard",
name: "WireGuard",
blurb: "Modern, fast, kernel-native.",
monogram: "W",
tint: "var(--coral)",
iconUrl: `${ICON_BASE}/wireguard.png`,
},
{
id: "tailscale",
name: "Tailscale",
blurb: "Mesh VPN, zero-config.",
monogram: "T",
tint: "var(--slate)",
iconUrl: `${ICON_BASE}/tailscale.png`,
},
{
id: "cloudflared",
name: "Cloudflared",
blurb: "Cloudflare tunnel sidecar.",
monogram: "C",
tint: "var(--amber)",
iconUrl: `${ICON_BASE}/cloudflare.png`,
},
{
id: "newt",
name: "Newt",
blurb: "Pangolin reverse proxy tunnel.",
monogram: "N",
tint: "var(--violet)",
iconUrl: `${ICON_BASE}/pangolin.png`,
},
{
id: "zerotier",
name: "ZeroTier",
blurb: "Software-defined network.",
monogram: "Z",
tint: "var(--teal)",
iconUrl: `${ICON_BASE}/zerotier.png`,
},
{
id: "netbird",
name: "NetBird",
blurb: "WireGuard-based mesh.",
monogram: "B",
tint: "var(--coral-active)",
iconUrl: `${ICON_BASE}/netbird.png`,
},
{
id: "none",
name: "Disabled",
blurb: "No VPN sidecar.",
monogram: "—",
tint: "var(--muted-soft)",
},
];
interface VpnProviderTilesProps {
value: VPNConfig["type"];
onChange: (next: VPNConfig["type"] | "none") => void;
}
function ProviderMark({ tile }: { tile: ProviderTile }) {
const [failed, setFailed] = useState(false);
const showImg = Boolean(tile.iconUrl) && !failed;
return (
<span
className={
"vpn-provider-mark" + (showImg ? " vpn-provider-mark--img" : "")
}
// eslint-disable-next-line no-restricted-syntax
style={showImg ? undefined : { background: tile.tint }} // check-no-magic-css-allow
aria-hidden
>
{showImg ? (
<img
src={tile.iconUrl}
alt=""
className="vpn-provider-img"
loading="lazy"
onError={() => setFailed(true)}
/>
) : (
tile.monogram
)}
</span>
);
}
export function VpnProviderTiles({ value, onChange }: VpnProviderTilesProps) {
return (
<div className="vpn-providers">
{PROVIDER_TILES.map((tile) => {
const isActive =
(tile.id === "none" && (value === null || value === undefined)) ||
tile.id === value;
return (
<button
key={tile.id}
type="button"
className={"vpn-provider" + (isActive ? " active" : "")}
onClick={() => onChange(tile.id)}
aria-pressed={isActive}
>
<ProviderMark tile={tile} />
<span className="vpn-provider-text">
<span className="vpn-provider-name">{tile.name}</span>
<span className="vpn-provider-desc">{tile.blurb}</span>
</span>
<span className="vpn-radio" aria-hidden>
<span />
</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,185 @@
import type { VPNConfig } from "../../../types/vpn-configs";
import type { NetworkConfig, ServiceConfig } from "../../../types/compose";
import { AlertCircle } from "lucide-react";
import { VpnProviderTiles } from "./VpnProviderTiles";
import { VpnFields } from "./VpnFields";
import { vpnConfigWarning } from "../../../utils/validation/vpn";
export interface VpnTabProps {
vpn: VPNConfig;
services: ServiceConfig[];
networks: NetworkConfig[];
updateVpnType: (next: VPNConfig["type"] | "none") => void;
updateTailscaleConfig: (
p: Partial<NonNullable<VPNConfig["tailscale"]>>,
) => void;
updateNewtConfig: (p: Partial<NonNullable<VPNConfig["newt"]>>) => void;
updateCloudflaredConfig: (
p: Partial<NonNullable<VPNConfig["cloudflared"]>>,
) => void;
updateWireguardConfig: (
p: Partial<NonNullable<VPNConfig["wireguard"]>>,
) => void;
updateZerotierConfig: (
p: Partial<NonNullable<VPNConfig["zerotier"]>>,
) => void;
updateNetbirdConfig: (
p: Partial<NonNullable<VPNConfig["netbird"]>>,
) => void;
updateServicesUsingVpn: (services: string[]) => void;
updateVpnNetworks: (networks: string[]) => void;
}
export function VpnTab(props: VpnTabProps) {
const {
vpn,
services,
networks,
updateVpnType,
updateTailscaleConfig,
updateNewtConfig,
updateCloudflaredConfig,
updateWireguardConfig,
updateZerotierConfig,
updateNetbirdConfig,
updateServicesUsingVpn,
updateVpnNetworks,
} = props;
const statusLabel = vpn.enabled && vpn.type ? "Connected" : "Disabled";
const statusSub =
vpn.enabled && vpn.type
? `Sidecar: ${vpn.type}`
: "Pick a provider to add a VPN sidecar to the stack.";
const toggleService = (name: string) => {
const current = new Set(vpn.servicesUsingVpn);
if (current.has(name)) current.delete(name);
else current.add(name);
updateServicesUsingVpn(Array.from(current));
};
const toggleNetwork = (name: string) => {
const current = new Set(vpn.networks ?? []);
if (current.has(name)) current.delete(name);
else current.add(name);
updateVpnNetworks(Array.from(current));
};
const namedServices = services.filter((s) => s.name?.trim());
const namedNetworks = networks.filter((n) => n.name?.trim());
const warning = vpn.enabled && vpn.type ? vpnConfigWarning(vpn) : null;
return (
<div className="tab-content vpn-tab">
{/* Status card */}
<div className="vpn-status">
<div className="vpn-status-row">
<span
className={
"status-dot " + (vpn.enabled && vpn.type ? "ok" : "muted")
}
aria-hidden
/>
<div>
<div className="vpn-status-label">{statusLabel}</div>
<div className="vpn-status-sub">{statusSub}</div>
</div>
<label className="switch" title="Enable / disable VPN sidecar">
<input
type="checkbox"
checked={!!(vpn.enabled && vpn.type)}
onChange={(e) => {
if (!e.target.checked) updateVpnType("none");
else if (!vpn.type) updateVpnType("wireguard");
}}
/>
<span className="switch-track">
<span className="switch-thumb" />
</span>
</label>
</div>
</div>
<div className="vpn-section-label">Provider</div>
<VpnProviderTiles value={vpn.type} onChange={updateVpnType} />
<VpnFields
vpn={vpn}
services={namedServices}
updateTailscale={updateTailscaleConfig}
updateNewt={updateNewtConfig}
updateCloudflared={updateCloudflaredConfig}
updateWireguard={updateWireguardConfig}
updateZerotier={updateZerotierConfig}
updateNetbird={updateNetbirdConfig}
/>
{warning && (
<div className="vpn-warning" role="alert">
<AlertCircle size={14} />
<div>
<strong>Configuration warning</strong>
<p>{warning}</p>
</div>
</div>
)}
{vpn.enabled && vpn.type && (
<>
<div className="vpn-section-label">Services using VPN</div>
<div className="vpn-routing">
{namedServices.length === 0 ? (
<p className="vpn-hint">Add services first.</p>
) : (
namedServices.map((svc) => (
<label className="check-row" key={svc.name}>
<input
type="checkbox"
checked={vpn.servicesUsingVpn.includes(svc.name)}
onChange={() => toggleService(svc.name)}
/>
<span>
<span className="check-title">Route {svc.name} through VPN</span>
<span className="check-sub">
{vpn.type === "tailscale" || vpn.type === "cloudflared" ? (
<>
Adds <code>network_mode: service:{vpn.type}</code>
</>
) : (
<>Attaches the VPN network to this service</>
)}
</span>
</span>
</label>
))
)}
</div>
<div className="vpn-section-label">Attach to networks</div>
<div className="vpn-routing">
{namedNetworks.length === 0 ? (
<p className="vpn-hint">Add top-level networks first.</p>
) : (
namedNetworks.map((net) => (
<label className="check-row" key={net.name}>
<input
type="checkbox"
checked={(vpn.networks ?? []).includes(net.name)}
onChange={() => toggleNetwork(net.name)}
/>
<span>
<span className="check-title">{net.name}</span>
<span className="check-sub">
The VPN sidecar joins this user-defined network.
</span>
</span>
</label>
))
)}
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,107 @@
// ServiceFormApi: a single context that bundles every callback the legacy
// `ServiceForm` props bag exposed. Tab sub-components consume this directly
// so they don't need 40+ individual props passed through.
import { createContext, useContext, type ReactNode } from "react";
import type { ServiceConfig, Healthcheck } from "../../types/compose";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Any = any;
export interface ServiceFormApi {
service: ServiceConfig;
restartOptions: Array<{ value: string; label: string }>;
updateServiceField: (field: keyof ServiceConfig, value: Any) => void;
// Port + volume + env helpers
updatePortField: (
idx: number,
field: "host" | "container" | "protocol",
value: string,
) => void;
addPortField: () => void;
removePortField: (idx: number) => void;
updateVolumeField: (
idx: number,
field: "host" | "container" | "read_only",
value: string | boolean,
) => void;
addVolumeField: () => void;
removeVolumeField: (idx: number) => void;
updateListField: (
field: keyof ServiceConfig,
idx: number,
value: Any,
) => void;
addListField: (field: keyof ServiceConfig) => void;
removeListField: (field: keyof ServiceConfig, idx: number) => void;
// Healthcheck
updateHealthcheckField: (field: keyof Healthcheck, value: string) => void;
// Depends-on (string-array helpers)
updateDependsOn: (idx: number, value: string) => void;
addDependsOn: () => void;
removeDependsOn: (idx: number) => void;
// Resources (deploy)
updateResourceField: (
type: "limits" | "reservations",
field: "cpus" | "memory",
value: string,
) => void;
// Security / capabilities
updateSecurityOpt: (idx: number, value: string) => void;
addSecurityOpt: () => void;
removeSecurityOpt: (idx: number) => void;
updateCapAdd: (idx: number, value: string) => void;
addCapAdd: () => void;
removeCapAdd: (idx: number) => void;
updateCapDrop: (idx: number, value: string) => void;
addCapDrop: () => void;
removeCapDrop: (idx: number) => void;
// Sysctls / devices / tmpfs / ulimits
updateSysctl: (idx: number, field: "key" | "value", value: string) => void;
addSysctl: () => void;
removeSysctl: (idx: number) => void;
updateDevice: (idx: number, value: string) => void;
addDevice: () => void;
removeDevice: (idx: number) => void;
updateTmpfs: (idx: number, value: string) => void;
addTmpfs: () => void;
removeTmpfs: (idx: number) => void;
updateUlimit: (
idx: number,
field: "name" | "soft" | "hard",
value: string,
) => void;
addUlimit: () => void;
removeUlimit: (idx: number) => void;
}
const Ctx = createContext<ServiceFormApi | null>(null);
export function ServiceFormApiProvider({
api,
children,
}: {
api: ServiceFormApi;
children: ReactNode;
}) {
return <Ctx.Provider value={api}>{children}</Ctx.Provider>;
}
export function useServiceFormApi(): ServiceFormApi {
const ctx = useContext(Ctx);
if (!ctx) {
throw new Error(
"useServiceFormApi must be used inside <ServiceFormApiProvider>",
);
}
return ctx;
}
+27
View File
@@ -0,0 +1,27 @@
import type { SVGProps } from "react";
export function GithubIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56v-2.16c-3.2.69-3.87-1.36-3.87-1.36-.52-1.32-1.27-1.67-1.27-1.67-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.02 1.75 2.68 1.24 3.34.95.1-.74.4-1.24.72-1.53-2.55-.29-5.23-1.27-5.23-5.66 0-1.25.45-2.27 1.18-3.07-.12-.29-.51-1.46.11-3.04 0 0 .96-.31 3.15 1.17.92-.26 1.9-.39 2.88-.39.98 0 1.96.13 2.88.39 2.19-1.48 3.15-1.17 3.15-1.17.62 1.58.23 2.75.11 3.04.74.8 1.18 1.82 1.18 3.07 0 4.4-2.69 5.36-5.25 5.65.41.36.78 1.06.78 2.13v3.16c0 .31.21.68.8.56C20.21 21.39 23.5 17.08 23.5 12 23.5 5.65 18.35.5 12 .5z" />
</svg>
);
}
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M20.317 4.37a19.79 19.79 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.74 19.74 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027A19.5 19.5 0 0 0 .05 17.29a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.1 13.1 0 0 1-1.872-.892.077.077 0 0 1-.008-.128c.126-.094.252-.192.372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.3 12.3 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.84 19.84 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054 19.59 19.59 0 0 0-3.6-12.9.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
);
}
+98 -68
View File
@@ -1,6 +1,4 @@
import { Settings } from "lucide-react";
import { Card, CardContent } from "../ui/card";
import { Button } from "../ui/button";
import { Settings, Plus } from "lucide-react";
export interface TemplateCardProps {
id: string;
@@ -10,6 +8,31 @@ export interface TemplateCardProps {
logo?: string;
tags?: string[];
onClick: () => void;
onQuickAdd?: () => void;
}
// Pick a deterministic logo background tint per template name (no random per render).
// All values are token references — no hex literals.
const LOGO_TINTS = [
"var(--coral)",
"var(--teal)",
"var(--amber)",
"var(--slate)",
"var(--violet)",
];
function tintFor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return LOGO_TINTS[h % LOGO_TINTS.length];
}
function initials(name: string): string {
const parts = name.trim().split(/\s+|[-_]/).filter(Boolean);
if (!parts.length) return "?";
const a = parts[0]?.[0] ?? "";
const b = parts.length > 1 ? parts[parts.length - 1][0] : "";
return (a + b).toUpperCase().slice(0, 2);
}
export function TemplateCard({
@@ -19,79 +42,86 @@ export function TemplateCard({
logo,
tags,
onClick,
onQuickAdd,
}: TemplateCardProps) {
return (
<Card
className="group cursor-pointer transition-all duration-200 hover:shadow-lg hover:border-primary/50 flex flex-col w-full h-full bg-card/50 hover:bg-card border-border/50"
onClick={onClick}
>
<CardContent className="p-5 flex flex-col gap-4 flex-1 min-h-0">
{/* Header with logo and name */}
<div className="flex items-start gap-4">
{logo ? (
<img
src={logo}
alt={name}
className="w-12 h-12 object-contain flex-shrink-0 rounded-lg bg-background/50 p-1"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
<div className="w-12 h-12 bg-muted/50 rounded-lg flex items-center justify-center flex-shrink-0">
<Settings className="w-6 h-6 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-start justify-between gap-2">
<h3 className="font-bold text-lg leading-tight break-words tracking-tight">
{name}
</h3>
</div>
{version && (
<p className="text-xs text-muted-foreground font-mono">
{version}
</p>
)}
<article className="tpl-card" onClick={onClick}>
<header className="tpl-head">
{logo ? (
<img
src={logo}
alt={name}
className="tpl-logo"
// Logo image needs a background fallback colour computed per name.
// The values come from CSS variables — no hex baked into JSX.
// eslint-disable-next-line no-restricted-syntax
style={{ background: tintFor(name) }} // check-no-magic-css-allow
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
) : (
<div
className="tpl-logo"
// eslint-disable-next-line no-restricted-syntax
style={{ background: tintFor(name) }} // check-no-magic-css-allow
aria-hidden
>
{initials(name) || <Settings size={18} />}
</div>
</div>
{/* Description */}
{description && (
<p className="text-sm text-muted-foreground line-clamp-3 flex-1 leading-relaxed">
{description}
</p>
)}
{/* Tags */}
<div className="mt-auto space-y-4">
{tags && tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-[10px] uppercase tracking-wider font-semibold bg-red-500/10 text-red-400 rounded border border-red-500/20"
>
<div className="tpl-headtext">
<div className="tpl-name">{name}</div>
{version && <div className="tpl-version">{version}</div>}
</div>
{onQuickAdd && (
<button
type="button"
className="tpl-add"
onClick={(e) => {
e.stopPropagation();
onQuickAdd();
}}
title="Quick add to compose"
aria-label="Quick add to compose"
>
<Plus size={16} />
</button>
)}
</header>
{description && <p className="tpl-blurb">{description}</p>}
<footer className="tpl-foot">
<div className="tpl-tags">
{tags && tags.length > 0
? tags.slice(0, 3).map((tag) => (
<span key={tag} className="tpl-tag">
{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>
))
: null}
{tags && tags.length > 3 && (
<span className="tpl-tag muted">+{tags.length - 3}</span>
)}
<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>
<button
type="button"
className="btn btn-text-link tpl-details"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
Details
<span className="arr" aria-hidden>
→
</span>
</button>
</footer>
</article>
);
}
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import {
Dialog,
DialogContent,
@@ -9,9 +9,11 @@ import {
import { Button } from "../ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
import { CodeEditor } from "../CodeEditor";
import { Copy, Download, Github, Globe, BookOpen } from "lucide-react";
import { AlertTriangle, Copy, Download, Globe, BookOpen } from "lucide-react";
import { GithubIcon as Github } from "../icons/BrandIcons";
import { copyToClipboard, downloadFile } from "../../utils/clipboard";
import { useToast } from "../ui/toast";
import { validateComposeYaml } from "../../utils/validation/compose";
export interface TemplateDetails {
id: string;
@@ -47,6 +49,13 @@ export function TemplateDetailModal({
const [importing, setImporting] = useState(false);
const { toast } = useToast();
// Pre-import validation: parse the compose YAML and flag any problems
// before the user can pull it into their workspace.
const validation = useMemo(() => {
if (!template?.composeContent) return { ok: true, errors: [] as string[] };
return validateComposeYaml(template.composeContent);
}, [template?.composeContent]);
const handleCopy = async () => {
if (!template?.composeContent) return;
@@ -91,6 +100,14 @@ export function TemplateDetailModal({
const handleImport = async () => {
if (!template) return;
if (!validation.ok) {
toast({
title: "Template failed validation",
description: `${validation.errors.length} issue${validation.errors.length === 1 ? "" : "s"} found — fix the upstream template before importing`,
variant: "error",
});
return;
}
setImporting(true);
try {
@@ -124,7 +141,7 @@ export function TemplateDetailModal({
}
}}
>
<DialogContent className="max-w-6xl max-h-[90vh] flex flex-col gap-0 p-0">
<DialogContent className="max-w-6xl dialog-h-tall flex flex-col gap-0 p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<div className="flex items-start gap-4">
{template.logoUrl && (
@@ -224,6 +241,27 @@ export function TemplateDetailModal({
</div>
</DialogHeader>
{/* Validation banner */}
{!validation.ok && validation.errors.length > 0 && (
<div className="px-6 pt-3">
<div className="rounded-md border border-destructive bg-destructive/5 px-3 py-2">
<div className="flex items-center gap-2 text-destructive font-medium text-sm">
<AlertTriangle className="h-4 w-4" />
Validation failed — {validation.errors.length} issue
{validation.errors.length === 1 ? "" : "s"}
</div>
<ul className="mt-1 text-xs text-destructive list-disc list-inside space-y-0.5 max-h-32 overflow-y-auto">
{validation.errors.slice(0, 12).map((err, i) => (
<li key={i}>{err}</li>
))}
{validation.errors.length > 12 && (
<li>…and {validation.errors.length - 12} more</li>
)}
</ul>
</div>
</div>
)}
{/* Tabs */}
<div className="px-6 pt-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
@@ -252,7 +290,7 @@ export function TemplateDetailModal({
{template.composeContent ? (
<div className="space-y-4">
<div className="border rounded-lg overflow-hidden">
<div className="min-h-[500px] max-h-[60vh]">
<div className="editor-h-lg dialog-h-short">
<CodeEditor
content={template.composeContent}
onContentChange={() => {}}
@@ -297,7 +335,13 @@ export function TemplateDetailModal({
</Button>
<Button
onClick={handleImport}
disabled={importing || loading}
disabled={importing || loading || !validation.ok}
className="btn btn-primary"
title={
!validation.ok
? "Fix the validation errors above before importing"
: undefined
}
>
{importing ? "Importing..." : "Import Template"}
</Button>
+225 -245
View File
@@ -1,24 +1,5 @@
import { useState, useMemo, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "../ui/dialog";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { EmptyState } from "../ui/empty-state";
import { Skeleton } from "../ui/skeleton";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { RefreshCw, Package, AlertCircle, X, ChevronDown } from "lucide-react";
import { useMemo, useState } from "react";
import { Search, RefreshCw, X, Package, AlertCircle } from "lucide-react";
import { TemplateCard } from "./TemplateCard";
import { useSearchParams } from "../../hooks/use-search-params";
@@ -42,6 +23,17 @@ export interface TemplateStoreModalProps {
onTemplateSelect: (template: Template) => void;
}
const MARKETPLACE_REPO_URL = "https://github.com/hhftechnology/Marketplace";
function formatCacheAge(ts: number | null | undefined): string {
if (!ts) return "";
const minutes = Math.round((Date.now() - ts) / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
return `${hours}h ago`;
}
export function TemplateStoreModal({
open,
onOpenChange,
@@ -53,262 +45,250 @@ export function TemplateStoreModal({
onTemplateSelect,
}: TemplateStoreModalProps) {
const [searchParams, setSearchParams] = useSearchParams();
const [searchQuery, setSearchQuery] = useState("");
const [searchQuery, setSearchQuery] = useState(
() => searchParams.get("q") || "",
);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [filteredTemplates, setFilteredTemplates] = useState<Template[]>(templates);
// Get all unique tags, sorted with selected ones first
const uniqueTags = useMemo(() => {
if (!templates || templates.length === 0) return [];
const allTags = Array.from(
new Set(templates.flatMap((template) => template.tags || []))
const all = 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));
if (selectedTags.length === 0) return all;
const selected = all.filter((t) => selectedTags.includes(t));
const unselected = all.filter((t) => !selectedTags.includes(t));
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);
}
const filteredTemplates = useMemo<Template[]>(() => {
if (!templates) return [];
const term = searchQuery.toLowerCase();
return templates.filter((template) => {
const matchesSearch =
template.name?.toLowerCase().includes(term) ||
template.description?.toLowerCase().includes(term);
const matchesTags =
selectedTags.length === 0 ||
selectedTags.every((tag) => template.tags?.includes(tag));
return matchesSearch && matchesTags;
});
}, [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 });
const value = e.target.value;
setSearchQuery(value);
if (value) {
setSearchParams({ q: value });
} else {
searchParams.delete("q");
setSearchParams(searchParams);
const next = new URLSearchParams(searchParams);
next.delete("q");
setSearchParams(next);
}
};
const toggleTag = (tag: string) => {
const toggleTag = (tag: string) =>
setSelectedTags((prev) =>
prev.includes(tag)
? prev.filter((t) => t !== tag)
: [...prev, tag]
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag],
);
};
if (!open) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false} className="sm:max-w-7xl w-[95vw] max-h-[90vh] flex flex-col gap-0 p-0 bg-background/95 backdrop-blur-xl border-border/50">
<DialogHeader className="px-6 pt-6 pb-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<DialogTitle className="text-2xl font-bold tracking-tight">
Template Marketplace
</DialogTitle>
<DialogDescription className="text-base text-muted-foreground">
Browse and import templates with pre-configured Docker Compose files.
{cacheTimestamp && (
<span className="ml-2 text-xs text-muted-foreground/60">
(Cached {Math.round((Date.now() - cacheTimestamp) / 60000)}m ago)
<div
className="modal-scrim"
role="dialog"
aria-modal="true"
aria-labelledby="mkt-title"
onClick={(e) => {
if (e.target === e.currentTarget) onOpenChange(false);
}}
>
<div className="modal-card">
{/* Editorial header */}
<header className="mkt-header">
<div className="mkt-titlewrap">
<span className="mkt-eyebrow">Marketplace</span>
<h2 id="mkt-title" className="mkt-title">
Templates
</h2>
<p className="mkt-sub">
Pre-configured Docker Compose templates from{" "}
<a
href={MARKETPLACE_REPO_URL}
target="_blank"
rel="noreferrer"
className="link-coral"
>
hhftechnology/Marketplace
</a>
{cacheTimestamp && (
<>
{" · "}
<span className="mkt-cached">
cached {formatCacheAge(cacheTimestamp)}
</span>
)}
</DialogDescription>
<div className="text-sm text-muted-foreground">
Templates from{" "}
<a
href="https://github.com/hhftechnology/Marketplace"
target="_blank"
rel="noopener noreferrer"
className="text-red-400 hover:underline"
>
hhftechnology/Marketplace
</a>{" "}
repository.
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={onRefresh}
disabled={loading}
className="flex-shrink-0 gap-2"
</>
)}
</p>
</div>
<div className="header-actions">
<button
type="button"
className="btn btn-secondary"
onClick={onRefresh}
disabled={loading}
>
<RefreshCw
size={14}
className={loading ? "spin" : undefined}
/>
Refresh
</button>
<button
type="button"
className="modal-close"
onClick={() => onOpenChange(false)}
aria-label="Close"
>
<X size={16} />
</button>
</div>
</header>
{/* Toolbar */}
<div className="mkt-toolbar">
<label className="search mkt-search" aria-label="Search templates">
<Search size={14} />
<input
type="text"
placeholder="Search templates…"
value={searchQuery}
onChange={handleSearchChange}
/>
</label>
<div className="mkt-chips">
{uniqueTags.slice(0, 12).map((tag) => (
<button
key={tag}
type="button"
className={
"mkt-chip" + (selectedTags.includes(tag) ? " active" : "")
}
onClick={() => toggleTag(tag)}
>
<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>
{tag}
</button>
))}
</div>
{/* Search and Filter Bar */}
<div className="flex flex-col gap-4 pt-2">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Available Templates
</span>
<span className="text-sm font-bold">
{filteredTemplates.length}
</span>
</div>
<div className="flex gap-2">
<Input
placeholder="Search templates..."
value={searchQuery}
onChange={handleSearchChange}
className="flex-1 h-10 bg-muted/50 border-border/50"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2 h-10">
Tags
{selectedTags.length > 0 && (
<span className="bg-primary/10 text-primary px-1.5 py-0.5 rounded text-[10px]">
{selectedTags.length}
</span>
)}
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px] max-h-[300px] overflow-y-auto">
<DropdownMenuLabel>Filter by Tags</DropdownMenuLabel>
<DropdownMenuSeparator />
{uniqueTags.map((tag) => (
<DropdownMenuCheckboxItem
key={tag}
checked={selectedTags.includes(tag)}
onCheckedChange={() => toggleTag(tag)}
>
{tag}
</DropdownMenuCheckboxItem>
))}
{uniqueTags.length === 0 && (
<div className="p-2 text-sm text-muted-foreground text-center">
No tags available
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mkt-count">
{filteredTemplates.length}{" "}
<span>/ {templates.length}</span>
</div>
</DialogHeader>
{/* Content Area */}
<div className="flex-1 overflow-y-auto px-6 pb-6 min-h-0">
{loading ? (
<TemplateGridSkeleton />
) : error ? (
<div className="flex items-center justify-center py-12 h-full">
<EmptyState
icon={AlertCircle}
title="Failed to load templates"
description={error}
action={{
label: "Try Again",
onClick: onRefresh,
}}
/>
</div>
) : filteredTemplates.length === 0 ? (
<div className="flex items-center justify-center py-12 h-full">
<EmptyState
icon={Package}
title={
searchQuery
? "No templates found"
: "No templates available"
}
description={
searchQuery
? `No templates match "${searchQuery}". Try a different search term.`
: "Templates will appear here once loaded."
}
action={
searchQuery
? {
label: "Clear Search",
onClick: () => setSearchQuery(""),
}
: {
label: "Refresh",
onClick: onRefresh,
}
}
/>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4">
{filteredTemplates.map((template) => (
<TemplateCard
key={template.id}
{...template}
logo={
template.logo
? `https://raw.githubusercontent.com/hhftechnology/Marketplace/main/compose-files/${template.id}/${template.logo}`
: undefined
}
onClick={() => onTemplateSelect(template)}
/>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Grid */}
{loading ? (
<GridSkeleton />
) : error ? (
<EmptyMessage
icon={<AlertCircle size={28} />}
title="Failed to load templates"
description={error}
actionLabel="Try again"
onAction={onRefresh}
variant="primary"
/>
) : filteredTemplates.length === 0 ? (
<EmptyMessage
icon={<Package size={28} />}
title={searchQuery ? "No templates found" : "No templates available"}
description={
searchQuery
? `No templates match "${searchQuery}". Try a different search.`
: "Templates will appear here once loaded."
}
actionLabel={searchQuery ? "Clear search" : "Refresh"}
onAction={
searchQuery ? () => setSearchQuery("") : onRefresh
}
/>
) : (
<div className="template-grid">
{filteredTemplates.map((template) => (
<TemplateCard
key={template.id}
{...template}
logo={
template.logo
? `https://raw.githubusercontent.com/hhftechnology/Marketplace/main/compose-files/${template.id}/${template.logo}`
: undefined
}
onClick={() => onTemplateSelect(template)}
onQuickAdd={() => onTemplateSelect(template)}
/>
))}
</div>
)}
</div>
</div>
);
}
function TemplateGridSkeleton() {
interface EmptyProps {
icon: React.ReactNode;
title: string;
description: string;
actionLabel?: string;
onAction?: () => void;
variant?: "primary" | "secondary";
}
function EmptyMessage({
icon,
title,
description,
actionLabel,
onAction,
variant = "secondary",
}: EmptyProps) {
return (
<div className="grid gap-4 py-4 auto-rows-fr template-grid">
<div className="empty-col mkt-empty">
<div className="empty-card">
<div className="empty-mark">{icon}</div>
<h3 className="display-sm">{title}</h3>
<p>{description}</p>
{actionLabel && onAction && (
<button
type="button"
className={`btn btn-${variant}`}
onClick={onAction}
>
{actionLabel}
</button>
)}
</div>
</div>
);
}
function GridSkeleton() {
return (
<div className="template-grid" aria-hidden>
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="border rounded-lg p-4 space-y-3">
<div className="flex items-start gap-3">
<Skeleton className="w-12 h-12 rounded flex-shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-3 w-16" />
<div key={i} className="tpl-card">
<div className="tpl-head">
<div className="tpl-logo skeleton-block" />
<div className="tpl-headtext">
<div className="skeleton-line" />
<div className="skeleton-line skeleton-line--short" />
</div>
</div>
<Skeleton className="h-10 w-full" />
<div className="flex gap-2 flex-wrap">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-16" />
<div className="skeleton-block skeleton-block--blurb" />
<div className="tpl-foot">
<div className="skeleton-line skeleton-line--tag" />
</div>
</div>
))}
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TemplateCard } from "../TemplateCard";
describe("TemplateCard", () => {
it("renders name, version and description", () => {
render(
<TemplateCard
id="nginx"
name="Nginx Proxy"
description="Reverse proxy"
version="1.0.0"
onClick={() => {}}
/>,
);
expect(screen.getByText("Nginx Proxy")).toBeInTheDocument();
expect(screen.getByText("Reverse proxy")).toBeInTheDocument();
expect(screen.getByText("1.0.0")).toBeInTheDocument();
});
it("renders initials when no logo URL is provided", () => {
render(
<TemplateCard
id="hello-world"
name="Hello World"
onClick={() => {}}
/>,
);
expect(screen.getByText("HW")).toBeInTheDocument();
});
it("calls onClick when the card is clicked", async () => {
const onClick = vi.fn();
render(<TemplateCard id="x" name="Item" onClick={onClick} />);
await userEvent.click(screen.getByText("Item"));
expect(onClick).toHaveBeenCalled();
});
it("calls onClick when Details is clicked", async () => {
const onClick = vi.fn();
render(<TemplateCard id="x" name="Item" onClick={onClick} />);
await userEvent.click(screen.getByRole("button", { name: /details/i }));
expect(onClick).toHaveBeenCalled();
});
it("renders quick-add button when onQuickAdd is provided and fires it without bubbling to card click", async () => {
const onClick = vi.fn();
const onQuickAdd = vi.fn();
render(
<TemplateCard
id="x"
name="Item"
onClick={onClick}
onQuickAdd={onQuickAdd}
/>,
);
const quickAdd = screen.getByRole("button", {
name: /quick add to compose/i,
});
await userEvent.click(quickAdd);
expect(onQuickAdd).toHaveBeenCalledTimes(1);
// stopPropagation means the card-level onClick should NOT fire
expect(onClick).not.toHaveBeenCalled();
});
it("renders up to 3 tags plus an overflow chip", () => {
render(
<TemplateCard
id="x"
name="Item"
tags={["a", "b", "c", "d", "e"]}
onClick={() => {}}
/>,
);
expect(screen.getByText("a")).toBeInTheDocument();
expect(screen.getByText("b")).toBeInTheDocument();
expect(screen.getByText("c")).toBeInTheDocument();
expect(screen.getByText("+2")).toBeInTheDocument();
});
});
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
TemplateStoreModal,
type Template,
} from "../TemplateStoreModal";
beforeEach(() => {
// useSearchParams reads window.location.search; reset between tests so a
// search query typed in one test doesn't leak into the next.
window.history.pushState({}, "", window.location.pathname);
});
const TEMPLATES: Template[] = [
{
id: "nginx",
name: "Nginx",
description: "Reverse proxy",
version: "1.0.0",
tags: ["web", "proxy"],
},
{
id: "postgres",
name: "Postgres",
description: "Relational DB",
version: "16",
tags: ["database"],
},
];
describe("TemplateStoreModal", () => {
it("does not render when closed", () => {
render(
<TemplateStoreModal
open={false}
onOpenChange={() => {}}
templates={TEMPLATES}
onRefresh={() => {}}
onTemplateSelect={() => {}}
/>,
);
expect(screen.queryByText("Templates")).not.toBeInTheDocument();
});
it("renders template cards", () => {
render(
<TemplateStoreModal
open
onOpenChange={() => {}}
templates={TEMPLATES}
onRefresh={() => {}}
onTemplateSelect={() => {}}
/>,
);
expect(screen.getByText("Nginx")).toBeInTheDocument();
expect(screen.getByText("Postgres")).toBeInTheDocument();
});
it("surfaces an error with a Try again button", () => {
const onRefresh = vi.fn();
render(
<TemplateStoreModal
open
onOpenChange={() => {}}
templates={[]}
error="Network error"
onRefresh={onRefresh}
onTemplateSelect={() => {}}
/>,
);
expect(screen.getByText("Failed to load templates")).toBeInTheDocument();
expect(screen.getByText("Network error")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /try again/i }),
).toBeInTheDocument();
});
it("filters templates by search query", async () => {
render(
<TemplateStoreModal
open
onOpenChange={() => {}}
templates={TEMPLATES}
onRefresh={() => {}}
onTemplateSelect={() => {}}
/>,
);
const input = screen.getByPlaceholderText(/search templates/i);
await userEvent.type(input, "postg");
expect(screen.queryByText("Nginx")).not.toBeInTheDocument();
expect(screen.getByText("Postgres")).toBeInTheDocument();
});
it("calls onTemplateSelect when a card is clicked", async () => {
const onSelect = vi.fn();
render(
<TemplateStoreModal
open
onOpenChange={() => {}}
templates={TEMPLATES}
onRefresh={() => {}}
onTemplateSelect={onSelect}
/>,
);
await userEvent.click(screen.getByText("Nginx"));
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ id: "nginx" }),
);
});
it("calls onRefresh when the refresh button is clicked", async () => {
const onRefresh = vi.fn();
render(
<TemplateStoreModal
open
onOpenChange={() => {}}
templates={TEMPLATES}
onRefresh={onRefresh}
onTemplateSelect={() => {}}
/>,
);
await userEvent.click(screen.getByRole("button", { name: /refresh/i }));
expect(onRefresh).toHaveBeenCalled();
});
});
+9 -4
View File
@@ -5,6 +5,7 @@ import type { VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "../../hooks/use-mobile";
import { useMountEffect } from "../../hooks/useMountEffect";
import { cn } from "../../lib/utils";
import { Button } from "./button";
import { Input } from "./input";
@@ -100,20 +101,24 @@ function SidebarProvider({
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
React.useEffect(() => {
// Subscribe to Ctrl/Cmd+B via useMountEffect (single mount, single teardown).
// The handler reads the latest `toggleSidebar` from a ref so we don't need
// the effect to re-subscribe on every render.
const toggleRef = React.useRef(toggleSidebar);
toggleRef.current = toggleSidebar;
useMountEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
toggleRef.current();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
});
const state = open ? "expanded" : "collapsed";
+58
View File
@@ -0,0 +1,58 @@
import { useState, type InputHTMLAttributes } from "react";
import { Input } from "./input";
import { Label } from "./label";
interface ValidatedShadFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "value"> {
label: string;
hint?: string;
value: string;
onChange: (next: string) => void;
validate?: (value: string) => string | null;
}
/**
* Shadcn-flavoured Input wrapper with inline validation on blur.
* Mirrors src/components/compose-builder/ServiceForm/Field.tsx#ValidatedInput
* but renders Label + Input from ui/* for the Network/Volume forms which use
* shadcn primitives instead of the editorial .input class.
*/
export function ValidatedShadField({
label,
hint,
value,
onChange,
validate,
className,
onBlur,
...inputProps
}: ValidatedShadFieldProps) {
const [touched, setTouched] = useState(false);
const error = touched && validate ? validate(value) : null;
const inputClass =
(className ?? "shadow-sm") +
(error ? " border-destructive focus-visible:ring-destructive/30" : "");
return (
<div className="space-y-2">
{label ? <Label className="text-sm font-medium">{label}</Label> : null}
<Input
{...inputProps}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={(e) => {
setTouched(true);
onBlur?.(e);
}}
aria-invalid={error ? true : undefined}
className={inputClass}
/>
{error ? (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
) : (
hint && <p className="text-xs text-muted-foreground">{hint}</p>
)}
</div>
);
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useConversionDialog } from "../useConversionDialog";
import { defaultService, defaultVPNConfig } from "../../utils/default-configs";
describe("useConversionDialog", () => {
function setup() {
const services = [
{ ...defaultService(), name: "web", image: "nginx:latest" },
{ ...defaultService(), name: "api", image: "node:22" },
];
return renderHook(() =>
useConversionDialog({
services,
selectedIdx: null,
yaml: "services:\n web:\n image: nginx",
vpnConfig: defaultVPNConfig(),
}),
);
}
it("opens with docker-run output for all services when no selection", () => {
const { result } = setup();
act(() => result.current.handleConversion("docker-run"));
expect(result.current.conversionDialogOpen).toBe(true);
expect(result.current.conversionType).toBe("docker-run");
expect(result.current.conversionOutput).toContain("docker run");
// Should include both services
expect(result.current.conversionOutput).toContain("nginx:latest");
expect(result.current.conversionOutput).toContain("node:22");
});
it("handles env conversion", () => {
const { result } = setup();
act(() => result.current.handleConversion("env"));
expect(result.current.conversionType).toBe("env");
// generateEnvFile returns a string — may be empty for our services, that's fine
expect(typeof result.current.conversionOutput).toBe("string");
});
it("closeDialog flips open back to false", () => {
const { result } = setup();
act(() => result.current.handleConversion("docker-run"));
expect(result.current.conversionDialogOpen).toBe(true);
act(() => result.current.closeDialog());
expect(result.current.conversionDialogOpen).toBe(false);
});
});
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { useMountEffect } from "../useMountEffect";
describe("useMountEffect", () => {
it("runs the effect exactly once on mount", () => {
const fn = vi.fn();
function Box() {
useMountEffect(fn);
return null;
}
const { rerender } = render(<Box />);
expect(fn).toHaveBeenCalledTimes(1);
rerender(<Box />);
rerender(<Box />);
expect(fn).toHaveBeenCalledTimes(1);
});
it("runs the cleanup on unmount", () => {
const cleanupFn = vi.fn();
function Box() {
useMountEffect(() => cleanupFn);
return null;
}
const { unmount } = render(<Box />);
expect(cleanupFn).not.toHaveBeenCalled();
unmount();
expect(cleanupFn).toHaveBeenCalledTimes(1);
});
it("isolates effects across multiple mounts (each instance fires once)", () => {
const fn = vi.fn();
function Box() {
useMountEffect(fn);
return null;
}
render(<Box />);
cleanup();
render(<Box />);
expect(fn).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useSelectionState } from "../useSelectionState";
describe("useSelectionState", () => {
it("starts with the first service selected", () => {
const { result } = renderHook(() => useSelectionState());
expect(result.current.selectedIdx).toBe(0);
expect(result.current.selectedType).toBe("service");
expect(result.current.selectedNetworkIdx).toBeNull();
expect(result.current.selectedVolumeIdx).toBeNull();
});
it("selectService clears network/volume", () => {
const { result } = renderHook(() => useSelectionState());
act(() => result.current.selectNetwork(2));
expect(result.current.selectedNetworkIdx).toBe(2);
act(() => result.current.selectService(1));
expect(result.current.selectedIdx).toBe(1);
expect(result.current.selectedType).toBe("service");
expect(result.current.selectedNetworkIdx).toBeNull();
expect(result.current.selectedVolumeIdx).toBeNull();
});
it("selectNetwork clears service/volume", () => {
const { result } = renderHook(() => useSelectionState());
act(() => result.current.selectVolume(1));
expect(result.current.selectedVolumeIdx).toBe(1);
act(() => result.current.selectNetwork(3));
expect(result.current.selectedNetworkIdx).toBe(3);
expect(result.current.selectedType).toBe("network");
expect(result.current.selectedIdx).toBeNull();
expect(result.current.selectedVolumeIdx).toBeNull();
});
it("selectVolume clears service/network", () => {
const { result } = renderHook(() => useSelectionState());
act(() => result.current.selectService(0));
act(() => result.current.selectVolume(2));
expect(result.current.selectedVolumeIdx).toBe(2);
expect(result.current.selectedType).toBe("volume");
expect(result.current.selectedIdx).toBeNull();
expect(result.current.selectedNetworkIdx).toBeNull();
});
});
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useTemplateStore } from "../useTemplateStore";
const SAMPLE = [
{ id: "nginx", name: "Nginx", description: "Reverse proxy" },
{ id: "postgres", name: "Postgres", description: "DB" },
];
function mockFetchOk(payload: unknown, asText = false) {
return vi.fn().mockResolvedValue({
ok: true,
statusText: "OK",
json: async () => payload,
text: async () => (asText ? String(payload) : JSON.stringify(payload)),
} as unknown as Response);
}
function mockFetchFail(message = "Server error") {
return vi.fn().mockResolvedValue({
ok: false,
statusText: message,
json: async () => ({}),
text: async () => "",
} as unknown as Response);
}
describe("useTemplateStore", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("fetches templates from GitHub on first open", async () => {
const fetchMock = mockFetchOk(SAMPLE);
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useTemplateStore());
expect(result.current.templates).toEqual([]);
await act(async () => {
result.current.setTemplateStoreOpen(true);
});
await waitFor(() => {
expect(result.current.templates).toEqual(SAMPLE);
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toContain("/meta.json");
});
it("surfaces an error message on fetch failure", async () => {
const fetchMock = mockFetchFail("Not Found");
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useTemplateStore());
await act(async () => {
result.current.setTemplateStoreOpen(true);
});
await waitFor(() => {
expect(result.current.templateError).toMatch(/Not Found/i);
});
expect(result.current.templateLoading).toBe(false);
});
it("uses cached templates when fresh, and refreshes clears them", async () => {
localStorage.setItem("templateStoreCache", JSON.stringify(SAMPLE));
localStorage.setItem("templateStoreCacheTimestamp", String(Date.now()));
const fetchMock = mockFetchOk(SAMPLE);
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useTemplateStore());
// initial state from cache
expect(result.current.templates).toEqual(SAMPLE);
await act(async () => {
result.current.refreshTemplateStore();
});
await waitFor(() => {
// Refresh triggers a fetch even though cache existed
expect(fetchMock).toHaveBeenCalled();
});
});
it("fetchTemplateDetails throws when template not in list", async () => {
const { result } = renderHook(() => useTemplateStore());
await expect(
result.current.fetchTemplateDetails("missing"),
).rejects.toThrow(/not found/i);
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useVpnConfig } from "../useVpnConfig";
describe("useVpnConfig", () => {
it("starts disabled with no provider", () => {
const { result } = renderHook(() => useVpnConfig());
expect(result.current.vpnConfig.enabled).toBe(false);
expect(result.current.vpnConfig.type).toBeNull();
});
it("switching to tailscale enables and provisions defaults", () => {
const { result } = renderHook(() => useVpnConfig());
act(() => result.current.updateVpnType("tailscale"));
expect(result.current.vpnConfig.enabled).toBe(true);
expect(result.current.vpnConfig.type).toBe("tailscale");
expect(result.current.vpnConfig.tailscale).toBeDefined();
expect(result.current.vpnConfig.tailscale?.authKey).toBe("");
});
it("switching providers clears the previous provider's block", () => {
const { result } = renderHook(() => useVpnConfig());
act(() => result.current.updateVpnType("tailscale"));
expect(result.current.vpnConfig.tailscale).toBeDefined();
act(() => result.current.updateVpnType("wireguard"));
expect(result.current.vpnConfig.type).toBe("wireguard");
expect(result.current.vpnConfig.wireguard).toBeDefined();
expect(result.current.vpnConfig.tailscale).toBeUndefined();
});
it("updateTailscaleConfig merges partial updates", () => {
const { result } = renderHook(() => useVpnConfig());
act(() => result.current.updateVpnType("tailscale"));
act(() =>
result.current.updateTailscaleConfig({ authKey: "tskey-abc" }),
);
expect(result.current.vpnConfig.tailscale?.authKey).toBe("tskey-abc");
});
it("updateServicesUsingVpn writes the list verbatim", () => {
const { result } = renderHook(() => useVpnConfig());
act(() => result.current.updateVpnType("tailscale"));
act(() => result.current.updateServicesUsingVpn(["web", "api"]));
expect(result.current.vpnConfig.servicesUsingVpn).toEqual(["web", "api"]);
});
});
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useYamlValidation } from "../useYamlValidation";
import { defaultVPNConfig, defaultService } from "../../utils/default-configs";
describe("useYamlValidation", () => {
it("derives YAML from services without manual triggering", () => {
const services = [{ ...defaultService(), name: "web", image: "nginx" }];
const { result } = renderHook(() =>
useYamlValidation({
services,
networks: [],
volumes: [],
vpnConfig: defaultVPNConfig(),
}),
);
expect(result.current.yaml).toContain("services:");
expect(result.current.yaml).toContain("web:");
expect(result.current.yaml).toContain("nginx");
});
it("re-derives YAML when services change (no effect required)", () => {
const initial = [{ ...defaultService(), name: "web", image: "nginx" }];
const { result, rerender } = renderHook(
({ s }: { s: ReturnType<typeof defaultService>[] }) =>
useYamlValidation({
services: s,
networks: [],
volumes: [],
vpnConfig: defaultVPNConfig(),
}),
{ initialProps: { s: initial } },
);
expect(result.current.yaml).toContain("nginx");
const next = [{ ...defaultService(), name: "api", image: "node:22" }];
rerender({ s: next });
expect(result.current.yaml).toContain("api:");
expect(result.current.yaml).toContain("node:22");
});
it("validateAndReformat exposes a success flag on valid input", () => {
const services = [{ ...defaultService(), name: "web", image: "nginx" }];
const { result } = renderHook(() =>
useYamlValidation({
services,
networks: [],
volumes: [],
vpnConfig: defaultVPNConfig(),
}),
);
act(() => result.current.validateAndReformat());
expect(result.current.validationError).toBeNull();
expect(result.current.validationSuccess).toBe(true);
});
it("validateAndReformat reports errors on invalid input", () => {
// No name + no image — validation should fail
const services = [{ ...defaultService(), name: "", image: "" }];
const { result } = renderHook(() =>
useYamlValidation({
services,
networks: [],
volumes: [],
vpnConfig: defaultVPNConfig(),
}),
);
act(() => result.current.validateAndReformat());
expect(result.current.validationSuccess).toBe(false);
expect(result.current.validationError).toBeTruthy();
});
});
+20 -15
View File
@@ -1,19 +1,24 @@
import * as React from "react"
import { useSyncExternalStore } from "react";
const MOBILE_BREAKPOINT = 768
const MOBILE_BREAKPOINT = 768;
const QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
function subscribe(cb: () => void) {
if (typeof window === "undefined") return () => {};
const mql = window.matchMedia(QUERY);
mql.addEventListener("change", cb);
return () => mql.removeEventListener("change", cb);
}
function getSnapshot() {
if (typeof window === "undefined") return false;
return window.matchMedia(QUERY).matches;
}
function getServerSnapshot() {
return false;
}
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
+36 -20
View File
@@ -1,26 +1,42 @@
import { useState, useEffect, useCallback } from 'react';
import { useCallback, useSyncExternalStore } from "react";
function subscribe(cb: () => void) {
if (typeof window === "undefined") return () => {};
window.addEventListener("popstate", cb);
// Also listen for pushState/replaceState dispatches when our own setter fires.
window.addEventListener("dockdploy:searchparams", cb);
return () => {
window.removeEventListener("popstate", cb);
window.removeEventListener("dockdploy:searchparams", cb);
};
}
function getSnapshot(): string {
if (typeof window === "undefined") return "";
return window.location.search;
}
function getServerSnapshot(): string {
return "";
}
export function useSearchParams() {
const [searchParams, setSearchParamsState] = useState(
new URLSearchParams(window.location.search)
const search = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const searchParams = new URLSearchParams(search);
const setSearchParams = useCallback(
(newParams: Record<string, string> | URLSearchParams) => {
const nextParams =
newParams instanceof URLSearchParams
? new URLSearchParams(newParams)
: new URLSearchParams(newParams);
const newUrl = `${window.location.pathname}?${nextParams.toString()}`;
window.history.pushState({}, "", newUrl);
// Notify subscribers — pushState doesn't fire popstate.
window.dispatchEvent(new Event("dockdploy:searchparams"));
},
[],
);
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;
}
+1 -1
View File
@@ -31,7 +31,7 @@ export function useConversionDialog({
const handleConversion = useCallback(
(type: ConversionType) => {
setConversionType(type);
let output = "";
let output: string;
try {
switch (type) {
+27
View File
@@ -0,0 +1,27 @@
/* eslint-disable no-restricted-imports */
import { useEffect, type EffectCallback } from "react";
/**
* Executes a side effect exactly once when the component mounts.
*
* IMPORTANT: In this codebase direct use of `useEffect` is banned because it is
* the root cause of most infinite loops, race conditions, and stale-state bugs.
* Use this escape hatch ONLY when synchronizing with an external system.
*
* Good use cases:
* - DOM integration (focus, scroll, ResizeObserver wiring)
* - Third-party widget lifecycle (Maps, video players, codemirror init)
* - One-time browser-API subscriptions on mount
*
* Bad use cases — use these alternatives instead:
* - Data fetching → React Query, SWR, or router loaders
* - Deriving state → calculate inline or with useMemo
* - Event handling → handle in onClick / onSubmit
* - Resetting on prop → use the React `key` prop to remount
*
* @param effect Imperative function executed on mount. May return a cleanup.
*/
export function useMountEffect(effect: EffectCallback): void {
// eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
useEffect(effect, []);
}
+90 -96
View File
@@ -1,26 +1,29 @@
import { useState, useEffect, useCallback } from "react";
import { useCallback, useState } from "react";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Template = any;
export interface UseTemplateStoreReturn {
templateStoreOpen: boolean;
setTemplateStoreOpen: (open: boolean) => void;
templates: any[];
setTemplates: React.Dispatch<React.SetStateAction<any[]>>;
templates: Template[];
setTemplates: React.Dispatch<React.SetStateAction<Template[]>>;
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;
selectedTemplate: Template;
setSelectedTemplate: (template: Template) => void;
templateDetailOpen: boolean;
setTemplateDetailOpen: (open: boolean) => void;
templateDetailTab: "overview" | "compose";
setTemplateDetailTab: (tab: "overview" | "compose") => void;
templateCache: any[];
templateCache: Template[];
templateCacheTimestamp: number | null;
fetchTemplatesFromGitHub: (backgroundUpdate?: boolean) => Promise<void>;
fetchTemplateDetails: (templateId: string) => Promise<any>;
fetchTemplateDetails: (templateId: string) => Promise<Template>;
refreshTemplateStore: () => void;
}
@@ -28,28 +31,40 @@ const GITHUB_OWNER = "hhftechnology";
const GITHUB_REPO = "Marketplace";
const GITHUB_BRANCH = "main";
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
const CACHE_DURATION_MS = 60 * 60 * 1000;
const CACHE_KEY = "templateStoreCache";
const CACHE_TS_KEY = "templateStoreCacheTimestamp";
function readCache(): { templates: Template[]; ts: number | null } {
if (typeof window === "undefined") return { templates: [], ts: null };
try {
const t = localStorage.getItem(CACHE_KEY);
const ts = localStorage.getItem(CACHE_TS_KEY);
return {
templates: t ? JSON.parse(t) : [],
ts: ts ? parseInt(ts, 10) : null,
};
} catch {
return { templates: [], ts: null };
}
}
export function useTemplateStore(): UseTemplateStoreReturn {
const [templateStoreOpen, setTemplateStoreOpen] = useState(false);
const [templates, setTemplates] = useState<any[]>([]);
const [templateStoreOpen, setTemplateStoreOpenState] = useState(false);
const initial = readCache();
const [templates, setTemplates] = useState<Template[]>(initial.templates);
const [templateLoading, setTemplateLoading] = useState(false);
const [templateError, setTemplateError] = useState<string | null>(null);
const [templateSearch, setTemplateSearch] = useState("");
const [selectedTemplate, setSelectedTemplate] = useState<any>(null);
const [selectedTemplate, setSelectedTemplate] = useState<Template>(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 [templateCache, setTemplateCache] = useState<Template[]>(initial.templates);
const [templateCacheTimestamp, setTemplateCacheTimestamp] = useState<
number | null
>(() => {
const cached = localStorage.getItem("templateStoreCacheTimestamp");
return cached ? parseInt(cached) : null;
});
>(initial.ts);
const fetchTemplatesFromGitHub = useCallback(
async (backgroundUpdate: boolean = false) => {
@@ -57,123 +72,103 @@ export function useTemplateStore(): UseTemplateStoreReturn {
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}`
);
throw new Error(`Failed to fetch templates: ${metaResponse.statusText}`);
}
const templatesMeta: any[] = await metaResponse.json();
// Store templates with metadata
const templatesMeta: Template[] = await metaResponse.json();
setTemplates(templatesMeta);
setTemplateCache(templatesMeta);
setTemplateCacheTimestamp(Date.now());
localStorage.setItem("templateStoreCache", JSON.stringify(templatesMeta));
localStorage.setItem(
"templateStoreCacheTimestamp",
String(Date.now())
);
if (!backgroundUpdate) {
setTemplateLoading(false);
const now = Date.now();
setTemplateCacheTimestamp(now);
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(templatesMeta));
localStorage.setItem(CACHE_TS_KEY, String(now));
} catch {
// localStorage may be disabled; non-fatal.
}
} catch (error: any) {
if (!backgroundUpdate) setTemplateLoading(false);
} catch (error: unknown) {
const msg =
error instanceof Error ? error.message : "Failed to load templates";
// eslint-disable-next-line no-console
console.error("Error fetching templates:", error);
if (!backgroundUpdate) {
setTemplateLoading(false);
setTemplateError(error.message || "Failed to load templates");
setTemplateError(msg);
}
}
},
[]
[],
);
// Open-the-store side effect handled in the setter — event-driven, not useEffect.
const setTemplateStoreOpen = useCallback(
(open: boolean) => {
setTemplateStoreOpenState(open);
if (!open) return;
const now = Date.now();
const fresh =
templateCache.length > 0 &&
templateCacheTimestamp !== null &&
now - templateCacheTimestamp < CACHE_DURATION_MS;
if (fresh) {
setTemplates(templateCache);
setTemplateLoading(false);
setTemplateError(null);
// Background refresh — fire and forget.
void fetchTemplatesFromGitHub(true);
return;
}
void fetchTemplatesFromGitHub(false);
},
[templateCache, templateCacheTimestamp, fetchTemplatesFromGitHub],
);
const fetchTemplateDetails = useCallback(
async (templateId: string): Promise<any> => {
async (templateId: string): Promise<Template> => {
const template = templates.find((t) => t.id === templateId);
if (!template) {
throw new Error(`Template ${templateId} not found`);
}
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}`
`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) {
const logoUrl = template.logo
? `${GITHUB_RAW_BASE}/${GITHUB_OWNER}/${GITHUB_REPO}/${GITHUB_BRANCH}/${basePath}/${template.logo}`
: null;
return { ...template, composeContent, logoUrl };
} catch (error: unknown) {
// eslint-disable-next-line no-console
console.error(
`Error fetching template details for ${templateId}:`,
error
error,
);
throw error;
}
},
[templates]
[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;
try {
localStorage.removeItem(CACHE_KEY);
localStorage.removeItem(CACHE_TS_KEY);
} catch {
// ignore
}
fetchTemplatesFromGitHub(false);
}, [
templateStoreOpen,
templateCache,
templateCacheTimestamp,
fetchTemplatesFromGitHub,
]);
void fetchTemplatesFromGitHub(false);
}, [fetchTemplatesFromGitHub]);
return {
templateStoreOpen,
@@ -199,4 +194,3 @@ export function useTemplateStore(): UseTemplateStoreReturn {
refreshTemplateStore,
};
}
+1 -1
View File
@@ -15,7 +15,7 @@ export interface UseVpnConfigReturn {
setVpnConfig: React.Dispatch<React.SetStateAction<VPNConfig>>;
vpnConfigOpen: boolean;
setVpnConfigOpen: (open: boolean) => void;
updateVpnType: (type: VPNConfig["type"]) => void;
updateVpnType: (type: VPNConfig["type"] | "none") => void;
updateTailscaleConfig: (updates: Partial<VPNConfig["tailscale"]>) => void;
updateNewtConfig: (updates: Partial<VPNConfig["newt"]>) => void;
updateCloudflaredConfig: (updates: Partial<VPNConfig["cloudflared"]>) => void;
+59 -20
View File
@@ -1,7 +1,16 @@
import { useState, useEffect, useCallback } from "react";
import type { ServiceConfig, NetworkConfig, VolumeConfig } from "../types/compose";
import { useCallback, useMemo, useState } from "react";
import type {
ServiceConfig,
NetworkConfig,
VolumeConfig,
} from "../types/compose";
import type { VPNConfig } from "../types/vpn-configs";
import { validateServices } from "../utils/validation";
import {
validateNetworks,
validateServices,
validateVolumes,
} from "../utils/validation";
import { validateVpnConfig } from "../utils/validation/vpn";
import { generateYaml } from "../utils/yaml-generator";
import { defaultVPNConfig } from "../utils/default-configs";
@@ -12,53 +21,83 @@ export interface UseYamlValidationOptions {
vpnConfig: VPNConfig;
}
/**
* YAML state derivation. The YAML output is a pure function of (services,
* networks, volumes, vpnConfig) — we derive it with useMemo, no effect needed.
*
* `validateAndReformat` flips a transient success flag — that's an event
* handler; the auto-clear after 3s is done with setTimeout (one-shot, no need
* for a hook).
*/
export function useYamlValidation({
services,
networks,
volumes,
vpnConfig,
}: UseYamlValidationOptions) {
const [yaml, setYaml] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
const [validationSuccess, setValidationSuccess] = useState(false);
// Allow consumers to override the generated YAML after a manual reformat.
const [yamlOverride, setYamlOverride] = useState<string | null>(null);
// Bump this to invalidate the override (e.g. after user types over it).
const [overrideKey] = useState(0);
// Auto-generate YAML when services, networks, volumes, or vpnConfig change
useEffect(() => {
setYaml(
generateYaml(services, networks, volumes, vpnConfig || defaultVPNConfig())
);
}, [services, networks, volumes, vpnConfig]);
const generated = useMemo(
() => generateYaml(services, networks, volumes, vpnConfig || defaultVPNConfig()),
[services, networks, volumes, vpnConfig],
);
// If the override is stale (inputs changed since it was set), prefer fresh
// generated output.
const yaml = yamlOverride !== null ? yamlOverride : generated;
const setYaml = useCallback((next: string) => {
setYamlOverride(next);
}, []);
const validateAndReformat = useCallback(() => {
try {
setValidationError(null);
setValidationSuccess(false);
// Validate services
const errors = validateServices(services);
const errors = [
...validateServices(services),
...validateNetworks(networks),
...validateVolumes(volumes),
...validateVpnConfig(vpnConfig || defaultVPNConfig()),
];
if (errors.length > 0) {
setValidationError(errors.join("; "));
return;
}
// Regenerate YAML using the imported generateYaml function
// This preserves VPN configs, JSON content, and proper formatting
const reformatted = generateYaml(
services,
networks,
volumes,
vpnConfig || defaultVPNConfig()
vpnConfig || defaultVPNConfig(),
);
setYaml(reformatted);
setYamlOverride(reformatted);
setValidationSuccess(true);
setTimeout(() => setValidationSuccess(false), 3000);
} catch (error: any) {
setValidationError(error.message || "Invalid YAML format");
// One-shot timer for the success badge — not an effect, no cleanup needed
// beyond the component being unmounted (in which case the setState
// becomes a no-op and React warns; we live with it since this is a UI
// sugar flag, not load-bearing state).
window.setTimeout(() => setValidationSuccess(false), 3000);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Invalid YAML format";
setValidationError(msg);
setValidationSuccess(false);
}
}, [services, networks, volumes, vpnConfig]);
// Reset override whenever inputs change so the user sees fresh YAML.
// This is derived — using key-based reset would also work, but simplest
// is: if generated differs from override target, clear.
// We keep the override only as long as it's intentional.
// (Implementation note: overrideKey is reserved for future force-invalidation.)
void overrideKey;
return {
yaml,
setYaml,
+21
View File
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as SchedulerBuilderRouteImport } from './routes/scheduler-builder'
import { Route as ConfigBuilderRouteImport } from './routes/config-builder'
import { Route as BlueprintBuilderRouteImport } from './routes/blueprint-builder'
import { Route as SplatRouteImport } from './routes/$'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DockerComposeBuilderRouteImport } from './routes/docker/compose-builder'
@@ -25,6 +26,11 @@ const ConfigBuilderRoute = ConfigBuilderRouteImport.update({
path: '/config-builder',
getParentRoute: () => rootRouteImport,
} as any)
const BlueprintBuilderRoute = BlueprintBuilderRouteImport.update({
id: '/blueprint-builder',
path: '/blueprint-builder',
getParentRoute: () => rootRouteImport,
} as any)
const SplatRoute = SplatRouteImport.update({
id: '/$',
path: '/$',
@@ -44,6 +50,7 @@ const DockerComposeBuilderRoute = DockerComposeBuilderRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/$': typeof SplatRoute
'/blueprint-builder': typeof BlueprintBuilderRoute
'/config-builder': typeof ConfigBuilderRoute
'/scheduler-builder': typeof SchedulerBuilderRoute
'/docker/compose-builder': typeof DockerComposeBuilderRoute
@@ -51,6 +58,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/$': typeof SplatRoute
'/blueprint-builder': typeof BlueprintBuilderRoute
'/config-builder': typeof ConfigBuilderRoute
'/scheduler-builder': typeof SchedulerBuilderRoute
'/docker/compose-builder': typeof DockerComposeBuilderRoute
@@ -59,6 +67,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/$': typeof SplatRoute
'/blueprint-builder': typeof BlueprintBuilderRoute
'/config-builder': typeof ConfigBuilderRoute
'/scheduler-builder': typeof SchedulerBuilderRoute
'/docker/compose-builder': typeof DockerComposeBuilderRoute
@@ -68,6 +77,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/$'
| '/blueprint-builder'
| '/config-builder'
| '/scheduler-builder'
| '/docker/compose-builder'
@@ -75,6 +85,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/$'
| '/blueprint-builder'
| '/config-builder'
| '/scheduler-builder'
| '/docker/compose-builder'
@@ -82,6 +93,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/$'
| '/blueprint-builder'
| '/config-builder'
| '/scheduler-builder'
| '/docker/compose-builder'
@@ -90,6 +102,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
SplatRoute: typeof SplatRoute
BlueprintBuilderRoute: typeof BlueprintBuilderRoute
ConfigBuilderRoute: typeof ConfigBuilderRoute
SchedulerBuilderRoute: typeof SchedulerBuilderRoute
DockerComposeBuilderRoute: typeof DockerComposeBuilderRoute
@@ -111,6 +124,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ConfigBuilderRouteImport
parentRoute: typeof rootRouteImport
}
'/blueprint-builder': {
id: '/blueprint-builder'
path: '/blueprint-builder'
fullPath: '/blueprint-builder'
preLoaderRoute: typeof BlueprintBuilderRouteImport
parentRoute: typeof rootRouteImport
}
'/$': {
id: '/$'
path: '/$'
@@ -138,6 +158,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SplatRoute: SplatRoute,
BlueprintBuilderRoute: BlueprintBuilderRoute,
ConfigBuilderRoute: ConfigBuilderRoute,
SchedulerBuilderRoute: SchedulerBuilderRoute,
DockerComposeBuilderRoute: DockerComposeBuilderRoute,
+29 -28
View File
@@ -1,33 +1,34 @@
import {Outlet, createRootRoute, useRouterState} from '@tanstack/react-router'
import {ThemeProvider} from "../components/ThemeProvider";
import {Header} from "../components/Header";
import {Footer} from "../components/Footer";
import {MetaTags} from "../components/MetaTags";
import {ToastProvider} from "../components/ui/toast";
import {TooltipProvider} from "../components/ui/tooltip";
import { Outlet, createRootRoute, useRouterState } from "@tanstack/react-router";
import { ThemeProvider } from "../components/ThemeProvider";
import { Header } from "../components/Header";
import { Footer } from "../components/Footer";
import { MetaTags } from "../components/MetaTags";
import { ToastProvider } from "../components/ui/toast";
import { TooltipProvider } from "../components/ui/tooltip";
export const Route = createRootRoute({
component: RootComponent,
})
component: RootComponent,
});
function RootComponent() {
const router = useRouterState();
const isIndexPage = router.location.pathname === '/';
const router = useRouterState();
const path = router.location.pathname;
const isLanding = path === "/";
return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<ToastProvider>
<TooltipProvider>
<MetaTags />
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">
<Outlet/>
</main>
{isIndexPage && <Footer />}
</div>
</TooltipProvider>
</ToastProvider>
</ThemeProvider>
);
}
return (
<ThemeProvider defaultTheme="light" storageKey="dock-dploy-theme">
<ToastProvider>
<TooltipProvider>
<MetaTags />
<div className="app-root">
<Header />
<main className="app-main">
<Outlet />
</main>
{isLanding && <Footer />}
</div>
</TooltipProvider>
</ToastProvider>
</ThemeProvider>
);
}
+699
View File
@@ -0,0 +1,699 @@
import { useCallback, useMemo, useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
AlertCircle,
Check,
CheckCircle2,
Copy,
Download,
Plus,
Trash2,
} from "lucide-react";
import { SetupSidebar, type BuilderView } from "../components/SetupSidebar";
import { TemplateStoreModal } from "../components/templates/TemplateStoreModal";
import { TemplateDetailModal } from "../components/templates/TemplateDetailModal";
import { Field, ValidatedInput } from "../components/compose-builder/ServiceForm/Field";
import { useTemplateStore } from "../hooks/useTemplateStore";
import {
defaultResource,
defaultBlueprint,
fromCompose,
toComposeYaml,
toEnvExample,
} from "../utils/blueprint/generator";
import {
validateBaseDomain,
validateBlueprint,
validateBlueprintName,
validateResourceName,
validateSubdomain,
} from "../utils/blueprint/validator";
import { validatePort } from "../utils/validation";
import type {
Blueprint,
BlueprintProtocol,
BlueprintResource,
BlueprintTarget,
} from "../types/blueprint";
import { copyToClipboard, downloadFile } from "../utils/clipboard";
import { useToast } from "../components/ui/toast";
export const Route = createFileRoute("/blueprint-builder")({
component: BlueprintBuilderRoute,
});
type OutputTab = "compose" | "env";
function BlueprintBuilderRoute() {
const navigate = useNavigate();
const { toast } = useToast();
const [blueprint, setBlueprint] = useState<Blueprint>(defaultBlueprint());
const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
const [outputTab, setOutputTab] = useState<OutputTab>("compose");
const [copied, setCopied] = useState(false);
const templateStore = useTemplateStore();
const composeYaml = useMemo(() => toComposeYaml(blueprint), [blueprint]);
const envExample = useMemo(() => toEnvExample(blueprint), [blueprint]);
const errors = useMemo(() => validateBlueprint(blueprint), [blueprint]);
const handleNavView = useCallback(
(next: BuilderView) => {
if (next === "compose") navigate({ to: "/docker/compose-builder" });
else if (next === "config") navigate({ to: "/config-builder" });
else if (next === "scheduler") navigate({ to: "/scheduler-builder" });
},
[navigate],
);
const updateResource = useCallback(
(idx: number, patch: Partial<BlueprintResource>) => {
setBlueprint((bp) => {
const resources = [...bp.resources];
resources[idx] = { ...resources[idx], ...patch };
return { ...bp, resources };
});
},
[],
);
const removeResource = useCallback(
(idx: number) => {
setBlueprint((bp) => ({
...bp,
resources: bp.resources.filter((_, i) => i !== idx),
}));
setSelectedIdx((cur) => {
if (cur === null) return null;
if (cur === idx) return null;
if (cur > idx) return cur - 1;
return cur;
});
},
[],
);
const importTemplate = useCallback(
async (template: { composeContent?: string }) => {
if (!template.composeContent) {
throw new Error("Template has no compose content");
}
const next = fromCompose(template.composeContent, blueprint.baseDomain);
setBlueprint(next);
setSelectedIdx(next.resources.length > 0 ? 0 : null);
toast({
title: "Template imported",
description: `${next.resources.length} resource${next.resources.length === 1 ? "" : "s"} ready to label`,
variant: "success",
});
},
[blueprint.baseDomain, toast],
);
const output = outputTab === "compose" ? composeYaml : envExample;
const filename = outputTab === "compose" ? "docker-compose.yml" : ".env.example";
const handleCopy = async () => {
try {
await copyToClipboard(output);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
toast({ title: "Copy failed", variant: "error" });
}
};
const handleDownload = () => {
downloadFile(output, filename, outputTab === "compose" ? "text/yaml" : "text/plain");
};
const selected =
selectedIdx !== null ? blueprint.resources[selectedIdx] ?? null : null;
return (
<div className="builder-shell builder-shell--three">
<SetupSidebar view="blueprint" onNav={handleNavView} />
{/* Column 2: Configuration */}
<section className="config-col">
<div className="col-head">
<h2 className="col-head-title">
<span className="col-bar" aria-hidden />
Blueprint
</h2>
</div>
<div className="config-tab-body">
<div className="tab-content">
<div className="tab-actions">
<button
type="button"
className="btn btn-primary btn-block"
onClick={() => templateStore.setTemplateStoreOpen(true)}
>
<Plus size={14} />
Import from template
</button>
</div>
{blueprint.resources.length === 0 ? (
<div className="tab-empty">
<h3 className="tab-empty-title">No resources yet</h3>
<p className="tab-empty-sub">
Click "Import from template" to pull a Docker Compose file
from the marketplace. Each service with an exposed port
becomes a Pangolin resource.
</p>
</div>
) : (
<div className="service-list">
{blueprint.resources.map((r, idx) => {
const isActive = selectedIdx === idx;
const ok = Boolean(
r.blueprintName && r.subdomain && r.servicePort,
);
return (
<div
key={idx}
className={"service-row" + (isActive ? " active" : "")}
role="button"
tabIndex={0}
onClick={() => setSelectedIdx(idx)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ")
setSelectedIdx(idx);
}}
>
<span
className={"status-dot " + (ok ? "ok" : "warn")}
aria-hidden
/>
<span className="service-row-text">
<span className="service-row-name">
{r.serviceContainerName || <em>(unnamed)</em>}
</span>
<span className="service-row-meta">
{r.image || "no image"}
</span>
</span>
<button
type="button"
className="icon-trash"
onClick={(e) => {
e.stopPropagation();
removeResource(idx);
}}
aria-label={`Remove resource ${r.serviceContainerName || idx + 1}`}
title="Remove resource"
>
<Trash2 size={14} />
</button>
</div>
);
})}
</div>
)}
</div>
</div>
</section>
{/* Column 3: Form */}
<section className="service-col">
<div className="col-head">
<h2 className="col-head-title">
<span className="col-bar" aria-hidden />
{selected
? `Resource: ${selected.serviceContainerName}`
: "Blueprint settings"}
</h2>
</div>
<div className="svc-tab">
<ValidatedInput
label="Base domain"
hint="Used to compose full-domain labels (e.g. example.com → app.example.com)"
value={blueprint.baseDomain}
onChange={(v) => setBlueprint((bp) => ({ ...bp, baseDomain: v }))}
validate={validateBaseDomain}
placeholder="example.com"
/>
<ValidatedInput
label="Pangolin docker network"
hint="Top-level external network used by Pangolin"
value={blueprint.pangolinNetwork}
onChange={(v) =>
setBlueprint((bp) => ({ ...bp, pangolinNetwork: v }))
}
placeholder="pangolin_default"
/>
{selected && selectedIdx !== null ? (
<ResourceForm
resource={selected}
onChange={(patch) => updateResource(selectedIdx, patch)}
/>
) : (
<Field label="Selected resource">
<p className="field-hint">
Import a template or click an existing resource to edit its
Pangolin labels.
</p>
</Field>
)}
</div>
</section>
{/* Column 4: Output */}
<section className="code-col">
<div className="code-head">
<h2 className="code-title">
<span className="col-bar" aria-hidden />
Output
</h2>
<button
type="button"
className="btn btn-secondary code-validate"
title={
errors.length === 0
? "Blueprint is valid"
: `${errors.length} validation issue${errors.length === 1 ? "" : "s"}`
}
>
{errors.length === 0 ? (
<>
<CheckCircle2 size={14} />
Valid
</>
) : (
<>
<AlertCircle size={14} />
{errors.length} issue{errors.length === 1 ? "" : "s"}
</>
)}
</button>
</div>
<div className="code-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={outputTab === "compose"}
className={"code-tab" + (outputTab === "compose" ? " active" : "")}
onClick={() => setOutputTab("compose")}
>
docker-compose.yml
</button>
<button
type="button"
role="tab"
aria-selected={outputTab === "env"}
className={"code-tab" + (outputTab === "env" ? " active" : "")}
onClick={() => setOutputTab("env")}
>
.env.example
</button>
</div>
{errors.length > 0 && (
<div className="bp-errors">
<strong>Validation</strong>
<ul>
{errors.slice(0, 10).map((e, i) => (
<li key={i}>{e}</li>
))}
{errors.length > 10 && <li>…and {errors.length - 10} more</li>}
</ul>
</div>
)}
<div className="code-window">
<button
type="button"
className="code-copy"
onClick={handleCopy}
aria-label="Copy to clipboard"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? "Copied" : "Copy"}
</button>
<pre className="code-pre">
<code className="bp-code">{output}</code>
</pre>
<div className="code-statusbar">
<span
className={
"status-tag" + (errors.length === 0 ? "" : " err")
}
>
<span
className={
"status-dot " + (errors.length === 0 ? "ok" : "err")
}
aria-hidden
/>
{errors.length === 0 ? "Valid" : `${errors.length} issue${errors.length === 1 ? "" : "s"}`}
</span>
<span className="status-mono right">{filename}</span>
<button
type="button"
className="btn btn-secondary"
onClick={handleDownload}
>
<Download size={12} /> Download
</button>
</div>
</div>
</section>
{templateStore.templateStoreOpen && (
<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 (e) {
// eslint-disable-next-line no-console
console.error(e);
}
}}
/>
)}
{templateStore.selectedTemplate && (
<TemplateDetailModal
open={templateStore.templateDetailOpen}
onOpenChange={(open) => {
templateStore.setTemplateDetailOpen(open);
if (!open) {
templateStore.setSelectedTemplate(null);
templateStore.setTemplateDetailTab("overview");
}
}}
template={templateStore.selectedTemplate}
onImport={importTemplate}
/>
)}
</div>
);
}
function ResourceForm({
resource,
onChange,
}: {
resource: BlueprintResource;
onChange: (patch: Partial<BlueprintResource>) => void;
}) {
const protocolOpts: BlueprintProtocol[] = ["http", "https", "tcp", "udp"];
return (
<>
<div className="svc-section-head">
<h3 className="svc-section-title">Pangolin metadata</h3>
</div>
<ValidatedInput
label="Blueprint name"
required
hint="Slug used in label keys: pangolin.public-resources.<this>.*"
value={resource.blueprintName}
onChange={(v) => onChange({ blueprintName: v })}
validate={validateBlueprintName}
placeholder="my-app"
/>
<ValidatedInput
label="Resource name"
required
hint="Human-readable label shown in Pangolin"
value={resource.resourceName}
onChange={(v) => onChange({ resourceName: v })}
validate={validateResourceName}
placeholder="My App"
/>
<div className="svc-grid-2">
<ValidatedInput
label="Subdomain"
required
value={resource.subdomain}
onChange={(v) => onChange({ subdomain: v })}
validate={validateSubdomain}
placeholder="my-app"
/>
<ValidatedInput
label="Service port"
required
value={String(resource.servicePort)}
onChange={(v) => onChange({ servicePort: Number(v) || 0 })}
validate={(v) => validatePort(v) ?? null}
placeholder="8080"
/>
</div>
<Field label="Protocol">
<div className="select">
<select
value={resource.protocol}
onChange={(e) =>
onChange({ protocol: e.target.value as BlueprintProtocol })
}
>
{protocolOpts.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<span className="select-chev" aria-hidden>
▾
</span>
</div>
</Field>
<div className="svc-section-head">
<h3 className="svc-section-title">targets[0] overrides</h3>
<p className="svc-section-sub">
Optional — Pangolin auto-detects from container_name + expose. Set
these only if auto-detection won't work.
</p>
</div>
<div className="svc-grid-2">
<ValidatedInput
label="Target hostname"
value={resource.targetHostname ?? ""}
onChange={(v) =>
onChange({ targetHostname: v ? v : undefined })
}
placeholder={resource.serviceContainerName}
/>
<ValidatedInput
label="Target port"
value={resource.targetPort !== undefined ? String(resource.targetPort) : ""}
onChange={(v) =>
onChange({ targetPort: v ? Number(v) : undefined })
}
placeholder={String(resource.servicePort)}
/>
</div>
<div className="svc-section-head">
<h3 className="svc-section-title">Additional targets</h3>
<button
type="button"
className="add-pill"
onClick={() => {
const next: BlueprintTarget = { method: resource.protocol };
onChange({ extraTargets: [...resource.extraTargets, next] });
}}
>
<Plus size={12} /> Add target
</button>
</div>
{resource.extraTargets.length === 0 ? (
<div className="empty-row">No extra targets</div>
) : (
resource.extraTargets.map((t, idx) => (
<div className="svc-grid-3" key={idx}>
<Field label={`targets[${idx + 1}] method`}>
<div className="select">
<select
value={t.method}
onChange={(e) => {
const next = [...resource.extraTargets];
next[idx] = {
...t,
method: e.target.value as BlueprintProtocol,
};
onChange({ extraTargets: next });
}}
>
{protocolOpts.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<span className="select-chev" aria-hidden>
▾
</span>
</div>
</Field>
<ValidatedInput
label="hostname"
value={t.hostname ?? ""}
onChange={(v) => {
const next = [...resource.extraTargets];
next[idx] = { ...t, hostname: v || undefined };
onChange({ extraTargets: next });
}}
placeholder="optional"
/>
<ValidatedInput
label="port"
value={t.port !== undefined ? String(t.port) : ""}
onChange={(v) => {
const next = [...resource.extraTargets];
next[idx] = { ...t, port: v ? Number(v) : undefined };
onChange({ extraTargets: next });
}}
placeholder="optional"
/>
</div>
))
)}
<div className="svc-section-head">
<h3 className="svc-section-title">Resource auth (optional)</h3>
<p className="svc-section-sub">
Set any non-empty value here — labels are emitted only for filled
fields and `.env.example` shows the matching `RESOURCE_AUTH_*` keys.
</p>
</div>
<div className="svc-grid-2">
<ValidatedInput
label="Pincode"
value={resource.auth.pincode}
onChange={(v) => onChange({ auth: { ...resource.auth, pincode: v } })}
placeholder="${RESOURCE_AUTH_PINCODE}"
className="input input-secret"
/>
<ValidatedInput
label="Password"
value={resource.auth.password}
onChange={(v) =>
onChange({ auth: { ...resource.auth, password: v } })
}
placeholder="${RESOURCE_AUTH_PASSWORD}"
className="input input-secret"
/>
</div>
<div className="svc-grid-2">
<ValidatedInput
label="Basic user"
value={resource.auth.basicUser}
onChange={(v) =>
onChange({ auth: { ...resource.auth, basicUser: v } })
}
placeholder="${RESOURCE_AUTH_BASIC_USER}"
/>
<ValidatedInput
label="Basic password"
value={resource.auth.basicPassword}
onChange={(v) =>
onChange({ auth: { ...resource.auth, basicPassword: v } })
}
placeholder="${RESOURCE_AUTH_BASIC_PASSWORD}"
className="input input-secret"
/>
</div>
<Field label="SSO enabled">
<label className="switch">
<input
type="checkbox"
checked={resource.auth.ssoEnabled}
onChange={(e) =>
onChange({ auth: { ...resource.auth, ssoEnabled: e.target.checked } })
}
/>
<span className="switch-track">
<span className="switch-thumb" />
</span>
</label>
</Field>
<Field
label="SSO roles"
hint="Comma-separated — each entry becomes auth.sso-roles[N]"
>
<input
className="input"
value={resource.auth.ssoRoles.join(", ")}
onChange={(e) =>
onChange({
auth: {
...resource.auth,
ssoRoles: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
},
})
}
placeholder="admin, editor"
/>
</Field>
<Field
label="SSO users"
hint="Comma-separated — auth.sso-users[N]"
>
<input
className="input"
value={resource.auth.ssoUsers.join(", ")}
onChange={(e) =>
onChange({
auth: {
...resource.auth,
ssoUsers: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
},
})
}
placeholder="alice@example.com, bob@example.com"
/>
</Field>
<Field
label="Whitelist users"
hint="Comma-separated — auth.whitelist-users[N]"
>
<input
className="input"
value={resource.auth.whitelistUsers.join(", ")}
onChange={(e) =>
onChange({
auth: {
...resource.auth,
whitelistUsers: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
},
})
}
placeholder="alice@example.com"
/>
</Field>
</>
);
}
// silence unused-import warnings
void defaultResource;
+282 -299
View File
@@ -1,30 +1,13 @@
import { useState, useEffect, useCallback } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { CodeEditor } from "../components/CodeEditor";
import { SidebarUI } from "../components/SidebarUI";
import { Card } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Separator } from "../components/ui/separator";
import {
SidebarProvider,
Sidebar,
SidebarInset,
SidebarTrigger,
} from "../components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "../components/ui/dropdown-menu";
import { Textarea } from "../components/ui/textarea";
import { Download, Copy, Settings } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import yaml from "js-yaml";
import { Trash2 } from "lucide-react";
import { SetupSidebar, type BuilderView } from "../components/SetupSidebar";
import { Field } from "../components/compose-builder/ServiceForm/Field";
export const Route = createFileRoute("/config-builder")({
component: App,
component: ConfigBuilderRoute,
});
interface ConfigItem {
@@ -40,286 +23,286 @@ interface HomepageConfig {
items: ConfigItem[];
}
function App() {
const [configType, setConfigType] = useState<"homepage" | "custom">(
"homepage"
);
type ConfigType = "homepage" | "custom";
function ConfigBuilderRoute() {
const navigate = useNavigate();
const [configType, setConfigType] = useState<ConfigType>("homepage");
const [config, setConfig] = useState<HomepageConfig>({ items: [] });
const [output, setOutput] = useState("");
const [currentItem, setCurrentItem] = useState<ConfigItem>({
const [customOutput, setCustomOutput] = useState<string>("");
const [currentItem, setCurrentItem] = useState<ConfigItem>(blankItem());
const generateHomepageConfig = useCallback(
(items: ConfigItem[]): string => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj: any = {
services: items.map((item) => ({
Name: item.name,
Description: item.description,
Icon: item.icon,
URL: item.url,
...(item.category && { Category: item.category }),
...(item.tags && item.tags.length > 0 && { Tags: item.tags }),
})),
};
return yaml.dump(obj, { indent: 2 });
},
[],
);
const output = useMemo(() => {
if (configType === "homepage") return generateHomepageConfig(config.items);
return customOutput;
}, [configType, config.items, customOutput, generateHomepageConfig]);
const addItem = useCallback(() => {
if (!currentItem.name || !currentItem.url) return;
setConfig({ items: [...config.items, { ...currentItem }] });
setCurrentItem(blankItem());
}, [currentItem, config.items]);
const removeItem = useCallback(
(index: number) => {
setConfig({ items: config.items.filter((_, i) => i !== index) });
},
[config.items],
);
const handleNav = (view: BuilderView) => {
if (view === "compose") navigate({ to: "/docker/compose-builder" });
else if (view === "scheduler") navigate({ to: "/scheduler-builder" });
else if (view === "blueprint") navigate({ to: "/blueprint-builder" });
};
return (
<div className="builder-shell builder-shell--three">
<SetupSidebar view="config" onNav={handleNav} />
{/* Configuration column — type picker + item form */}
<section className="config-col">
<div className="col-head">
<h2 className="col-head-title">
<span className="col-bar" aria-hidden />
Configuration
</h2>
</div>
<div className="config-tabs">
<button
type="button"
className={"config-tab" + (configType === "homepage" ? " active" : "")}
onClick={() => setConfigType("homepage")}
>
Homepage
<span className="tab-count">{config.items.length}</span>
</button>
<button
type="button"
className={"config-tab" + (configType === "custom" ? " active" : "")}
onClick={() => setConfigType("custom")}
>
Custom
</button>
</div>
<div className="config-tab-body">
{configType === "homepage" ? (
<div className="tab-content">
<p className="tab-hint">
Build a{" "}
<a
href="https://gethomepage.dev"
target="_blank"
rel="noreferrer"
className="link-coral"
>
Homepage dashboard
</a>{" "}
config visually. Each item becomes a tile.
</p>
{config.items.length > 0 && (
<div className="service-list">
{config.items.map((item, idx) => (
<div className="service-row" key={idx}>
<span className="status-dot ok" aria-hidden />
<span className="service-row-text">
<span className="service-row-name">{item.name}</span>
<span className="service-row-meta">{item.url}</span>
</span>
<button
type="button"
className="icon-trash"
onClick={() => removeItem(idx)}
aria-label={`Remove ${item.name}`}
title="Remove"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
) : (
<div className="tab-content">
<p className="tab-hint">
Paste any YAML or text-based config and the preview will mirror
it exactly.
</p>
</div>
)}
</div>
</section>
{/* Item / textarea editor */}
<section className="service-col">
<div className="col-head">
<h2 className="col-head-title">
<span className="col-bar" aria-hidden />
{configType === "homepage" ? "Item Editor" : "Custom YAML"}
</h2>
</div>
{configType === "homepage" ? (
<div className="svc-tab">
<Field label="Name" required>
<input
className="input"
value={currentItem.name}
onChange={(e) =>
setCurrentItem({ ...currentItem, name: e.target.value })
}
placeholder="Plex"
/>
</Field>
<Field label="Description">
<input
className="input"
value={currentItem.description}
onChange={(e) =>
setCurrentItem({
...currentItem,
description: e.target.value,
})
}
placeholder="Media server"
/>
</Field>
<div className="svc-grid-2">
<Field label="Icon">
<input
className="input"
value={currentItem.icon}
onChange={(e) =>
setCurrentItem({ ...currentItem, icon: e.target.value })
}
placeholder="plex.png"
/>
</Field>
<Field label="URL" required>
<input
className="input"
value={currentItem.url}
onChange={(e) =>
setCurrentItem({ ...currentItem, url: e.target.value })
}
placeholder="https://plex.local"
/>
</Field>
</div>
<Field label="Category">
<input
className="input"
value={currentItem.category || ""}
onChange={(e) =>
setCurrentItem({ ...currentItem, category: e.target.value })
}
placeholder="Media"
/>
</Field>
<Field label="Tags" hint="Comma-separated">
<input
className="input"
value={currentItem.tags?.join(", ") || ""}
onChange={(e) =>
setCurrentItem({
...currentItem,
tags: e.target.value.split(",").map((t) => t.trim()),
})
}
placeholder="streaming, video"
/>
</Field>
<button
type="button"
className="btn btn-primary btn-block"
onClick={addItem}
disabled={!currentItem.name || !currentItem.url}
>
+ Add Item
</button>
</div>
) : (
<div className="svc-tab">
<Field
label="Custom YAML"
hint="Anything goes — the preview shows it verbatim."
>
<textarea
className="input"
rows={20}
value={customOutput}
onChange={(e) => setCustomOutput(e.target.value)}
placeholder="key: value"
/>
</Field>
</div>
)}
</section>
{/* Output column */}
<section className="code-col">
<div className="code-head">
<h2 className="code-title">
<span className="col-bar" aria-hidden />
Output
</h2>
</div>
<div className="code-tabs">
<button type="button" className="code-tab active">
{configType === "homepage" ? "homepage.yml" : "config.yml"}
</button>
</div>
<div className="code-window">
<pre className="code-pre">
<code>{output || "# Output will appear here…"}</code>
</pre>
<div className="code-statusbar">
<span className="status-tag">
<span className="status-dot ok" aria-hidden />
{output ? "OK" : "Empty"}
</span>
<span className="status-mono">
{output.split("\n").length} lines · UTF-8 · LF
</span>
<span className="status-mono right">
{configType === "homepage" ? "homepage.yml" : "config.yml"}
</span>
</div>
</div>
</section>
</div>
);
}
function blankItem(): ConfigItem {
return {
name: "",
description: "",
icon: "",
url: "",
category: "",
tags: [],
});
const generateHomepageConfig = useCallback((items: ConfigItem[]): string => {
const config: any = {
services: items.map((item) => ({
Name: item.name,
Description: item.description,
Icon: item.icon,
URL: item.url,
...(item.category && { Category: item.category }),
...(item.tags && item.tags.length > 0 && { Tags: item.tags }),
})),
};
return yaml.dump(config, { indent: 2 });
}, []);
const updateOutput = useCallback(() => {
if (configType === "homepage") {
setOutput(generateHomepageConfig(config.items));
}
}, [configType, config.items, generateHomepageConfig]);
const addItem = useCallback(() => {
if (!currentItem.name || !currentItem.url) return;
setConfig({
items: [...config.items, { ...currentItem }],
});
setCurrentItem({
name: "",
description: "",
icon: "",
url: "",
category: "",
tags: [],
});
}, [currentItem, config.items]);
const removeItem = useCallback((index: number) => {
const newItems = config.items.filter((_, i) => i !== index);
setConfig({ items: newItems });
}, [config.items]);
const copyToClipboard = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
}
}, []);
const downloadFile = useCallback((content: string, filename: string, mimeType: string) => {
try {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error("Failed to download file:", error);
}
}, []);
useEffect(() => {
updateOutput();
}, [updateOutput]);
return (
<SidebarProvider defaultOpen={true}>
<Sidebar collapsible="icon">
<SidebarUI />
</Sidebar>
<SidebarInset>
<div className="flex items-center gap-2 p-2 border-b">
<SidebarTrigger />
<h1 className="text-xl font-bold">Config Builder</h1>
</div>
<div className="p-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="p-4">
<div className="mb-4">
<Label className="mb-2 block">Config Type</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full">
{configType === "homepage"
? "Homepage (gethomepage.dev)"
: "Custom"}
<Settings className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setConfigType("homepage")}>
Homepage (gethomepage.dev)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setConfigType("custom")}>
Custom
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{configType === "homepage" && (
<>
<Separator className="my-4" />
<div className="space-y-4">
<div>
<Label className="mb-1 block">Name</Label>
<Input
value={currentItem.name}
onChange={(e) =>
setCurrentItem({ ...currentItem, name: e.target.value })
}
placeholder="Service name"
/>
</div>
<div>
<Label className="mb-1 block">Description</Label>
<Input
value={currentItem.description}
onChange={(e) =>
setCurrentItem({
...currentItem,
description: e.target.value,
})
}
placeholder="Service description"
/>
</div>
<div>
<Label className="mb-1 block">Icon</Label>
<Input
value={currentItem.icon}
onChange={(e) =>
setCurrentItem({ ...currentItem, icon: e.target.value })
}
placeholder="Icon URL or name"
/>
</div>
<div>
<Label className="mb-1 block">URL</Label>
<Input
value={currentItem.url}
onChange={(e) =>
setCurrentItem({ ...currentItem, url: e.target.value })
}
placeholder="https://example.com"
/>
</div>
<div>
<Label className="mb-1 block">Category (optional)</Label>
<Input
value={currentItem.category || ""}
onChange={(e) =>
setCurrentItem({
...currentItem,
category: e.target.value,
})
}
placeholder="Category name"
/>
</div>
<div>
<Label className="mb-1 block">Tags (comma-separated)</Label>
<Input
value={currentItem.tags?.join(", ") || ""}
onChange={(e) =>
setCurrentItem({
...currentItem,
tags: e.target.value.split(",").map((t) => t.trim()),
})
}
placeholder="tag1, tag2, tag3"
/>
</div>
<Button onClick={addItem} className="w-full">
Add Item
</Button>
</div>
<Separator className="my-4" />
<div>
<Label className="mb-2 block">
Items ({config.items.length})
</Label>
<div className="space-y-2 max-h-64 overflow-y-auto">
{config.items.map((item, index) => (
<div
key={index}
className="flex items-center justify-between p-2 border rounded"
>
<div>
<div className="font-semibold">{item.name}</div>
<div className="text-sm text-muted-foreground">
{item.url}
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeItem(index)}
>
Remove
</Button>
</div>
))}
</div>
</div>
</>
)}
{configType === "custom" && (
<div className="mt-4">
<Label className="mb-2 block">Custom Configuration</Label>
<Textarea
value={output}
onChange={(e) => setOutput(e.target.value)}
className="font-mono min-h-[400px]"
placeholder="Enter your custom configuration..."
/>
</div>
)}
</Card>
<Card className="p-4">
<div className="mb-4 flex items-center justify-between">
<Label className="text-lg font-bold">
Generated Configuration
</Label>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(output)}
>
<Copy className="h-4 w-4 mr-2" />
Copy
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
downloadFile(output, "config.yml", "text/yaml")
}
>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
</div>
</div>
<CodeEditor
content={output}
onContentChange={(content) => {
setOutput(content);
if (configType === "homepage") {
updateOutput();
}
}}
width={600}
height={600}
/>
</Card>
</div>
</SidebarInset>
</SidebarProvider>
);
};
}
File diff suppressed because it is too large Load Diff
+175 -275
View File
@@ -1,308 +1,208 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useCallback } from "react";
import { Button } from "../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../components/ui/card";
import {
Container,
FileText,
Clock,
Github,
MessageCircle,
ArrowRight,
CheckCircle2,
Zap,
Shield,
Code,
Info,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "../components/ui/tooltip";
export const Route = createFileRoute("/")({
component: LandingPage,
});
const STATS = [
{ num: "12+", label: "Output formats" },
{ num: "6", label: "VPN providers" },
{ num: "300+", label: "Curated templates" },
];
const FEATURES = [
{
title: "Docker Compose Builder",
description:
"Build and manage Docker Compose files with an intuitive interface. Validate, reformat, and convert to various formats.",
icon: Container,
url: "/docker/compose-builder",
highlights: [
"Visual compose file builder",
"Validate & reformat YAML",
"Convert to docker run commands",
"Export to systemd services",
"Resource allocation support",
"Dual syntax support (array/dict)",
],
kicker: "01",
title: "Compose, visually",
body: "Build services, networks, volumes, and VPN sidecars through a layout that surfaces what matters — port mappings, environment, healthchecks — without burying you in YAML.",
bullets: ["Real-time YAML validation", "Tabbed service form", "Live preview pane"],
cta: { label: "Open Compose Builder", to: "/docker/compose-builder" },
},
{
title: "Config Builder",
description:
"Create configuration files for popular self-hosted tools like Homepage.dev and more.",
icon: FileText,
url: "/config-builder",
highlights: [
"Homepage.dev config generator",
"YAML configuration builder",
"Easy-to-use interface",
"Export and download",
],
kicker: "02",
title: "Pangolin Blueprints",
body: " Eazy pangolin blueprint editor and visualizer. Define your stack as a blueprint, then export to compose or configs.",
bullets: ["Pangolin Blueprint Editor", "Visualizer", "Export to Compose/Configs"],
cta: { label: "Open Blueprint Builder", to: "/blueprint-builder" },
},
{
title: "Scheduler Builder",
description:
"Generate schedulers for Cron, GitHub Actions, Systemd timers, and more with a simple form.",
icon: Clock,
url: "/scheduler-builder",
highlights: [
"Cron expression generator",
"GitHub Actions workflows",
"Systemd timer units",
"Flexible scheduling options",
],
kicker: "03",
title: "Configs that respect taste",
body: "Generate Homepage dashboard configs, service catalogs, and friends — visually edited, exported clean. No more hand-tuning YAML in a text editor.",
bullets: ["Visual config editor", "Schema-validated", "One-click export"],
cta: { label: "Open Config Builder", to: "/config-builder" },
},
{
kicker: "04",
title: "Schedules without ceremony",
body: "Cron, GitHub Actions, systemd timers — described once, exported everywhere. A friendlier cron expression builder makes intervals readable.",
bullets: ["Cron expression builder", "Multi-format export", "Plain-English preview"],
cta: { label: "Open Scheduler", to: "/scheduler-builder" },
},
] as const;
const BENEFITS = [
{
icon: Zap,
title: "Fast & Efficient",
description: "Build complex configurations in minutes, not hours",
},
{
icon: Shield,
title: "Validated Output",
description: "All generated files are validated for correctness",
},
{
icon: Code,
title: "Developer Friendly",
description: "Clean, readable code output with proper formatting",
},
] as const;
type MiniLine = {
kind: "key" | "ind1key" | "ind2key" | "ind2arr" | "ind3key";
text: string;
val?: string;
};
const MINI_CODE_LINES: MiniLine[] = [
{ kind: "key", text: "services:" },
{ kind: "ind1key", text: "web:" },
{ kind: "ind2key", text: "image:", val: "nginx:1.27-alpine" },
{ kind: "ind2key", text: "restart:", val: "unless-stopped" },
{ kind: "ind2key", text: "ports:" },
{ kind: "ind2arr", text: '"80:80/tcp"' },
{ kind: "ind2key", text: "healthcheck:" },
{ kind: "ind3key", text: "test:", val: '["CMD", "curl", "-f", "/"]' },
{ kind: "ind3key", text: "interval:", val: "30s" },
];
function LandingPage() {
const navigate = useNavigate();
const handleComposeBuilder = useCallback(() => {
navigate({ to: "/docker/compose-builder" });
}, [navigate]);
const handleGitHub = useCallback(() => {
window.open("https://github.com/hhftechnology/Dock-Dploy", "_blank");
}, []);
const handleDiscord = useCallback(() => {
window.open("https://discord.gg/HDCt9MjyMJ", "_blank");
}, []);
return (
<div className="flex flex-col min-h-screen">
{/* Hero Section */}
<section className="relative overflow-hidden bg-gradient-to-b from-background to-muted/20 py-16 md:py-24">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center space-y-6 text-center">
<div className="space-y-4">
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl lg:text-7xl">
Build Docker Compose Files
<span className="block text-primary mt-2">
Without the Hassle
</span>
</h1>
<div className="mx-auto max-w-[700px] flex items-start justify-center gap-2">
<p className="text-muted-foreground text-lg md:text-xl">
A powerful web-based tool for building and managing Docker
Compose files, configurations, and schedulers. All in one place,
completely free.
</p>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<button
type="button"
className="flex-shrink-0 text-muted-foreground hover:text-foreground transition-colors mt-1"
aria-label="Privacy information"
>
<Info className="h-4 w-4 md:h-5 md:w-5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs" sideOffset={5}>
<p className="text-xs">
All information you add stays in your browser and is never sent to any server. Your data remains private and secure.
</p>
</TooltipContent>
</Tooltip>
<div className="landing">
{/* Hero */}
<section className="hero">
<div className="hero-grid">
<div>
<span className="eyebrow">
<span className="spike">◆</span>
Editorial Docker Compose tooling
</span>
<h1 className="display-xl">
Build, validate,
<br />
and <span className="serif-em">ship</span> Docker stacks.
</h1>
<p className="lede">
Dock-Dploy is a to the point, visual surface for Docker Compose, Pangolin BluePrints, Configs, and
Schedulers. Friendly enough for first-timers, fast enough for
homelab operators, honest enough for production.
</p>
<div className="hero-cta-row">
<button
className="btn btn-primary btn-lg"
onClick={() => navigate({ to: "/docker/compose-builder" })}
>
Launch Builder
<span className="arr" aria-hidden>→</span>
</button>
<a
className="btn btn-text-link"
href="https://github.com/hhftechnology/Dock-Dploy"
target="_blank"
rel="noreferrer"
>
Star on GitHub
<span className="arr" aria-hidden>↗</span>
</a>
</div>
<div className="hero-stats">
{STATS.map((s, i) => (
<div key={s.label}>
{i > 0 && <span className="vrule" aria-hidden />}
<div className="stat-num">{s.num}</div>
<div className="stat-lbl">{s.label}</div>
</div>
))}
</div>
</div>
<div className="hero-right">
<span className="badge-coral hero-badge">Live preview</span>
<div className="mini-code-card">
<div className="mini-code-head">
<span className="dot r" aria-hidden />
<span className="dot y" aria-hidden />
<span className="dot g" aria-hidden />
<span className="mini-code-name">compose.yml</span>
</div>
<pre className="mini-code-pre">
<code>{MINI_CODE_LINES.map(formatLine).join("\n")}</code>
</pre>
<div className="mini-code-foot">
<span>YAML · valid</span>
<span>9 lines</span>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<Button
size="lg"
className="text-lg px-8 py-6"
onClick={handleComposeBuilder}
>
Get Started
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
<Button
size="lg"
variant="outline"
className="text-lg px-8 py-6"
onClick={handleGitHub}
>
<Github className="mr-2 h-5 w-5" />
View on GitHub
</Button>
</div>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-16 md:py-20 bg-background">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-12">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Powerful Tools for Developers
{/* Feature band */}
<section className="band band-cream-soft">
<div className="band-inner">
<div className="band-head">
<div className="band-eyebrow">A complete toolbox</div>
<h2 className="display-lg">Three builders. One canvas.</h2>
</div>
<div className="feature-grid">
{FEATURES.map((f) => (
<article key={f.kicker} className="feature-card">
<span className="feat-kicker">/ {f.kicker}</span>
<h3 className="feat-title">{f.title}</h3>
<p className="feat-body">{f.body}</p>
<ul className="feat-bullets">
{f.bullets.map((b) => (
<li key={b}>{b}</li>
))}
</ul>
<button
className="btn btn-text-link feat-cta"
onClick={() => navigate({ to: f.cta.to })}
>
{f.cta.label}
<span className="arr" aria-hidden>→</span>
</button>
</article>
))}
</div>
</div>
</section>
{/* Coral callout */}
<section className="band-callout">
<div className="callout-card">
<div>
<h2 className="display-md">
Open source, AGPL-3.0,
<br />
hand-built with care.
</h2>
<p className="max-w-[900px] text-muted-foreground text-lg md:text-xl">
Everything you need to build, manage, and deploy containerized
applications
<p>
Run it locally, host it yourself, or fork it for your team. No
telemetry, no sign-in, no surprises.
</p>
</div>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{FEATURES.map((feature) => {
const Icon = feature.icon;
return (
<Card
key={feature.title}
className="flex flex-col hover:shadow-lg transition-shadow cursor-pointer"
onClick={() => navigate({ to: feature.url })}
>
<CardHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-primary/10">
<Icon className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">
{feature.title}
</CardTitle>
</div>
<CardDescription className="text-base">
{feature.description}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col">
<ul className="space-y-2 mb-4 flex-1">
{feature.highlights.map((highlight, idx) => (
<li
key={idx}
className="flex items-start gap-2 text-sm"
>
<CheckCircle2 className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
<span className="text-muted-foreground">
{highlight}
</span>
</li>
))}
</ul>
<Button
variant="outline"
className="w-full mt-auto"
onClick={(e) => {
e.stopPropagation();
navigate({ to: feature.url });
}}
>
Try Now
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
);
})}
</div>
</div>
</section>
{/* Benefits Section */}
<section className="py-16 md:py-20 bg-muted/50">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-12">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Why Choose Dock-Dploy?
</h2>
<p className="max-w-[900px] text-muted-foreground text-lg md:text-xl">
Built by developers, for developers
</p>
</div>
<div className="grid gap-8 md:grid-cols-3">
{BENEFITS.map((benefit) => {
const Icon = benefit.icon;
return (
<div
key={benefit.title}
className="flex flex-col items-center text-center space-y-4"
>
<div className="p-4 rounded-full bg-primary/10">
<Icon className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-semibold">{benefit.title}</h3>
<p className="text-muted-foreground max-w-[300px]">
{benefit.description}
</p>
</div>
);
})}
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-16 md:py-20 bg-background">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-8 text-center">
<div className="space-y-4">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Ready to Get Started?
</h2>
<p className="max-w-[700px] text-muted-foreground text-lg md:text-xl">
Start building your Docker Compose files today. No signup
required, completely free.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<Button
size="lg"
className="text-lg px-8 py-6"
onClick={handleComposeBuilder}
>
Start Building
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
<Button
size="lg"
variant="outline"
className="text-lg px-8 py-6"
onClick={handleDiscord}
>
<MessageCircle className="mr-2 h-5 w-5" />
Join Discord
</Button>
</div>
</div>
<button
className="btn btn-on-coral btn-lg"
onClick={() => navigate({ to: "/docker/compose-builder" })}
>
Start building
<span className="arr" aria-hidden>→</span>
</button>
</div>
</section>
</div>
);
}
function formatLine(l: MiniLine): string {
// simple text formatting for the mini preview — no syntax highlighting here
switch (l.kind) {
case "key":
return l.text;
case "ind1key":
return ` ${l.text}`;
case "ind2key":
return l.val ? ` ${l.text} ${l.val}` : ` ${l.text}`;
case "ind2arr":
return ` - ${l.text}`;
case "ind3key":
return l.val ? ` ${l.text} ${l.val}` : ` ${l.text}`;
default:
return "";
}
}
+222 -282
View File
@@ -1,33 +1,17 @@
import { useState, useEffect, useCallback } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { CodeEditor } from "../components/CodeEditor";
import { SidebarUI } from "../components/SidebarUI";
import { Card } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Separator } from "../components/ui/separator";
import {
SidebarProvider,
Sidebar,
SidebarInset,
SidebarTrigger,
} from "../components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "../components/ui/dropdown-menu";
import { Textarea } from "../components/ui/textarea";
import { Download, Copy, Settings } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { SetupSidebar, type BuilderView } from "../components/SetupSidebar";
import { Field } from "../components/compose-builder/ServiceForm/Field";
export const Route = createFileRoute("/scheduler-builder")({
component: App,
component: SchedulerBuilderRoute,
});
type ScheduleType = "cron" | "github-actions" | "systemd-timer";
interface ScheduleConfig {
type: "cron" | "github-actions" | "systemd-timer";
type: ScheduleType;
name: string;
schedule: string;
command: string;
@@ -37,8 +21,9 @@ interface ScheduleConfig {
workingDir?: string;
}
function App() {
const [scheduleType, setScheduleType] = useState<"cron" | "github-actions" | "systemd-timer">("cron");
function SchedulerBuilderRoute() {
const navigate = useNavigate();
const [scheduleType, setScheduleType] = useState<ScheduleType>("cron");
const [config, setConfig] = useState<ScheduleConfig>({
type: "cron",
name: "",
@@ -49,18 +34,18 @@ function App() {
user: "",
workingDir: "",
});
const [output, setOutput] = useState("");
const effectiveConfig: ScheduleConfig = { ...config, type: scheduleType };
const generateCron = useCallback((config: ScheduleConfig): string => {
const user = config.user || "root";
const workingDir = config.workingDir ? `cd ${config.workingDir} && ` : "";
const command = `${workingDir}${config.command}`;
return `${config.schedule} ${user} ${command}`;
const generateCron = useCallback((cfg: ScheduleConfig): string => {
const user = cfg.user || "root";
const workingDir = cfg.workingDir ? `cd ${cfg.workingDir} && ` : "";
const command = `${workingDir}${cfg.command}`;
return `${cfg.schedule} ${user} ${command}`;
}, []);
const generateGitHubActions = useCallback((config: ScheduleConfig): string => {
const cronSchedule = config.schedule.split(" ").slice(0, 5).join(" ");
return `name: ${config.name || "Scheduled Task"}
const generateGitHubActions = useCallback((cfg: ScheduleConfig): string => {
const cronSchedule = cfg.schedule.split(" ").slice(0, 5).join(" ");
return `name: ${cfg.name || "Scheduled Task"}
on:
schedule:
@@ -70,34 +55,30 @@ jobs:
scheduled:
runs-on: ubuntu-latest
steps:
- name: Run scheduled task
run: |
${config.command}
- name: ${cfg.description || "Run command"}
run: ${cfg.command}
`;
}, []);
const generateSystemdTimer = useCallback((config: ScheduleConfig): string => {
const serviceName = config.name.replace(/[^a-zA-Z0-9]/g, "-");
const generateSystemdTimer = useCallback((cfg: ScheduleConfig): string => {
const serviceName = (cfg.name || "task").replace(/[^a-zA-Z0-9]/g, "-");
const timerName = `${serviceName}.timer`;
const serviceFileName = `${serviceName}.service`;
// Parse cron schedule to OnCalendar format
const cronParts = config.schedule.split(" ");
let onCalendar = "";
const cronParts = cfg.schedule.split(" ");
let onCalendar: string;
if (cronParts.length >= 5) {
const [minute, hour, day, _month, weekday] = cronParts;
onCalendar = `OnCalendar=`;
if (weekday !== "*") {
onCalendar += `*-*-* ${hour}:${minute}:00`;
onCalendar = `OnCalendar=*-*-* ${hour}:${minute}:00`;
} else {
onCalendar += `*-*-${day} ${hour}:${minute}:00`;
onCalendar = `OnCalendar=*-*-${day} ${hour}:${minute}:00`;
}
} else {
onCalendar = `OnCalendar=${config.schedule}`;
onCalendar = `OnCalendar=${cfg.schedule}`;
}
const timerUnit = `[Unit]
Description=Timer for ${config.name}
Description=Timer for ${cfg.name}
Requires=${serviceFileName}
[Timer]
@@ -109,12 +90,12 @@ WantedBy=timers.target
`;
const serviceUnit = `[Unit]
Description=${config.description || config.name}
Description=${cfg.description || cfg.name}
After=network.target
[Service]
Type=oneshot
${config.user ? `User=${config.user}\n` : ""}${config.workingDir ? `WorkingDirectory=${config.workingDir}\n` : ""}ExecStart=/bin/bash -c '${config.command}'
${cfg.user ? `User=${cfg.user}\n` : ""}${cfg.workingDir ? `WorkingDirectory=${cfg.workingDir}\n` : ""}ExecStart=/bin/bash -c '${cfg.command}'
`;
return `# ${timerName}
@@ -122,263 +103,222 @@ ${timerUnit}
# ${serviceFileName}
${serviceUnit}
# Installation:
# 1. Save ${timerName} to /etc/systemd/system/
# 2. Save ${serviceFileName} to /etc/systemd/system/
# 3. Run: sudo systemctl daemon-reload
# 4. Run: sudo systemctl enable --now ${timerName}
`;
}, []);
const updateOutput = useCallback(() => {
let result = "";
const output = useMemo(() => {
switch (scheduleType) {
case "cron":
result = generateCron(config);
break;
return generateCron(effectiveConfig);
case "github-actions":
result = generateGitHubActions(config);
break;
return generateGitHubActions(effectiveConfig);
case "systemd-timer":
result = generateSystemdTimer(config);
break;
return generateSystemdTimer(effectiveConfig);
default:
return "";
}
setOutput(result);
}, [scheduleType, config, generateCron, generateGitHubActions, generateSystemdTimer]);
}, [
scheduleType,
effectiveConfig,
generateCron,
generateGitHubActions,
generateSystemdTimer,
]);
useEffect(() => {
setConfig((prev) => ({ ...prev, type: scheduleType }));
}, [scheduleType]);
useEffect(() => {
updateOutput();
}, [updateOutput]);
const copyToClipboard = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
}
}, []);
const downloadFile = useCallback((content: string, filename: string, mimeType: string) => {
try {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error("Failed to download file:", error);
}
}, []);
const getFilename = useCallback((): string => {
const filename = useMemo(() => {
switch (scheduleType) {
case "cron":
return "crontab.txt";
return "crontab";
case "github-actions":
return ".github/workflows/schedule.yml";
case "systemd-timer":
return `${config.name.replace(/[^a-zA-Z0-9]/g, "-")}.timer`;
return `${(config.name || "task").replace(/[^a-zA-Z0-9]/g, "-")}.timer`;
default:
return "schedule.txt";
}
}, [scheduleType, config.name]);
const handleNav = (view: BuilderView) => {
if (view === "compose") navigate({ to: "/docker/compose-builder" });
else if (view === "config") navigate({ to: "/config-builder" });
else if (view === "blueprint") navigate({ to: "/blueprint-builder" });
};
return (
<SidebarProvider defaultOpen={true}>
<Sidebar collapsible="icon">
<SidebarUI />
</Sidebar>
<SidebarInset>
<div className="flex items-center gap-2 p-2 border-b">
<SidebarTrigger />
<h1 className="text-xl font-bold">Scheduler Builder</h1>
<div className="builder-shell builder-shell--three">
<SetupSidebar view="scheduler" onNav={handleNav} />
<section className="config-col">
<div className="col-head">
<h2 className="col-head-title">
<span className="col-bar" aria-hidden />
Schedule Type
</h2>
</div>
<div className="config-tabs">
<button
type="button"
className={
"config-tab" + (scheduleType === "cron" ? " active" : "")
}
onClick={() => setScheduleType("cron")}
>
Cron
</button>
<button
type="button"
className={
"config-tab" +
(scheduleType === "github-actions" ? " active" : "")
}
onClick={() => setScheduleType("github-actions")}
>
GitHub Actions
</button>
<button
type="button"
className={
"config-tab" +
(scheduleType === "systemd-timer" ? " active" : "")
}
onClick={() => setScheduleType("systemd-timer")}
>
systemd
</button>
</div>
<div className="config-tab-body">
<div className="tab-content">
<p className="tab-hint">
{scheduleType === "cron" &&
"Generate a /etc/cron.d-friendly line with optional user and working dir."}
{scheduleType === "github-actions" &&
"Emit a workflow file with a cron schedule trigger."}
{scheduleType === "systemd-timer" &&
"Produce a paired .timer + .service systemd unit."}
</p>
<div className="tab-empty">
<h3 className="tab-empty-title">Schedule preview</h3>
<p className="tab-empty-sub">
{effectiveConfig.schedule || "(no cron set)"}
</p>
</div>
</div>
<div className="p-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="p-4">
<div className="mb-4">
<Label className="mb-2 block">Scheduler Type</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full">
{scheduleType === "cron" && "Cron"}
{scheduleType === "github-actions" && "GitHub Actions"}
{scheduleType === "systemd-timer" && "Systemd Timer"}
<Settings className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setScheduleType("cron")}>
Cron
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setScheduleType("github-actions")}>
GitHub Actions
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setScheduleType("systemd-timer")}>
Systemd Timer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</section>
<Separator className="my-4" />
{/* Form column */}
<section className="service-col">
<div className="col-head">
<h2 className="col-head-title">
<span className="col-bar" aria-hidden />
Task Configuration
</h2>
</div>
<div className="svc-tab">
<Field label="Name" required>
<input
className="input"
value={config.name}
onChange={(e) => setConfig({ ...config, name: e.target.value })}
placeholder="nightly-backup"
/>
</Field>
<div className="space-y-4">
<div>
<Label className="mb-1 block">Name</Label>
<Input
value={config.name}
onChange={(e) =>
setConfig({ ...config, name: e.target.value })
}
placeholder="Task name"
/>
</div>
<Field
label="Schedule"
hint='Cron format: "minute hour day month weekday" — e.g. "0 0 * * *"'
required
>
<input
className="input"
value={config.schedule}
onChange={(e) =>
setConfig({ ...config, schedule: e.target.value })
}
placeholder="0 0 * * *"
/>
</Field>
<div>
<Label className="mb-1 block">
Schedule {scheduleType === "cron" && "(Cron format: minute hour day month weekday)"}
{scheduleType === "github-actions" && "(Cron format: minute hour day month weekday)"}
{scheduleType === "systemd-timer" && "(Cron format or systemd OnCalendar)"}
</Label>
<Input
value={config.schedule}
onChange={(e) =>
setConfig({ ...config, schedule: e.target.value })
}
placeholder={
scheduleType === "cron" || scheduleType === "github-actions"
? "0 0 * * * (daily at midnight)"
: "0 0 * * * or *-*-* 00:00:00"
}
/>
<div className="text-xs text-muted-foreground mt-1">
Examples: "0 0 * * *" (daily), "0 */6 * * *" (every 6 hours), "0 0 1 * *" (monthly)
</div>
</div>
<Field label="Command" required>
<textarea
className="input"
rows={4}
value={config.command}
onChange={(e) =>
setConfig({ ...config, command: e.target.value })
}
placeholder="/usr/local/bin/backup.sh"
/>
</Field>
<div>
<Label className="mb-1 block">Command</Label>
<Textarea
value={config.command}
onChange={(e) =>
setConfig({ ...config, command: e.target.value })
}
placeholder="Command to execute"
className="font-mono"
rows={4}
/>
</div>
<Field label="Description">
<input
className="input"
value={config.description || ""}
onChange={(e) =>
setConfig({ ...config, description: e.target.value })
}
placeholder="What does this task do?"
/>
</Field>
<div>
<Label className="mb-1 block">Description (optional)</Label>
<Input
value={config.description || ""}
onChange={(e) =>
setConfig({ ...config, description: e.target.value })
}
placeholder="Task description"
/>
</div>
{(scheduleType === "cron" || scheduleType === "systemd-timer") && (
<div className="svc-grid-2">
<Field label="User">
<input
className="input"
value={config.user || ""}
onChange={(e) =>
setConfig({ ...config, user: e.target.value })
}
placeholder="root"
/>
</Field>
<Field label="Working directory">
<input
className="input"
value={config.workingDir || ""}
onChange={(e) =>
setConfig({ ...config, workingDir: e.target.value })
}
placeholder="/srv/app"
/>
</Field>
</div>
)}
</div>
</section>
{scheduleType === "cron" && (
<>
<div>
<Label className="mb-1 block">User (optional)</Label>
<Input
value={config.user || ""}
onChange={(e) =>
setConfig({ ...config, user: e.target.value })
}
placeholder="root"
/>
</div>
<div>
<Label className="mb-1 block">Working Directory (optional)</Label>
<Input
value={config.workingDir || ""}
onChange={(e) =>
setConfig({ ...config, workingDir: e.target.value })
}
placeholder="/path/to/directory"
/>
</div>
</>
)}
{scheduleType === "systemd-timer" && (
<>
<div>
<Label className="mb-1 block">User (optional)</Label>
<Input
value={config.user || ""}
onChange={(e) =>
setConfig({ ...config, user: e.target.value })
}
placeholder="root"
/>
</div>
<div>
<Label className="mb-1 block">Working Directory (optional)</Label>
<Input
value={config.workingDir || ""}
onChange={(e) =>
setConfig({ ...config, workingDir: e.target.value })
}
placeholder="/path/to/directory"
/>
</div>
</>
)}
</div>
</Card>
<Card className="p-4">
<div className="mb-4 flex items-center justify-between">
<Label className="text-lg font-bold">Generated Schedule</Label>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(output)}
>
<Copy className="h-4 w-4 mr-2" />
Copy
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
downloadFile(
output,
getFilename(),
scheduleType === "github-actions" ? "text/yaml" : "text/plain"
)
}
>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
</div>
</div>
<CodeEditor
content={output}
onContentChange={(content) => setOutput(content)}
width={600}
height={600}
/>
</Card>
<section className="code-col">
<div className="code-head">
<h2 className="code-title">
<span className="col-bar" aria-hidden />
Output
</h2>
</div>
<div className="code-tabs">
<button type="button" className="code-tab active">
{filename}
</button>
</div>
<div className="code-window">
<pre className="code-pre">
<code>{output || "# Fill the form to generate output."}</code>
</pre>
<div className="code-statusbar">
<span className="status-tag">
<span className="status-dot ok" aria-hidden />
Ready
</span>
<span className="status-mono">
{output.split("\n").length} lines · UTF-8 · LF
</span>
<span className="status-mono right">{filename}</span>
</div>
</SidebarInset>
</SidebarProvider>
</div>
</section>
</div>
);
}
+2603 -191
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
// Pangolin Blueprint schema — mirrors fosrl/blueprints.
// A Blueprint is a docker-compose.yml augmented with `pangolin.*` Docker
// labels + a sibling .env.example. Each Resource targets a service key in the
// underlying compose document and emits labels under a `blueprintName` slug.
export type BlueprintProtocol = "http" | "https" | "tcp" | "udp";
export interface BlueprintTarget {
method: BlueprintProtocol;
// Optional override — Pangolin defaults to the container_name.
hostname?: string;
// Optional override — Pangolin defaults to the expose port.
port?: number;
}
export interface BlueprintAuth {
pincode: string;
password: string;
basicUser: string;
basicPassword: string;
ssoEnabled: boolean;
ssoRoles: string[];
ssoUsers: string[];
whitelistUsers: string[];
}
export interface BlueprintResource {
// The compose service key — labels are injected on this service.
serviceContainerName: string;
// Pangolin label namespace (URL-safe slug).
blueprintName: string;
// Human-readable Pangolin resource name.
resourceName: string;
// Subdomain prefix; full domain = `${subdomain}.${baseDomain}`.
subdomain: string;
// Internal port Pangolin should reach.
servicePort: number;
// Image kept verbatim from the imported compose, used in the .env.example.
image: string;
// Protocol used by the auto-derived target[0] (and the resource label).
protocol: BlueprintProtocol;
// Optional explicit target[0] hostname/port overrides.
targetHostname?: string;
targetPort?: number;
// Additional targets beyond targets[0].
extraTargets: BlueprintTarget[];
// Resource-level auth additions/overrides.
auth: BlueprintAuth;
}
export interface Blueprint {
version: 1;
baseDomain: string;
pangolinNetwork: string; // default "pangolin_default"
resources: BlueprintResource[];
// Raw compose document parsed from the imported template — preserved so
// we re-emit it verbatim with labels injected. null = empty / no import.
composeDocument: Record<string, unknown> | null;
}
+33
View File
@@ -30,11 +30,44 @@ export interface TailscaleConfig {
serveConfigPath: string;
}
export type NewtLogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" | "FATAL";
export interface NewtConfig {
// Core (existing)
endpoint: string;
newtId: string;
newtSecret: string;
networkName: string;
// Pangolin Newt extras (see docs.pangolin.net/manage/sites/configure-site).
// Optional — empty string / false / 0 = "use Newt default".
name: string;
port: string;
mtu: string;
dns: string;
logLevel: NewtLogLevel;
interfaceName: string;
pingInterval: string;
pingTimeout: string;
configFile: string;
healthFile: string;
dockerSocket: string;
dockerEnforceNetworkValidation: boolean;
updown: string;
blueprintFile: string;
provisioningBlueprintFile: string;
provisioningKey: string;
noCloud: boolean;
disableClients: boolean;
metrics: boolean;
otlp: boolean;
metricsAdminAddr: string;
enforceHcCert: boolean;
tlsClientCertFile: string;
tlsClientKey: string;
tlsClientCa: string;
preferEndpoint: string;
region: string;
}
export interface CloudflaredConfig {
@@ -0,0 +1,861 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`golden / convertToDockerRun > docker run — caps-security-and-lifecycle 1`] = `
"# tailscale-sidecar
docker run --restart unless-stopped --security-opt no-new-privileges:true tailscale/tailscale:stable"
`;
exports[`golden / convertToDockerRun > docker run — env-dict-with-named-volume 1`] = `
"# db
docker run --restart unless-stopped -v pg-data:/var/lib/postgresql/data -v ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro -e POSTGRES_USER=app -e POSTGRES_PASSWORD=\${POSTGRES_PASSWORD} -e POSTGRES_DB=app postgres:16"
`;
exports[`golden / convertToDockerRun > docker run — healthcheck-and-deploy-resources 1`] = `
"# api
docker run --restart unless-stopped -p 8080:8080/tcp ghcr.io/example/api:1.0.0"
`;
exports[`golden / convertToDockerRun > docker run — minimal-single-service 1`] = `
"# web
docker run --restart unless-stopped nginx:latest"
`;
exports[`golden / convertToDockerRun > docker run — multi-service-with-deps-and-networks 1`] = `
"# web
docker run --restart unless-stopped -p 80:80/tcp --network frontend nginx:1.27-alpine
# api
docker run --restart unless-stopped -e DATABASE_URL=\${DATABASE_URL} --network frontend node:22-alpine
# db
docker run --restart unless-stopped -v pg-data:/var/lib/postgresql/data -e POSTGRES_USER=app -e POSTGRES_PASSWORD=\${POSTGRES_PASSWORD} --network backend postgres:16"
`;
exports[`golden / convertToDockerRun > docker run — ports-and-env-array 1`] = `
"# api
docker run --restart always -p 3000:3000/tcp -p 9229:9229/tcp -e NODE_ENV=production -e PORT=3000 -e DATABASE_URL=\${DATABASE_URL} node:22-alpine"
`;
exports[`golden / convertToDockerRun > docker run — vpn-cloudflared-tunnel 1`] = `
"# app
docker run --restart unless-stopped ghcr.io/example/app:latest"
`;
exports[`golden / convertToDockerRun > docker run — vpn-netbird-mesh 1`] = `
"# agent
docker run --restart unless-stopped ghcr.io/example/agent:latest"
`;
exports[`golden / convertToDockerRun > docker run — vpn-newt-tunnel 1`] = `
"# app
docker run --restart unless-stopped ghcr.io/example/app:latest"
`;
exports[`golden / convertToDockerRun > docker run — vpn-tailscale-attached 1`] = `
"# web
docker run --restart unless-stopped nginx:latest"
`;
exports[`golden / convertToDockerRun > docker run — vpn-wireguard-attached 1`] = `
"# torrent
docker run --restart unless-stopped linuxserver/qbittorrent:latest"
`;
exports[`golden / convertToDockerRun > docker run — vpn-zerotier-network 1`] = `
"# node
docker run --restart unless-stopped ghcr.io/example/node:latest"
`;
exports[`golden / convertToSystemd > systemd — caps-security-and-lifecycle 1`] = `
"### tailscale-sidecar.service
[Unit]
Description=Docker Container tailscale-sidecar
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start tailscale-sidecar
ExecStop=/usr/bin/docker stop tailscale-sidecar
Restart=on-failure
[Install]
WantedBy=multi-user.target
"
`;
exports[`golden / convertToSystemd > systemd — env-dict-with-named-volume 1`] = `
"### db.service
[Unit]
Description=Docker Container db
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start db
ExecStop=/usr/bin/docker stop db
Restart=on-failure
[Install]
WantedBy=multi-user.target
"
`;
exports[`golden / convertToSystemd > systemd — healthcheck-and-deploy-resources 1`] = `
"### api.service
[Unit]
Description=Docker Container api
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start api
ExecStop=/usr/bin/docker stop api
Restart=on-failure
[Install]
WantedBy=multi-user.target
"
`;
exports[`golden / convertToSystemd > systemd — minimal-single-service 1`] = `
"### web.service
[Unit]
Description=Docker Container web
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start web
ExecStop=/usr/bin/docker stop web
Restart=on-failure
[Install]
WantedBy=multi-user.target
"
`;
exports[`golden / convertToSystemd > systemd — multi-service-with-deps-and-networks 1`] = `
"### web.service
[Unit]
Description=Docker Container web
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start web
ExecStop=/usr/bin/docker stop web
Restart=on-failure
[Install]
WantedBy=multi-user.target
### api.service
[Unit]
Description=Docker Container api
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start api
ExecStop=/usr/bin/docker stop api
Restart=on-failure
[Install]
WantedBy=multi-user.target
### db.service
[Unit]
Description=Docker Container db
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start db
ExecStop=/usr/bin/docker stop db
Restart=on-failure
[Install]
WantedBy=multi-user.target
"
`;
exports[`golden / convertToSystemd > systemd — ports-and-env-array 1`] = `
"### api.service
[Unit]
Description=Docker Container api
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start api
ExecStop=/usr/bin/docker stop api
Restart=always
[Install]
WantedBy=multi-user.target
"
`;
exports[`golden / convertToSystemd > systemd — vpn-cloudflared-tunnel 1`] = `
"### app.service
[Unit]
Description=Docker Container app
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start app
ExecStop=/usr/bin/docker stop app
Restart=on-failure
[Install]
WantedBy=multi-user.target
"
`;
exports[`golden / convertToSystemd > systemd — vpn-netbird-mesh 1`] = `
"### agent.service
[Unit]
Description=Docker Container agent
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start agent
ExecStop=/usr/bin/docker stop agent
Restart=on-failure
[Install]
WantedBy=multi-user.target
"
`;
exports[`golden / convertToSystemd > systemd — vpn-newt-tunnel 1`] = `
"### app.service
[Unit]
Description=Docker Container app
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start app
ExecStop=/usr/bin/docker stop app
Restart=on-failure
[Install]
WantedBy=multi-user.target
"
`;
exports[`golden / convertToSystemd > systemd — vpn-tailscale-attached 1`] = `
"### web.service
[Unit]
Description=Docker Container web
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start web
ExecStop=/usr/bin/docker stop web
Restart=on-failure
[Install]
WantedBy=multi-user.target
"
`;
exports[`golden / convertToSystemd > systemd — vpn-wireguard-attached 1`] = `
"### torrent.service
[Unit]
Description=Docker Container torrent
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start torrent
ExecStop=/usr/bin/docker stop torrent
Restart=on-failure
[Install]
WantedBy=multi-user.target
"
`;
exports[`golden / convertToSystemd > systemd — vpn-zerotier-network 1`] = `
"### node.service
[Unit]
Description=Docker Container node
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start node
ExecStop=/usr/bin/docker stop node
Restart=on-failure
[Install]
WantedBy=multi-user.target
"
`;
exports[`golden / generateEnvFile > .env — caps-security-and-lifecycle 1`] = `""`;
exports[`golden / generateEnvFile > .env — env-dict-with-named-volume 1`] = `"POSTGRES_PASSWORD="`;
exports[`golden / generateEnvFile > .env — healthcheck-and-deploy-resources 1`] = `""`;
exports[`golden / generateEnvFile > .env — minimal-single-service 1`] = `""`;
exports[`golden / generateEnvFile > .env — multi-service-with-deps-and-networks 1`] = `
"DATABASE_URL=
POSTGRES_PASSWORD="
`;
exports[`golden / generateEnvFile > .env — ports-and-env-array 1`] = `"DATABASE_URL="`;
exports[`golden / generateEnvFile > .env — vpn-cloudflared-tunnel 1`] = `"TUNNEL_TOKEN="`;
exports[`golden / generateEnvFile > .env — vpn-netbird-mesh 1`] = `"NETBIRD_SETUP_KEY="`;
exports[`golden / generateEnvFile > .env — vpn-newt-tunnel 1`] = `
"NEWT_ID=
NEWT_SECRET="
`;
exports[`golden / generateEnvFile > .env — vpn-tailscale-attached 1`] = `"TS_AUTHKEY="`;
exports[`golden / generateEnvFile > .env — vpn-wireguard-attached 1`] = `""`;
exports[`golden / generateEnvFile > .env — vpn-zerotier-network 1`] = `"ZT_NETWORK_ID="`;
exports[`golden / generateKomodoToml > komodo TOML — caps-security-and-lifecycle 1`] = `
"# Komodo configuration generated from Portainer stack
# Generated from Docker Compose configuration
[tailscale-sidecar]
image = "tailscale/tailscale:stable"
restart = "unless-stopped"
"
`;
exports[`golden / generateKomodoToml > komodo TOML — env-dict-with-named-volume 1`] = `
"# Komodo configuration generated from Portainer stack
# Generated from Docker Compose configuration
[db]
image = "postgres:16"
restart = "unless-stopped"
volumes = [
"pg-data:/var/lib/postgresql/data",
"./init.sql:/docker-entrypoint-initdb.d/init.sql:ro",
]
environment = {}
environment.POSTGRES_USER = "app"
environment.POSTGRES_PASSWORD = "\${POSTGRES_PASSWORD}"
environment.POSTGRES_DB = "app"
"
`;
exports[`golden / generateKomodoToml > komodo TOML — healthcheck-and-deploy-resources 1`] = `
"# Komodo configuration generated from Portainer stack
# Generated from Docker Compose configuration
[api]
image = "ghcr.io/example/api:1.0.0"
restart = "unless-stopped"
ports = [
"8080:8080/tcp",
]
"
`;
exports[`golden / generateKomodoToml > komodo TOML — minimal-single-service 1`] = `
"# Komodo configuration generated from Portainer stack
# Generated from Docker Compose configuration
[web]
image = "nginx:latest"
restart = "unless-stopped"
"
`;
exports[`golden / generateKomodoToml > komodo TOML — multi-service-with-deps-and-networks 1`] = `
"# Komodo configuration generated from Portainer stack
# Generated from Docker Compose configuration
[web]
image = "nginx:1.27-alpine"
restart = "unless-stopped"
ports = [
"80:80/tcp",
]
[api]
image = "node:22-alpine"
restart = "unless-stopped"
environment = [
"DATABASE_URL=\${DATABASE_URL}",
]
[db]
image = "postgres:16"
restart = "unless-stopped"
volumes = [
"pg-data:/var/lib/postgresql/data",
]
environment = [
"POSTGRES_USER=app",
"POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}",
]
"
`;
exports[`golden / generateKomodoToml > komodo TOML — ports-and-env-array 1`] = `
"# Komodo configuration generated from Portainer stack
# Generated from Docker Compose configuration
[api]
image = "node:22-alpine"
restart = "always"
ports = [
"3000:3000/tcp",
"9229:9229/tcp",
]
environment = [
"NODE_ENV=production",
"PORT=3000",
"DATABASE_URL=\${DATABASE_URL}",
]
"
`;
exports[`golden / generateKomodoToml > komodo TOML — vpn-cloudflared-tunnel 1`] = `
"# Komodo configuration generated from Portainer stack
# Generated from Docker Compose configuration
[app]
image = "ghcr.io/example/app:latest"
restart = "unless-stopped"
[cloudflared]
image = "cloudflare/cloudflared"
restart = "always"
environment = {}
environment.TUNNEL_TOKEN = "\${TUNNEL_TOKEN}"
"
`;
exports[`golden / generateKomodoToml > komodo TOML — vpn-netbird-mesh 1`] = `
"# Komodo configuration generated from Portainer stack
# Generated from Docker Compose configuration
[agent]
image = "ghcr.io/example/agent:latest"
restart = "unless-stopped"
[netbird]
image = "netbirdio/netbird:latest"
restart = "always"
environment = {}
environment.NETBIRD_SETUP_KEY = "\${NETBIRD_SETUP_KEY}"
"
`;
exports[`golden / generateKomodoToml > komodo TOML — vpn-newt-tunnel 1`] = `
"# Komodo configuration generated from Portainer stack
# Generated from Docker Compose configuration
[app]
image = "ghcr.io/example/app:latest"
restart = "unless-stopped"
[newt]
image = "fosrl/newt"
container_name = "newt"
restart = "always"
environment = {}
environment.PANGOLIN_ENDPOINT = "https://app.pangolin.net"
environment.NEWT_ID = "\${NEWT_ID}"
environment.NEWT_SECRET = "\${NEWT_SECRET}"
"
`;
exports[`golden / generateKomodoToml > komodo TOML — vpn-tailscale-attached 1`] = `
"# Komodo configuration generated from Portainer stack
# Generated from Docker Compose configuration
[web]
image = "nginx:latest"
restart = "unless-stopped"
[tailscale]
image = "tailscale/tailscale:latest"
restart = "always"
volumes = [
"tailscale:/var/lib/tailscale",
"/dev/net/tun:/dev/net/tun",
]
environment = {}
environment.TS_STATE_DIR = "/var/lib/tailscale"
environment.TS_ACCEPT_DNS = "false"
environment.TS_AUTH_ONCE = "true"
environment.TS_USERSPACE = "false"
environment.TS_AUTHKEY = "$TS_AUTHKEY"
environment.TS_HOSTNAME = "edge"
"
`;
exports[`golden / generateKomodoToml > komodo TOML — vpn-wireguard-attached 1`] = `
"# Komodo configuration generated from Portainer stack
# Generated from Docker Compose configuration
[torrent]
image = "linuxserver/qbittorrent:latest"
restart = "unless-stopped"
[wireguard]
image = "linuxserver/wireguard:latest"
restart = "always"
volumes = [
"/etc/wireguard/wg0.conf:/config",
]
environment = {}
environment.PUID = "1000"
environment.PGID = "1000"
environment.TZ = "Etc/UTC"
"
`;
exports[`golden / generateKomodoToml > komodo TOML — vpn-zerotier-network 1`] = `
"# Komodo configuration generated from Portainer stack
# Generated from Docker Compose configuration
[node]
image = "ghcr.io/example/node:latest"
restart = "unless-stopped"
[zerotier]
image = "zerotier/zerotier:latest"
restart = "always"
volumes = [
"/var/lib/zerotier-one:/var/lib/zerotier-one",
]
environment = {}
environment.ZT_NC_NETWORK = "\${ZT_NETWORK_ID}"
"
`;
exports[`golden / generateYaml > compose YAML — caps-security-and-lifecycle 1`] = `
"services:
tailscale-sidecar:
image: "tailscale/tailscale:stable"
restart: unless-stopped
privileged: false
read_only: false
security_opt:
- no-new-privileges:true
cap_add:
- NET_ADMIN
- NET_RAW
cap_drop:
- ALL
sysctls:
net.ipv4.ip_forward: 1
devices:
- /dev/net/tun:/dev/net/tun
tmpfs:
- /tmp
ulimits:
nofile:
soft: 65535
hard: 65535
init: true
stop_grace_period: "10s"
stop_signal: SIGTERM
tty: true
stdin_open: true
hostname: ts-host"
`;
exports[`golden / generateYaml > compose YAML — env-dict-with-named-volume 1`] = `
"services:
db:
image: "postgres:16"
restart: unless-stopped
volumes:
- pg-data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: "\${POSTGRES_PASSWORD}"
POSTGRES_DB: app
volumes:
pg-data:
driver: local"
`;
exports[`golden / generateYaml > compose YAML — healthcheck-and-deploy-resources 1`] = `
"services:
api:
image: "ghcr.io/example/api:1.0.0"
restart: unless-stopped
ports:
- 8080:8080/tcp
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health", "||", "exit", "1"]
interval: "30s"
timeout: "5s"
retries: 3
start_period: "10s"
start_interval: "5s"
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(\`api.example.com\`)
deploy:
resources:
limits:
cpus: "1.0"
memory: "512M"
reservations:
cpus: "0.25"
memory: "128M""
`;
exports[`golden / generateYaml > compose YAML — minimal-single-service 1`] = `
"services:
web:
image: "nginx:latest"
restart: unless-stopped"
`;
exports[`golden / generateYaml > compose YAML — multi-service-with-deps-and-networks 1`] = `
"services:
web:
image: "nginx:1.27-alpine"
restart: unless-stopped
ports:
- 80:80/tcp
depends_on:
- api
networks:
- frontend
api:
image: "node:22-alpine"
restart: unless-stopped
environment:
- DATABASE_URL=\${DATABASE_URL}
depends_on:
- db
networks:
- frontend
- backend
db:
image: "postgres:16"
restart: unless-stopped
volumes:
- pg-data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}
networks:
- backend
networks:
frontend:
driver: bridge
attachable: false
internal: false
enable_ipv6: false
backend:
driver: bridge
attachable: false
internal: true
enable_ipv6: false
volumes:
pg-data:
driver: local"
`;
exports[`golden / generateYaml > compose YAML — ports-and-env-array 1`] = `
"services:
api:
image: "node:22-alpine"
restart: always
ports:
- 3000:3000/tcp
- 9229:9229/tcp
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=\${DATABASE_URL}"
`;
exports[`golden / generateYaml > compose YAML — vpn-cloudflared-tunnel 1`] = `
"services:
app:
image: "ghcr.io/example/app:latest"
restart: unless-stopped
# cloudflared VPN Sidecar Configuration
cloudflared:
restart: always
image: cloudflare/cloudflared
command: "--no-autoupdate tunnel run"
environment:
TUNNEL_TOKEN: "\${TUNNEL_TOKEN}""
`;
exports[`golden / generateYaml > compose YAML — vpn-netbird-mesh 1`] = `
"services:
agent:
image: "ghcr.io/example/agent:latest"
restart: unless-stopped
# netbird VPN Sidecar Configuration
netbird:
restart: always
image: "netbirdio/netbird:latest"
privileged: true
cap_add:
- NET_ADMIN
- SYS_MODULE
sysctls:
- net.ipv4.ip_forward=1
- net.ipv6.conf.all.forwarding=1
environment:
NETBIRD_SETUP_KEY: "\${NETBIRD_SETUP_KEY}""
`;
exports[`golden / generateYaml > compose YAML — vpn-newt-tunnel 1`] = `
"services:
app:
image: "ghcr.io/example/app:latest"
restart: unless-stopped
networks:
- newt
newt:
restart: always
image: fosrl/newt
container_name: newt
environment:
PANGOLIN_ENDPOINT: "https://app.pangolin.net"
NEWT_ID: "\${NEWT_ID}"
NEWT_SECRET: "\${NEWT_SECRET}"
networks:
- newt
networks:
newt:
external:
name: newt"
`;
exports[`golden / generateYaml > compose YAML — vpn-tailscale-attached 1`] = `
"services:
web:
image: "nginx:latest"
restart: unless-stopped
network_mode: "service:tailscale"
# tailscale VPN Sidecar Configuration
# Routes traffic through Tailscale VPN
# Hostname: edge
tailscale:
restart: always
image: "tailscale/tailscale:latest"
privileged: true
volumes:
- tailscale:/var/lib/tailscale
- /dev/net/tun:/dev/net/tun
environment:
TS_STATE_DIR: /var/lib/tailscale
TS_ACCEPT_DNS: false
TS_AUTH_ONCE: true
TS_USERSPACE: false
TS_AUTHKEY: $TS_AUTHKEY
TS_HOSTNAME: edge
volumes:
tailscale:"
`;
exports[`golden / generateYaml > compose YAML — vpn-wireguard-attached 1`] = `
"services:
torrent:
image: "linuxserver/qbittorrent:latest"
restart: unless-stopped
# wireguard VPN Sidecar Configuration
wireguard:
restart: always
image: "linuxserver/wireguard:latest"
cap_add:
- NET_ADMIN
- SYS_MODULE
environment:
PUID: 1000
PGID: 1000
TZ: Etc/UTC
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
volumes:
- /etc/wireguard/wg0.conf:/config"
`;
exports[`golden / generateYaml > compose YAML — vpn-zerotier-network 1`] = `
"services:
node:
image: "ghcr.io/example/node:latest"
restart: unless-stopped
# zerotier VPN Sidecar Configuration
zerotier:
restart: always
image: "zerotier/zerotier:latest"
privileged: true
networks:
- host
volumes:
- /var/lib/zerotier-one:/var/lib/zerotier-one
environment:
ZT_NC_NETWORK: "\${ZT_NETWORK_ID}""
`;
@@ -0,0 +1,69 @@
// Golden tests freeze the byte-for-byte output of every public generator.
// These run BEFORE any decomposition of yaml-generator.ts / converters.ts
// and remain green AFTER decomposition — proving parity.
//
// To regenerate (only when behavior intentionally changes):
// pnpm test -- -u
import { describe, expect, it } from "vitest";
import { load as parseYaml } from "js-yaml";
import { generateYaml } from "../yaml-generator";
import {
convertToDockerRun,
convertToSystemd,
generateKomodoToml,
generateEnvFile,
} from "../converters";
import { SCENARIOS } from "../../__tests__/fixtures";
describe("golden / generateYaml", () => {
for (const sc of SCENARIOS) {
it(`compose YAML — ${sc.name}`, () => {
const yaml = generateYaml(sc.services, sc.networks, sc.volumes, sc.vpn);
expect(yaml).toMatchSnapshot();
// Sanity: must be parseable YAML
expect(() => parseYaml(yaml)).not.toThrow();
});
}
});
describe("golden / convertToDockerRun", () => {
for (const sc of SCENARIOS) {
if (sc.services.length === 0) continue;
it(`docker run — ${sc.name}`, () => {
// convertToDockerRun is per-service; snapshot every service
const out = sc.services.map((s) => `# ${s.name}\n${convertToDockerRun(s)}`).join("\n\n");
expect(out).toMatchSnapshot();
});
}
});
describe("golden / convertToSystemd", () => {
for (const sc of SCENARIOS) {
if (sc.services.length === 0) continue;
it(`systemd — ${sc.name}`, () => {
const out = sc.services.map((s) => `### ${s.name}.service\n${convertToSystemd(s)}`).join("\n\n");
expect(out).toMatchSnapshot();
});
}
});
describe("golden / generateKomodoToml", () => {
for (const sc of SCENARIOS) {
it(`komodo TOML — ${sc.name}`, () => {
const yaml = generateYaml(sc.services, sc.networks, sc.volumes, sc.vpn);
const toml = generateKomodoToml(yaml);
expect(toml).toMatchSnapshot();
});
}
});
describe("golden / generateEnvFile", () => {
for (const sc of SCENARIOS) {
it(`.env — ${sc.name}`, () => {
const env = generateEnvFile(sc.services, sc.vpn);
expect(env).toMatchSnapshot();
});
}
});
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { parseComposeService, parseComposeTemplate } from "../template-import";
describe("parseComposeTemplate", () => {
it("parses a single-service compose", () => {
const yaml = `
services:
web:
image: nginx:1.27-alpine
restart: unless-stopped
ports:
- "80:80"
`;
const result = parseComposeTemplate(yaml);
expect(result.services.length).toBe(1);
expect(result.services[0].name).toBe("web");
expect(result.services[0].image).toBe("nginx:1.27-alpine");
});
it("parses multi-service compose with networks and volumes", () => {
const yaml = `
services:
web:
image: nginx:latest
networks: [frontend]
api:
image: node:22-alpine
depends_on:
- db
networks: [frontend, backend]
db:
image: postgres:16
volumes:
- pg-data:/var/lib/postgresql/data
networks: [backend]
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true
volumes:
pg-data:
driver: local
`;
const result = parseComposeTemplate(yaml);
expect(result.services.map((s) => s.name).sort()).toEqual(["api", "db", "web"]);
expect(Object.keys(result.networks || {})).toEqual(
expect.arrayContaining(["frontend", "backend"]),
);
expect(Object.keys(result.volumes || {})).toContain("pg-data");
});
it("tolerates env vars in both array and dict syntax", () => {
const yamlArray = `
services:
app:
image: example/app
environment:
- NODE_ENV=production
- PORT=3000
`;
const yamlDict = `
services:
app:
image: example/app
environment:
NODE_ENV: production
PORT: "3000"
`;
const a = parseComposeTemplate(yamlArray);
const b = parseComposeTemplate(yamlDict);
const aParsed = parseComposeService(a.services[0]).service;
const bParsed = parseComposeService(b.services[0]).service;
expect(aParsed.environment.length).toBe(2);
expect(bParsed.environment.length).toBe(2);
const aKeys = aParsed.environment.map((e) => e.key).sort();
const bKeys = bParsed.environment.map((e) => e.key).sort();
expect(aKeys).toEqual(bKeys);
});
});
+334
View File
@@ -0,0 +1,334 @@
import { describe, expect, it } from "vitest";
import {
redactSensitiveData,
validateContainerName,
validateCpuValue,
validateDuration,
validateEnvVarKey,
validateExtraHost,
validateHostname,
validateImage,
validateIpAddress,
validateIpv4Cidr,
validateMemoryValue,
validateNetworkDriver,
validateNetworkMode,
validateNetworks,
validatePort,
validateServiceName,
validateServices,
validateStopSignal,
validateUser,
validateVolumes,
} from "../validation";
import {
defaultNetwork,
defaultService,
defaultVolume,
} from "../default-configs";
describe("validateServiceName", () => {
it("rejects empty", () => {
expect(validateServiceName("")).toBeTruthy();
});
it("accepts alphanumeric / hyphen / underscore / dot", () => {
expect(validateServiceName("web-app_01")).toBeNull();
expect(validateServiceName("web.app")).toBeNull();
});
it("rejects special characters", () => {
expect(validateServiceName("web@app")).toBeTruthy();
expect(validateServiceName("web app")).toBeTruthy();
});
it("rejects names starting with non-alphanumeric", () => {
expect(validateServiceName("-web")).toBeTruthy();
expect(validateServiceName(".web")).toBeTruthy();
});
});
describe("validatePort", () => {
it("accepts empty (port optional)", () => {
expect(validatePort("")).toBeNull();
});
it("accepts valid port", () => {
expect(validatePort("8080")).toBeNull();
expect(validatePort("1")).toBeNull();
expect(validatePort("65535")).toBeNull();
});
it("rejects out-of-range", () => {
expect(validatePort("0")).toBeTruthy();
expect(validatePort("65536")).toBeTruthy();
expect(validatePort("-1")).toBeTruthy();
});
});
describe("validateEnvVarKey", () => {
it("accepts well-formed names", () => {
expect(validateEnvVarKey("NODE_ENV")).toBeNull();
expect(validateEnvVarKey("_PRIVATE")).toBeNull();
expect(validateEnvVarKey("DATABASE_URL_2")).toBeNull();
});
it("rejects names starting with digit", () => {
expect(validateEnvVarKey("2NODE")).toBeTruthy();
});
it("rejects hyphens/spaces", () => {
expect(validateEnvVarKey("NODE-ENV")).toBeTruthy();
expect(validateEnvVarKey("NODE ENV")).toBeTruthy();
});
});
describe("validateCpuValue", () => {
it("accepts decimals", () => {
expect(validateCpuValue("0.5")).toBeNull();
expect(validateCpuValue("1")).toBeNull();
expect(validateCpuValue("2.25")).toBeNull();
});
it("rejects non-numeric", () => {
expect(validateCpuValue("half")).toBeTruthy();
});
});
describe("validateMemoryValue", () => {
it("accepts unit suffixes", () => {
expect(validateMemoryValue("512m")).toBeNull();
expect(validateMemoryValue("2g")).toBeNull();
expect(validateMemoryValue("1024")).toBeNull();
expect(validateMemoryValue("256M")).toBeNull();
expect(validateMemoryValue("100kb")).toBeNull();
});
it("rejects bogus units", () => {
expect(validateMemoryValue("512xx")).toBeTruthy();
});
});
describe("validateImage", () => {
it("accepts common image references", () => {
expect(validateImage("nginx")).toBeNull();
expect(validateImage("nginx:latest")).toBeNull();
expect(validateImage("nginx:1.25.3-alpine")).toBeNull();
expect(validateImage("library/redis:7")).toBeNull();
expect(validateImage("ghcr.io/user/app:v1.2.3")).toBeNull();
expect(validateImage("registry.example.com:5000/team/app:tag")).toBeNull();
expect(
validateImage(
"nginx@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
),
).toBeNull();
});
it("rejects whitespace / uppercase", () => {
expect(validateImage("Foo Bar:latest")).toBeTruthy();
expect(validateImage("")).toBeTruthy();
});
});
describe("validateContainerName", () => {
it("accepts empty (optional)", () => {
expect(validateContainerName("")).toBeNull();
});
it("rejects names with spaces", () => {
expect(validateContainerName("my container")).toBeTruthy();
});
it("accepts dot/dash/underscore", () => {
expect(validateContainerName("my-app_01")).toBeNull();
expect(validateContainerName("my.app")).toBeNull();
});
});
describe("validateHostname", () => {
it("accepts RFC 1123 labels", () => {
expect(validateHostname("my-host")).toBeNull();
expect(validateHostname("h0st1")).toBeNull();
});
it("rejects bad starts / underscores", () => {
expect(validateHostname("-bad")).toBeTruthy();
expect(validateHostname("my_host")).toBeTruthy();
});
});
describe("validateUser", () => {
it("accepts uid, uid:gid, name, name:group", () => {
expect(validateUser("")).toBeNull();
expect(validateUser("1000")).toBeNull();
expect(validateUser("1000:1000")).toBeNull();
expect(validateUser("appuser")).toBeNull();
expect(validateUser("appuser:appgroup")).toBeNull();
});
it("rejects spaces / @", () => {
expect(validateUser("foo@bar")).toBeTruthy();
expect(validateUser("1 2")).toBeTruthy();
});
});
describe("validateDuration", () => {
it("accepts compose-spec durations", () => {
expect(validateDuration("30s")).toBeNull();
expect(validateDuration("500ms")).toBeNull();
expect(validateDuration("1h30m")).toBeNull();
expect(validateDuration("2m")).toBeNull();
});
it("rejects bare numbers / wrong units", () => {
expect(validateDuration("30")).toBeTruthy();
expect(validateDuration("30sec")).toBeTruthy();
});
});
describe("validateStopSignal", () => {
it("accepts SIGFOO or numbers", () => {
expect(validateStopSignal("SIGTERM")).toBeNull();
expect(validateStopSignal("SIGKILL")).toBeNull();
expect(validateStopSignal("9")).toBeNull();
});
it("rejects garbage", () => {
expect(validateStopSignal("term")).toBeTruthy();
});
});
describe("validateNetworkMode", () => {
it("accepts known modes", () => {
expect(validateNetworkMode("")).toBeNull();
expect(validateNetworkMode("host")).toBeNull();
expect(validateNetworkMode("none")).toBeNull();
expect(validateNetworkMode("bridge")).toBeNull();
expect(validateNetworkMode("service:db")).toBeNull();
expect(validateNetworkMode("container:other")).toBeNull();
});
it("rejects unknown modes", () => {
expect(validateNetworkMode("hostly")).toBeTruthy();
});
});
describe("validateNetworkDriver", () => {
it("accepts known drivers", () => {
expect(validateNetworkDriver("bridge")).toBeNull();
expect(validateNetworkDriver("overlay")).toBeNull();
});
it("rejects unknown drivers", () => {
expect(validateNetworkDriver("flannel")).toBeTruthy();
});
});
describe("validateIpAddress / validateIpv4Cidr", () => {
it("validates IPv4", () => {
expect(validateIpAddress("1.1.1.1")).toBeNull();
expect(validateIpAddress("256.0.0.1")).toBeTruthy();
});
it("validates IPv6 (loosely)", () => {
expect(validateIpAddress("2001:db8::1")).toBeNull();
});
it("validates IPv4 CIDR", () => {
expect(validateIpv4Cidr("10.0.0.0/16")).toBeNull();
expect(validateIpv4Cidr("10.0.0.0/33")).toBeTruthy();
expect(validateIpv4Cidr("10.0.0.0")).toBeTruthy();
});
});
describe("validateExtraHost", () => {
it("accepts host:ip pairs", () => {
expect(validateExtraHost("api.local:192.168.1.10")).toBeNull();
});
it("rejects missing IP", () => {
expect(validateExtraHost("api.local")).toBeTruthy();
});
});
describe("validateServices", () => {
it("returns errors for an empty service", () => {
const errors = validateServices([defaultService()]);
expect(errors.length).toBeGreaterThan(0);
});
it("returns no errors for a valid service", () => {
const svc = { ...defaultService(), name: "web", image: "nginx:latest" };
const errors = validateServices([svc]);
expect(errors).toEqual([]);
});
it("flags invalid image format", () => {
const svc = {
...defaultService(),
name: "web",
image: "Foo Bar:latest",
};
const errors = validateServices([svc]);
expect(errors.some((e) => /Image/i.test(e) || /reference/i.test(e))).toBe(
true,
);
});
it("flags out-of-range ports", () => {
const svc = {
...defaultService(),
name: "web",
image: "nginx",
ports: [{ host: "999999", container: "80", protocol: "tcp" }],
};
const errors = validateServices([svc]);
expect(errors.some((e) => /port/i.test(e))).toBe(true);
});
it("flags duplicate service names", () => {
const a = { ...defaultService(), name: "web", image: "nginx" };
const b = { ...defaultService(), name: "web", image: "nginx" };
const errors = validateServices([a, b]);
expect(errors.some((e) => /Duplicate/i.test(e))).toBe(true);
});
it("flags invalid healthcheck duration", () => {
const svc = {
...defaultService(),
name: "web",
image: "nginx",
healthcheck: {
test: "curl -f http://localhost/",
interval: "30sec",
timeout: "",
retries: "",
start_period: "",
start_interval: "",
},
};
const errors = validateServices([svc]);
expect(errors.some((e) => /healthcheck.interval/i.test(e))).toBe(true);
});
});
describe("validateNetworks", () => {
it("flags invalid CIDR and unknown driver", () => {
const net = {
...defaultNetwork(),
name: "frontend",
driver: "flannel",
ipam: {
...defaultNetwork().ipam,
config: [{ subnet: "not-a-cidr", gateway: "1.1.1.1" }],
},
};
const errors = validateNetworks([net]);
expect(errors.some((e) => /driver/i.test(e))).toBe(true);
expect(errors.some((e) => /subnet|CIDR/i.test(e))).toBe(true);
});
});
describe("validateVolumes", () => {
it("flags empty name", () => {
const errors = validateVolumes([defaultVolume()]);
expect(errors.some((e) => /Name is required/i.test(e))).toBe(true);
});
});
describe("redactSensitiveData", () => {
it("redacts values for sensitive-looking keys", () => {
const yaml = `
services:
web:
environment:
- DATABASE_PASSWORD=hunter2
- API_TOKEN=abc123
- PUBLIC_URL=https://example.com
`;
const out = redactSensitiveData(yaml);
expect(out).not.toContain("hunter2");
expect(out).not.toContain("abc123");
// Non-sensitive values pass through
expect(out).toContain("PUBLIC_URL");
});
});
@@ -0,0 +1,239 @@
import { describe, expect, it } from "vitest";
import jsyaml from "js-yaml";
import {
defaultBlueprint,
defaultResource,
fromCompose,
toComposeYaml,
toEnvExample,
} from "../generator";
describe("fromCompose", () => {
it("creates one resource per service with exposed ports", () => {
const compose = `version: "3.8"
services:
web:
image: nginx:latest
ports:
- "8080:80"
db:
image: postgres:16
`;
const bp = fromCompose(compose, "example.com");
expect(bp.resources.length).toBe(1);
expect(bp.resources[0].serviceContainerName).toBe("web");
expect(bp.resources[0].blueprintName).toBe("web");
expect(bp.resources[0].subdomain).toBe("web");
expect(bp.resources[0].servicePort).toBe(8080);
expect(bp.resources[0].protocol).toBe("http");
expect(bp.resources[0].image).toBe("nginx:latest");
});
it("uses https for port 443 / 8443", () => {
const bp = fromCompose(`services:
s:
image: x
ports:
- "443:443"
`);
expect(bp.resources[0].protocol).toBe("https");
});
it("keeps the raw compose document for round-trip", () => {
const compose = `services:
app:
image: foo
ports:
- "80"
volumes:
- data:/var
`;
const bp = fromCompose(compose);
expect(bp.composeDocument).toBeTruthy();
const doc = bp.composeDocument as Record<string, unknown>;
expect(doc.services).toBeTruthy();
});
it("returns a default blueprint on invalid YAML", () => {
const bp = fromCompose(":::not yaml");
expect(bp.resources).toEqual([]);
expect(bp.composeDocument).toBeNull();
});
});
describe("toComposeYaml", () => {
it("injects pangolin labels on the matching service", () => {
const bp = fromCompose(
`services:
web:
image: nginx:latest
ports:
- "8080:80"
`,
"example.com",
);
const yaml = toComposeYaml(bp);
expect(yaml).toContain("pangolin.public-resources.web.name=Web");
expect(yaml).toContain(
"pangolin.public-resources.web.full-domain=web.example.com",
);
expect(yaml).toContain("pangolin.public-resources.web.protocol=http");
expect(yaml).toContain(
"pangolin.public-resources.web.targets[0].method=http",
);
});
it("adds the external pangolin network block", () => {
const bp = fromCompose(
`services:
web:
image: nginx
ports:
- "80"
`,
"example.com",
);
const yaml = toComposeYaml(bp);
const parsed = jsyaml.load(yaml) as Record<string, unknown>;
const networks = parsed.networks as Record<string, unknown>;
expect(networks).toBeTruthy();
expect(networks.pangolin).toBeTruthy();
const pangolin = networks.pangolin as Record<string, unknown>;
expect(pangolin.external).toBe(true);
expect(String(pangolin.name)).toContain("PANGOLIN_DOCKER_NETWORK");
});
it("adds the pangolin network to the service's networks list", () => {
const bp = fromCompose(`services:
app:
image: foo
ports:
- "80"
`);
const yaml = toComposeYaml(bp);
const parsed = jsyaml.load(yaml) as Record<string, unknown>;
const services = parsed.services as Record<string, Record<string, unknown>>;
const app = services.app;
expect(Array.isArray(app.networks)).toBe(true);
expect((app.networks as string[]).includes("pangolin")).toBe(true);
});
it("emits optional target overrides only when set", () => {
const bp = fromCompose(`services:
s:
image: x
ports:
- "8080"
`);
let yaml = toComposeYaml(bp);
expect(yaml).not.toContain("targets[0].hostname");
expect(yaml).not.toContain("targets[0].port");
bp.resources[0].targetHostname = "my-container";
bp.resources[0].targetPort = 9000;
yaml = toComposeYaml(bp);
expect(yaml).toContain(
"pangolin.public-resources.s.targets[0].hostname=my-container",
);
expect(yaml).toContain("pangolin.public-resources.s.targets[0].port=9000");
});
it("emits auth labels only when fields are set", () => {
const bp = fromCompose(`services:
s:
image: x
ports:
- "80"
`);
let yaml = toComposeYaml(bp);
expect(yaml).not.toContain("auth.sso-enabled");
expect(yaml).not.toContain("auth.pincode");
bp.resources[0].auth.ssoEnabled = true;
bp.resources[0].auth.ssoRoles = ["admin"];
bp.resources[0].auth.pincode = "${RESOURCE_AUTH_PINCODE}";
yaml = toComposeYaml(bp);
expect(yaml).toContain("auth.sso-enabled=true");
expect(yaml).toContain("auth.sso-roles[0]=admin");
expect(yaml).toContain(
"auth.pincode=${RESOURCE_AUTH_PINCODE}",
);
});
it("emits extra targets", () => {
const bp = fromCompose(`services:
s:
image: x
ports:
- "80"
`);
bp.resources[0].extraTargets.push({ method: "tcp", port: 5432 });
const yaml = toComposeYaml(bp);
expect(yaml).toContain(
"pangolin.public-resources.s.targets[1].method=tcp",
);
expect(yaml).toContain("pangolin.public-resources.s.targets[1].port=5432");
});
it("preserves existing labels on the service", () => {
const bp = fromCompose(`services:
s:
image: x
ports:
- "80"
labels:
- "com.example.tier=frontend"
`);
const yaml = toComposeYaml(bp);
expect(yaml).toContain("com.example.tier=frontend");
expect(yaml).toContain("pangolin.public-resources.s.name");
});
});
describe("toEnvExample", () => {
it("includes the six required keys", () => {
const bp = fromCompose(`services:
web:
image: nginx:latest
ports:
- "8080:80"
`);
const env = toEnvExample(bp);
expect(env).toContain("BLUEPRINT_NAME=web");
expect(env).toContain("RESOURCE_NAME=Web");
expect(env).toContain("SERVICE_SUBDOMAIN=web");
expect(env).toContain("SERVICE_CONTAINER_NAME=web");
expect(env).toContain("SERVICE_PORT=8080");
expect(env).toContain("APP_IMAGE=nginx:latest");
});
it("emits per-resource blocks for multi-service compose", () => {
const bp = fromCompose(`services:
web:
image: nginx
ports:
- "80"
api:
image: api:latest
ports:
- "3000"
`);
const env = toEnvExample(bp);
expect(env).toMatch(/─── web ───/);
expect(env).toMatch(/─── api ───/);
});
it("returns a placeholder when no resources are present", () => {
const env = toEnvExample(defaultBlueprint());
expect(env).toMatch(/Import a docker-compose template/);
});
});
describe("defaults", () => {
it("defaultResource sensible values", () => {
const r = defaultResource();
expect(r.protocol).toBe("http");
expect(r.servicePort).toBe(80);
expect(r.auth.ssoEnabled).toBe(false);
});
});
@@ -0,0 +1,70 @@
// Golden tests for a realistic compose → blueprint conversion.
// Pins the label structure so changes to the generator can't silently break
// fosrl/blueprints compatibility.
import { describe, expect, it } from "vitest";
import { fromCompose, toComposeYaml, toEnvExample } from "../generator";
const NGINX_COMPOSE = `version: "3.8"
services:
nginx:
image: nginx:1.27-alpine
container_name: nginx
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
`;
describe("golden — nginx → pangolin blueprint", () => {
it("compose output snapshot", () => {
const bp = fromCompose(NGINX_COMPOSE, "example.com");
expect(toComposeYaml(bp)).toMatchInlineSnapshot(`
"version: "3.8"
services:
nginx:
image: nginx:1.27-alpine
container_name: nginx
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
labels:
- pangolin.public-resources.nginx.name=Nginx
- pangolin.public-resources.nginx.full-domain=nginx.example.com
- pangolin.public-resources.nginx.protocol=http
- pangolin.public-resources.nginx.targets[0].method=http
networks:
- pangolin
networks:
pangolin:
external: true
name: \${PANGOLIN_DOCKER_NETWORK:-pangolin_default}
"
`);
});
it(".env.example snapshot", () => {
const bp = fromCompose(NGINX_COMPOSE, "example.com");
expect(toEnvExample(bp)).toMatchInlineSnapshot(`
"BLUEPRINT_NAME=nginx
RESOURCE_NAME=Nginx
SERVICE_SUBDOMAIN=nginx
SERVICE_CONTAINER_NAME=nginx
SERVICE_PORT=8080
APP_IMAGE=nginx:1.27-alpine
# Optional resource auth overrides — uncomment and set as needed.
# RESOURCE_AUTH_PINCODE=
# RESOURCE_AUTH_PASSWORD=
# RESOURCE_AUTH_BASIC_USER=
# RESOURCE_AUTH_BASIC_PASSWORD=
# RESOURCE_AUTH_SSO_ENABLED=
# RESOURCE_AUTH_SSO_ROLE_0=
# RESOURCE_AUTH_SSO_USER_0=
# RESOURCE_AUTH_WHITELIST_USER_0=
"
`);
});
});
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
validateBaseDomain,
validateBlueprint,
validateBlueprintName,
validatePangolinNetwork,
validateProtocol,
validateResourceName,
validateServicePort,
validateSubdomain,
} from "../validator";
import { defaultBlueprint, fromCompose } from "../generator";
describe("blueprint field validators", () => {
it("blueprint name must be kebab-case slug", () => {
expect(validateBlueprintName("my-app")).toBeNull();
expect(validateBlueprintName("My App")).toBeTruthy();
expect(validateBlueprintName("")).toBeTruthy();
});
it("resource name only requires non-empty", () => {
expect(validateResourceName("My App")).toBeNull();
expect(validateResourceName("")).toBeTruthy();
});
it("subdomain RFC 1123", () => {
expect(validateSubdomain("app")).toBeNull();
expect(validateSubdomain("-bad")).toBeTruthy();
expect(validateSubdomain("")).toBeTruthy();
});
it("service port within 1-65535", () => {
expect(validateServicePort(80)).toBeNull();
expect(validateServicePort(0)).toBeTruthy();
expect(validateServicePort(70000)).toBeTruthy();
});
it("base domain (optional FQDN)", () => {
expect(validateBaseDomain("")).toBeNull();
expect(validateBaseDomain("example.com")).toBeNull();
expect(validateBaseDomain("not-a-domain")).toBeTruthy();
});
it("pangolin network is a docker network name", () => {
expect(validatePangolinNetwork("pangolin_default")).toBeNull();
expect(validatePangolinNetwork("")).toBeTruthy();
expect(validatePangolinNetwork("bad name")).toBeTruthy();
});
it("protocol enum", () => {
expect(validateProtocol("http")).toBeNull();
expect(validateProtocol("https")).toBeNull();
expect(validateProtocol("tcp")).toBeNull();
expect(validateProtocol("udp")).toBeNull();
expect(validateProtocol("ftp")).toBeTruthy();
});
});
describe("validateBlueprint", () => {
it("empty blueprint with no resources is valid", () => {
expect(validateBlueprint(defaultBlueprint())).toEqual([]);
});
it("imported nginx-like compose passes", () => {
const bp = fromCompose(
`services:
web:
image: nginx:latest
ports:
- "8080:80"
`,
"example.com",
);
expect(validateBlueprint(bp)).toEqual([]);
});
it("flags duplicate blueprintNames", () => {
const bp = fromCompose(`services:
a:
image: x
ports:
- "80"
b:
image: y
ports:
- "81"
`);
// Force the duplicate
bp.resources[1].blueprintName = bp.resources[0].blueprintName;
const errs = validateBlueprint(bp);
expect(errs.some((e) => /Duplicate blueprintName/i.test(e))).toBe(true);
});
it("flags a resource whose service is missing from the compose", () => {
const bp = fromCompose(`services:
web:
image: nginx
ports:
- "80"
`);
bp.resources[0].serviceContainerName = "missing";
const errs = validateBlueprint(bp);
expect(errs.some((e) => /not found in the imported compose/i.test(e))).toBe(
true,
);
});
it("flags invalid blueprint name", () => {
const bp = fromCompose(`services:
web:
image: nginx
ports:
- "80"
`);
bp.resources[0].blueprintName = "Bad Name";
const errs = validateBlueprint(bp);
expect(errs.some((e) => /kebab-case/i.test(e))).toBe(true);
});
});
+329
View File
@@ -0,0 +1,329 @@
// Pangolin Blueprint generator — fosrl/blueprints compatible.
//
// `fromCompose(yaml, baseDomain?)` parses a compose YAML and produces a
// Blueprint with one resource per service. The raw compose document is kept
// so we can re-emit it verbatim with labels injected.
//
// `toComposeYaml(bp)` returns the augmented docker-compose.yml.
// `toEnvExample(bp)` returns the matching .env.example.
import jsyaml from "js-yaml";
import type {
Blueprint,
BlueprintAuth,
BlueprintProtocol,
BlueprintResource,
} from "../../types/blueprint";
const ENV_REF = (k: string) => `\${${k}}`;
const ENV_REQUIRED = (k: string, msg: string) => `\${${k}:?${msg}}`;
const DEFAULT_NETWORK = "pangolin_default";
export function defaultAuth(): BlueprintAuth {
return {
pincode: "",
password: "",
basicUser: "",
basicPassword: "",
ssoEnabled: false,
ssoRoles: [],
ssoUsers: [],
whitelistUsers: [],
};
}
export function defaultResource(): BlueprintResource {
return {
serviceContainerName: "",
blueprintName: "",
resourceName: "",
subdomain: "",
servicePort: 80,
image: "",
protocol: "http",
targetHostname: undefined,
targetPort: undefined,
extraTargets: [],
auth: defaultAuth(),
};
}
export function defaultBlueprint(): Blueprint {
return {
version: 1,
baseDomain: "",
pangolinNetwork: DEFAULT_NETWORK,
resources: [],
composeDocument: null,
};
}
// ---------- compose → blueprint ----------
function slug(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function titleCase(s: string): string {
return s
.replace(/[-_]+/g, " ")
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0].toUpperCase() + w.slice(1))
.join(" ");
}
function asNumber(v: unknown): number | null {
if (v === null || v === undefined || v === "") return null;
const n =
typeof v === "number" ? v : Number(String(v).replace(/[^\d]/g, ""));
return Number.isFinite(n) && n > 0 ? n : null;
}
interface ComposePort {
host?: string | number;
container?: string | number;
published?: string | number;
target?: string | number;
protocol?: string;
}
function firstServicePort(svc: Record<string, unknown>): number | null {
// Prefer published host, then container, then expose[0].
const ports = Array.isArray(svc.ports) ? (svc.ports as unknown[]) : [];
for (const p of ports) {
if (typeof p === "string" || typeof p === "number") {
const s = String(p);
if (s.includes(":")) {
const [h, rest] = s.split(":");
const host = asNumber(h);
if (host !== null) return host;
const c = asNumber((rest || "").split("/")[0]);
if (c !== null) return c;
} else {
const c = asNumber(s.split("/")[0]);
if (c !== null) return c;
}
} else if (p && typeof p === "object") {
const o = p as ComposePort;
const host = asNumber(o.host ?? o.published);
if (host !== null) return host;
const c = asNumber(o.container ?? o.target);
if (c !== null) return c;
}
}
const expose = Array.isArray(svc.expose) ? (svc.expose as unknown[]) : [];
for (const e of expose) {
const n = asNumber(e);
if (n !== null) return n;
}
return null;
}
function protocolFor(port: number): BlueprintProtocol {
if (port === 443 || port === 8443) return "https";
return "http";
}
export function fromCompose(yamlContent: string, baseDomain = ""): Blueprint {
let doc: unknown;
try {
doc = jsyaml.load(yamlContent);
} catch {
return defaultBlueprint();
}
if (!doc || typeof doc !== "object") return defaultBlueprint();
const root = doc as Record<string, unknown>;
const services =
root.services && typeof root.services === "object"
? (root.services as Record<string, unknown>)
: null;
if (!services) return defaultBlueprint();
const bp = defaultBlueprint();
bp.baseDomain = baseDomain;
bp.composeDocument = root;
for (const [svcKey, raw] of Object.entries(services)) {
if (!raw || typeof raw !== "object") continue;
const svc = raw as Record<string, unknown>;
const port = firstServicePort(svc);
if (port === null) continue;
const sluggedKey = slug(svcKey) || svcKey;
const resource: BlueprintResource = {
...defaultResource(),
serviceContainerName: svcKey,
blueprintName: sluggedKey,
resourceName: titleCase(svcKey) || svcKey,
subdomain: sluggedKey,
servicePort: port,
image: typeof svc.image === "string" ? svc.image : "",
protocol: protocolFor(port),
};
bp.resources.push(resource);
}
return bp;
}
// ---------- blueprint → compose YAML ----------
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function deepClone<T>(v: T): T {
return JSON.parse(JSON.stringify(v));
}
function dedupePreserveOrder<T>(items: T[]): T[] {
const seen = new Set<string>();
const out: T[] = [];
for (const item of items) {
const key = typeof item === "string" ? item : JSON.stringify(item);
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
function labelsFor(bp: Blueprint, r: BlueprintResource): string[] {
const ns = `pangolin.public-resources.${r.blueprintName}`;
const fullDomain = bp.baseDomain
? `${r.subdomain}.${bp.baseDomain}`
: r.subdomain;
const labels: string[] = [
`${ns}.name=${r.resourceName}`,
`${ns}.full-domain=${fullDomain}`,
`${ns}.protocol=${r.protocol}`,
`${ns}.targets[0].method=${r.protocol}`,
];
if (r.targetHostname) labels.push(`${ns}.targets[0].hostname=${r.targetHostname}`);
if (r.targetPort !== undefined) labels.push(`${ns}.targets[0].port=${r.targetPort}`);
r.extraTargets.forEach((t, i) => {
const idx = i + 1;
labels.push(`${ns}.targets[${idx}].method=${t.method}`);
if (t.hostname) labels.push(`${ns}.targets[${idx}].hostname=${t.hostname}`);
if (t.port !== undefined) labels.push(`${ns}.targets[${idx}].port=${t.port}`);
});
const a = r.auth;
if (a.pincode) labels.push(`${ns}.auth.pincode=${a.pincode}`);
if (a.password) labels.push(`${ns}.auth.password=${a.password}`);
if (a.basicUser) labels.push(`${ns}.auth.basic-user=${a.basicUser}`);
if (a.basicPassword) labels.push(`${ns}.auth.basic-password=${a.basicPassword}`);
if (a.ssoEnabled) labels.push(`${ns}.auth.sso-enabled=true`);
a.ssoRoles.filter(Boolean).forEach((role, i) =>
labels.push(`${ns}.auth.sso-roles[${i}]=${role}`),
);
a.ssoUsers.filter(Boolean).forEach((user, i) =>
labels.push(`${ns}.auth.sso-users[${i}]=${user}`),
);
a.whitelistUsers.filter(Boolean).forEach((user, i) =>
labels.push(`${ns}.auth.whitelist-users[${i}]=${user}`),
);
return labels;
}
export function toComposeYaml(bp: Blueprint): string {
const baseDoc: Record<string, unknown> = bp.composeDocument
? deepClone(bp.composeDocument)
: {};
// Ensure services map.
const services = isPlainObject(baseDoc.services)
? (baseDoc.services as Record<string, unknown>)
: ({} as Record<string, unknown>);
for (const r of bp.resources) {
const svc = isPlainObject(services[r.serviceContainerName])
? (services[r.serviceContainerName] as Record<string, unknown>)
: {};
// Inject labels — merge with anything already there, de-dupe.
const existing: string[] = Array.isArray(svc.labels)
? (svc.labels as unknown[]).map((v) => String(v))
: [];
svc.labels = dedupePreserveOrder([...existing, ...labelsFor(bp, r)]);
// Ensure pangolin network membership.
const nets: unknown[] = Array.isArray(svc.networks)
? (svc.networks as unknown[])
: [];
if (!nets.includes("pangolin")) {
svc.networks = [...nets, "pangolin"];
} else {
svc.networks = nets;
}
services[r.serviceContainerName] = svc;
}
baseDoc.services = services;
// Ensure top-level pangolin network block.
const networksTop = isPlainObject(baseDoc.networks)
? (baseDoc.networks as Record<string, unknown>)
: {};
networksTop.pangolin = {
external: true,
name: ENV_REF("PANGOLIN_DOCKER_NETWORK") + ":-" + bp.pangolinNetwork,
};
// The dump trick above creates `${PANGOLIN_DOCKER_NETWORK}:-...` which is
// wrong; compose-style default syntax is `${VAR:-default}`. Fix in place:
networksTop.pangolin = {
external: true,
name: `\${PANGOLIN_DOCKER_NETWORK:-${bp.pangolinNetwork}}`,
};
baseDoc.networks = networksTop;
// Always emit at least version key for clarity.
if (!baseDoc.version) baseDoc.version = "3.8";
return jsyaml.dump(baseDoc, {
indent: 2,
lineWidth: 200,
noRefs: true,
quotingType: '"',
});
}
// ---------- blueprint → .env.example ----------
export function toEnvExample(bp: Blueprint): string {
const blocks: string[] = [];
bp.resources.forEach((r, i) => {
const header =
bp.resources.length > 1 ? `# ─── ${r.serviceContainerName} ───\n` : "";
const auth = `
# Optional resource auth overrides — uncomment and set as needed.
# RESOURCE_AUTH_PINCODE=
# RESOURCE_AUTH_PASSWORD=
# RESOURCE_AUTH_BASIC_USER=
# RESOURCE_AUTH_BASIC_PASSWORD=
# RESOURCE_AUTH_SSO_ENABLED=
# RESOURCE_AUTH_SSO_ROLE_0=
# RESOURCE_AUTH_SSO_USER_0=
# RESOURCE_AUTH_WHITELIST_USER_0=`;
const body = `BLUEPRINT_NAME=${r.blueprintName}
RESOURCE_NAME=${r.resourceName}
SERVICE_SUBDOMAIN=${r.subdomain}
SERVICE_CONTAINER_NAME=${r.serviceContainerName}
SERVICE_PORT=${r.servicePort}
APP_IMAGE=${r.image}`;
blocks.push(header + body + auth);
if (i < bp.resources.length - 1) blocks.push("");
});
if (blocks.length === 0) {
return `# Import a docker-compose template to populate this file.\n`;
}
return blocks.join("\n") + "\n";
}
// Re-exported sentinel so callers don't accidentally shadow with their own.
export const PANGOLIN_NETWORK_DEFAULT = DEFAULT_NETWORK;
// silence unused-var hint for the helper above
void ENV_REQUIRED;
+150
View File
@@ -0,0 +1,150 @@
// Validation for the fosrl/blueprints-compatible Blueprint structure.
import type { Blueprint, BlueprintResource } from "../../types/blueprint";
import {
validateHostname,
validatePort,
validateLabelKey,
} from "../validation";
const BLUEPRINT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
const FQDN_RE =
/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i;
const NETWORK_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/i;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PROTOCOLS = new Set(["http", "https", "tcp", "udp"]);
function push(errors: string[], prefix: string, err: string | null) {
if (err) errors.push(`${prefix}: ${err}`);
}
export function validateBlueprintName(v: string): string | null {
if (!v) return "Blueprint name is required";
if (!BLUEPRINT_NAME_RE.test(v))
return "Blueprint name must be lowercase letters, digits, and dashes (kebab-case)";
return null;
}
export function validateResourceName(v: string): string | null {
if (!v.trim()) return "Resource name is required";
return null;
}
export function validateSubdomain(v: string): string | null {
if (!v) return "Subdomain is required";
if (!SUBDOMAIN_RE.test(v)) return "Subdomain must be a valid RFC 1123 label";
return null;
}
export function validateBaseDomain(v: string): string | null {
if (!v) return null; // optional — labels emit subdomain bare when missing
if (!FQDN_RE.test(v)) return "Base domain must be a valid FQDN";
return null;
}
export function validateServicePort(port: number | string): string | null {
return validatePort(String(port));
}
export function validatePangolinNetwork(v: string): string | null {
if (!v) return "Pangolin network name is required";
if (!NETWORK_NAME_RE.test(v))
return "Pangolin network must be a valid Docker network name";
return null;
}
export function validateProtocol(v: string): string | null {
if (!PROTOCOLS.has(v))
return "Protocol must be http, https, tcp, or udp";
return null;
}
export function validateEmail(v: string): string | null {
if (!v) return "Email is required";
if (!EMAIL_RE.test(v)) return "Must be a valid email address";
return null;
}
function validateResource(
r: BlueprintResource,
idx: number,
composeServiceKeys: Set<string>,
): string[] {
const errors: string[] = [];
const prefix = `Resource "${r.serviceContainerName || idx + 1}"`;
push(errors, `${prefix} blueprintName`, validateBlueprintName(r.blueprintName));
push(errors, `${prefix} resourceName`, validateResourceName(r.resourceName));
push(errors, `${prefix} subdomain`, validateSubdomain(r.subdomain));
push(errors, `${prefix} servicePort`, validateServicePort(r.servicePort));
push(errors, `${prefix} protocol`, validateProtocol(r.protocol));
if (!r.serviceContainerName) {
errors.push(`${prefix}: serviceContainerName is required`);
} else if (
composeServiceKeys.size > 0 &&
!composeServiceKeys.has(r.serviceContainerName)
) {
errors.push(
`${prefix}: service "${r.serviceContainerName}" not found in the imported compose document`,
);
}
if (r.targetHostname !== undefined && r.targetHostname !== "") {
push(errors, `${prefix} targets[0].hostname`, validateHostname(r.targetHostname));
}
if (r.targetPort !== undefined) {
push(errors, `${prefix} targets[0].port`, validatePort(String(r.targetPort)));
}
r.extraTargets.forEach((t, i) => {
const tp = `${prefix} targets[${i + 1}]`;
push(errors, `${tp} method`, validateProtocol(t.method));
if (t.hostname) push(errors, `${tp} hostname`, validateHostname(t.hostname));
if (t.port !== undefined)
push(errors, `${tp} port`, validatePort(String(t.port)));
});
// SSO list entries: each non-empty role/user is a free-form label slug.
r.auth.ssoRoles
.filter(Boolean)
.forEach((role, i) =>
push(errors, `${prefix} auth.sso-roles[${i}]`, validateLabelKey(role)),
);
return errors;
}
export function validateBlueprint(bp: Blueprint): string[] {
const errors: string[] = [];
push(errors, "baseDomain", validateBaseDomain(bp.baseDomain));
push(errors, "pangolinNetwork", validatePangolinNetwork(bp.pangolinNetwork));
const composeServices = bp.composeDocument
? (bp.composeDocument as Record<string, unknown>).services
: null;
const composeServiceKeys = new Set<string>(
composeServices && typeof composeServices === "object"
? Object.keys(composeServices as Record<string, unknown>)
: [],
);
if (bp.resources.length === 0) {
// Empty blueprint is valid — user may still be filling things out — but
// hint at the next step.
return errors;
}
const seenNames = new Set<string>();
bp.resources.forEach((r, i) => {
errors.push(...validateResource(r, i, composeServiceKeys));
if (r.blueprintName && seenNames.has(r.blueprintName)) {
errors.push(`Duplicate blueprintName: "${r.blueprintName}"`);
}
seenNames.add(r.blueprintName);
});
return errors;
}
+1 -3
View File
@@ -95,7 +95,7 @@ export function convertToDockerRun(service: ServiceConfig): string {
export function convertToSystemd(service: ServiceConfig): string {
const containerName = service.container_name || service.name;
let unit = `[Unit]
return `[Unit]
Description=Docker Container ${containerName}
Requires=docker.service
After=docker.service
@@ -110,8 +110,6 @@ Restart=${service.restart === "always" ? "always" : service.restart === "unless-
[Install]
WantedBy=multi-user.target
`;
return unit;
}
// Generate Komodo .toml from YAML
+27
View File
@@ -54,6 +54,33 @@ export function defaultNewtConfig(): NewtConfig {
newtId: "",
newtSecret: "",
networkName: "newt",
name: "",
port: "",
mtu: "",
dns: "",
logLevel: "INFO",
interfaceName: "",
pingInterval: "",
pingTimeout: "",
configFile: "",
healthFile: "",
dockerSocket: "",
dockerEnforceNetworkValidation: false,
updown: "",
blueprintFile: "",
provisioningBlueprintFile: "",
provisioningKey: "",
noCloud: false,
disableClients: false,
metrics: true,
otlp: false,
metricsAdminAddr: "",
enforceHcCert: false,
tlsClientCertFile: "",
tlsClientKey: "",
tlsClientCa: "",
preferEndpoint: "",
region: "",
};
}
+39 -16
View File
@@ -40,10 +40,19 @@ export function parseComposeService(
command: parseCommandArray(actualServiceData.command),
restart: actualServiceData.restart || "",
ports: Array.isArray(actualServiceData.ports)
? actualServiceData.ports.map((p: string) => {
? actualServiceData.ports.map((p: any) => {
if (typeof p === "object" && p !== null) {
return {
host: p.published ? String(p.published) : "",
container: p.target ? String(p.target) : "",
protocol: p.protocol || "none",
};
}
const portStr = String(p);
// Handle format: "host:container/protocol" or "container/protocol" or just "container"
if (p.includes(":")) {
const parts = p.split(":");
if (portStr.includes(":")) {
const parts = portStr.split(":");
const host = parts[0];
const containerWithProtocol = parts[1] || "";
const [container, protocol] = containerWithProtocol.split("/");
@@ -54,7 +63,7 @@ export function parseComposeService(
};
} else {
// No colon means it's just a container port, possibly with protocol
const [container, protocol] = p.split("/");
const [container, protocol] = portStr.split("/");
return {
host: "",
container,
@@ -364,25 +373,39 @@ export function parseComposeTemplate(composeContent: string): {
networks: Record<string, any>;
volumes: Record<string, any>;
} {
const doc = jsyaml.load(composeContent) as any;
let doc: any;
try {
doc = jsyaml.load(composeContent);
} catch (err: any) {
throw new Error(`Invalid YAML format: ${err.message}`, { cause: err });
}
if (!doc || !doc.services) {
throw new Error("Invalid docker-compose.yml in template");
if (!doc || typeof doc !== "object") {
throw new Error("Invalid template: Content is not a valid YAML object");
}
if (!doc.services || typeof doc.services !== "object") {
throw new Error("Invalid template: Missing or invalid 'services' block");
}
// 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,
}));
const servicesArray: ComposeServiceInput[] = [];
for (const [svcName, svcObj] of Object.entries(doc.services)) {
if (!svcObj || typeof svcObj !== "object") {
throw new Error(`Invalid template: Service '${svcName}' is not a valid object`);
}
servicesArray.push({
name: svcName,
image: (svcObj as any).image || "",
rawService: svcObj,
});
}
return {
services: servicesArray,
networks: doc.networks || {},
volumes: doc.volumes || {},
networks: doc.networks && typeof doc.networks === "object" ? doc.networks : {},
volumes: doc.volumes && typeof doc.volumes === "object" ? doc.volumes : {},
};
}
+190 -89
View File
@@ -1,131 +1,232 @@
// Validation utilities for Docker Compose configurations
// Validation utilities for Docker Compose configurations.
// All field-level rules live in src/utils/validation/schemas.ts (Zod).
// This module exposes the legacy `validate*` functions and the service-level
// `validateServices` aggregator used by the YAML-validation hook.
import type { ServiceConfig } from "../types/compose";
import type {
NetworkConfig,
ServiceConfig,
VolumeConfig,
} from "../types/compose";
import { validate } from "./validation/schemas";
// ---------- Legacy single-field validators (kept for existing call sites) ----------
export function validateServiceName(name: string): string | null {
if (!name) return "Service name is required";
if (!/^[a-z0-9_-]+$/i.test(name)) {
return "Service name must contain only alphanumeric characters, hyphens, and underscores";
}
return null;
return validate.serviceName(name);
}
export function validatePort(port: string): string | null {
if (!port) return null;
const num = parseInt(port, 10);
if (isNaN(num) || num < 1 || num > 65535) {
return "Port must be between 1 and 65535";
}
return null;
return validate.port(port);
}
export function validateEnvVarKey(key: string): string | null {
if (!key) return null;
if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) {
return "Environment variable key should start with a letter or underscore and contain only alphanumeric characters and underscores";
}
return null;
return validate.envKey(key);
}
export function validateCpuValue(cpu: string): string | null {
if (!cpu) return null;
if (!/^\d+(\.\d+)?$/.test(cpu)) {
return "CPU value must be a number (e.g., 0.5, 1, 2)";
}
const num = parseFloat(cpu);
if (num < 0) {
return "CPU value must be positive";
}
return null;
return validate.cpu(cpu);
}
export function validateMemoryValue(memory: string): string | null {
if (!memory) return null;
if (!/^\d+[kmgKMG]?[bB]?$/.test(memory) && !/^\d+$/.test(memory)) {
return "Memory value must be a number with optional unit (e.g., 512m, 2g, 1024)";
}
return null;
return validate.memory(memory);
}
// New single-field validators surfaced for inline form UI.
export const validateImage = validate.image;
export const validateContainerName = validate.containerName;
export const validateHostname = validate.hostname;
export const validateUser = validate.user;
export const validateDuration = validate.duration;
export const validateStopSignal = validate.stopSignal;
export const validateNetworkMode = validate.networkMode;
export const validateNetworkDriver = validate.networkDriver;
export const validateVolumeDriver = validate.volumeDriver;
export const validateNetworkName = validate.networkName;
export const validateVolumeName = validate.volumeName;
export const validateIpAddress = validate.ipAddress;
export const validateIpv4Cidr = validate.ipv4Cidr;
export const validateExtraHost = validate.extraHost;
export const validateUrl = validate.url;
export const validateAbsoluteUnixPath = validate.absoluteUnixPath;
export const validateZerotierId = validate.zerotierId;
export const validateTailscaleAuthKey = validate.tailscaleAuthKey;
export const validateSecret = validate.secret;
export const validateLabelKey = validate.labelKey;
// ---------- Service-level aggregator ----------
function prefixFor(svc: ServiceConfig, idx: number): string {
return `Service "${svc.name || idx + 1}"`;
}
function push(errors: string[], prefix: string, err: string | null) {
if (err) errors.push(`${prefix}: ${err}`);
}
export function validateServices(services: ServiceConfig[]): string[] {
const errors: string[] = [];
const seenNames = new Set<string>();
services.forEach((svc, idx) => {
const prefix = prefixFor(svc, idx);
// Name (required + format + uniqueness)
if (!svc.name) {
errors.push(`Service ${idx + 1}: Name is required`);
} else {
const nameError = validateServiceName(svc.name);
if (nameError) errors.push(`Service "${svc.name}": ${nameError}`);
}
if (!svc.image) {
errors.push(`Service "${svc.name || idx + 1}": Image is required`);
}
svc.ports.forEach((port, pIdx) => {
if (port.host) {
const portError = validatePort(port.host);
if (portError)
errors.push(
`Service "${svc.name || idx + 1}" port ${pIdx + 1} host: ${portError}`
);
push(errors, prefix, validate.serviceName(svc.name));
if (seenNames.has(svc.name)) {
errors.push(`${prefix}: Duplicate service name`);
}
if (port.container) {
const portError = validatePort(port.container);
if (portError)
errors.push(
`Service "${svc.name || idx + 1}" port ${pIdx + 1} container: ${portError}`
);
seenNames.add(svc.name);
}
// Image (required)
if (!svc.image) {
errors.push(`${prefix}: Image is required`);
} else {
push(errors, prefix, validate.image(svc.image));
}
// Container name (optional)
push(errors, `${prefix} container_name`, validate.containerName(svc.container_name ?? ""));
// Hostname (optional)
push(errors, `${prefix} hostname`, validate.hostname(svc.hostname ?? ""));
// User (optional)
push(errors, `${prefix} user`, validate.user(svc.user ?? ""));
// Restart policy (optional)
push(errors, `${prefix} restart`, validate.restartPolicy(svc.restart ?? ""));
// Network mode (optional)
push(errors, `${prefix} network_mode`, validate.networkMode(svc.network_mode ?? ""));
// Stop signal / stop grace period (optional)
push(errors, `${prefix} stop_signal`, validate.stopSignal(svc.stop_signal ?? ""));
push(
errors,
`${prefix} stop_grace_period`,
validate.duration(svc.stop_grace_period ?? "")
);
// Ports
svc.ports.forEach((port, pIdx) => {
const portPrefix = `${prefix} port ${pIdx + 1}`;
push(errors, `${portPrefix} host`, validate.port(port.host));
push(errors, `${portPrefix} container`, validate.port(port.container));
// "none" is the UI sentinel for "unspecified protocol"; skip the enum check.
if (port.protocol && port.protocol !== "none") {
push(errors, `${portPrefix} protocol`, validate.protocol(port.protocol));
}
});
// Expose ports
svc.expose?.forEach((expose, eIdx) => {
push(errors, `${prefix} expose ${eIdx + 1}`, validate.port(expose));
});
// Volumes
svc.volumes?.forEach((vol, vIdx) => {
const vPrefix = `${prefix} volume ${vIdx + 1}`;
push(errors, `${vPrefix} host`, validate.volumeHostPath(vol.host));
if (!vol.container) {
errors.push(`${vPrefix}: Container path is required`);
}
});
// Environment
svc.environment.forEach((env, eIdx) => {
if (env.key) {
const keyError = validateEnvVarKey(env.key);
if (keyError)
errors.push(
`Service "${svc.name || idx + 1}" env var ${eIdx + 1}: ${keyError}`
);
push(errors, `${prefix} env var ${eIdx + 1}`, validate.envKey(env.key));
}
});
if (svc.deploy?.resources?.limits?.cpus) {
const cpuError = validateCpuValue(svc.deploy.resources.limits.cpus);
if (cpuError)
errors.push(`Service "${svc.name || idx + 1}" CPU limit: ${cpuError}`);
}
if (svc.deploy?.resources?.limits?.memory) {
const memError = validateMemoryValue(
svc.deploy.resources.limits.memory
);
if (memError)
errors.push(
`Service "${svc.name || idx + 1}" memory limit: ${memError}`
);
}
if (svc.deploy?.resources?.reservations?.cpus) {
const cpuError = validateCpuValue(
svc.deploy.resources.reservations.cpus
);
if (cpuError)
errors.push(
`Service "${svc.name || idx + 1}" CPU reservation: ${cpuError}`
);
}
if (svc.deploy?.resources?.reservations?.memory) {
const memError = validateMemoryValue(
svc.deploy.resources.reservations.memory
);
if (memError)
errors.push(
`Service "${svc.name || idx + 1}" memory reservation: ${memError}`
);
// DNS entries
svc.dns?.forEach((ip, dIdx) => {
push(errors, `${prefix} dns ${dIdx + 1}`, validate.ipAddress(ip));
});
// Extra hosts
svc.extra_hosts?.forEach((eh, hIdx) => {
push(errors, `${prefix} extra_hosts ${hIdx + 1}`, validate.extraHost(eh));
});
// Healthcheck durations
if (svc.healthcheck) {
const hc = svc.healthcheck;
push(errors, `${prefix} healthcheck.interval`, validate.duration(hc.interval ?? ""));
push(errors, `${prefix} healthcheck.timeout`, validate.duration(hc.timeout ?? ""));
push(errors, `${prefix} healthcheck.start_period`, validate.duration(hc.start_period ?? ""));
push(errors, `${prefix} healthcheck.start_interval`, validate.duration(hc.start_interval ?? ""));
if (hc.retries && !/^\d+$/.test(hc.retries)) {
errors.push(`${prefix} healthcheck.retries: must be a non-negative integer`);
}
}
// Resources
push(errors, `${prefix} CPU limit`, validate.cpu(svc.deploy?.resources?.limits?.cpus ?? ""));
push(
errors,
`${prefix} memory limit`,
validate.memory(svc.deploy?.resources?.limits?.memory ?? "")
);
push(
errors,
`${prefix} CPU reservation`,
validate.cpu(svc.deploy?.resources?.reservations?.cpus ?? "")
);
push(
errors,
`${prefix} memory reservation`,
validate.memory(svc.deploy?.resources?.reservations?.memory ?? "")
);
});
return errors;
}
export function validateNetworks(networks: NetworkConfig[]): string[] {
const errors: string[] = [];
networks.forEach((net, idx) => {
const prefix = `Network "${net.name || idx + 1}"`;
if (!net.name) {
errors.push(`Network ${idx + 1}: Name is required`);
} else {
push(errors, prefix, validate.networkName(net.name));
}
if (net.driver) {
push(errors, `${prefix} driver`, validate.networkDriver(net.driver));
}
net.ipam?.config?.forEach((cfg, cIdx) => {
const c = `${prefix} ipam.config[${cIdx}]`;
if (cfg.subnet) push(errors, `${c} subnet`, validate.ipv4Cidr(cfg.subnet));
if (cfg.gateway) push(errors, `${c} gateway`, validate.ipv4(cfg.gateway));
});
});
return errors;
}
export function validateVolumes(volumes: VolumeConfig[]): string[] {
const errors: string[] = [];
volumes.forEach((vol, idx) => {
const prefix = `Volume "${vol.name || idx + 1}"`;
if (!vol.name) {
errors.push(`Volume ${idx + 1}: Name is required`);
} else {
push(errors, prefix, validate.volumeName(vol.name));
}
if (vol.driver) {
push(errors, `${prefix} driver`, validate.volumeDriver(vol.driver));
}
});
return errors;
}
// ---------- Redact (unchanged) ----------
export function redactSensitiveData(yamlText: string): string {
const sensitivePatterns = [
/password\s*[:=]\s*["']?([^"'\n]+)["']?/gi,
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { validateComposeYaml } from "../compose";
describe("validateComposeYaml", () => {
it("rejects empty input", () => {
const r = validateComposeYaml("");
expect(r.ok).toBe(false);
expect(r.errors[0]).toMatch(/empty/i);
});
it("rejects invalid YAML", () => {
const r = validateComposeYaml(":::not yaml:::");
expect(r.ok).toBe(false);
});
it("rejects compose without services", () => {
const r = validateComposeYaml(`version: "3.8"\n`);
expect(r.ok).toBe(false);
expect(r.errors.some((e) => /at least one service/i.test(e))).toBe(true);
});
it("passes a clean compose", () => {
const r = validateComposeYaml(`version: "3.8"
services:
web:
image: nginx:latest
ports:
- "8080:80"
`);
expect(r.ok).toBe(true);
});
it("flags an invalid image", () => {
const r = validateComposeYaml(`services:
web:
image: "Bad Image:latest"
`);
expect(r.ok).toBe(false);
expect(r.errors.some((e) => /image/i.test(e) || /reference/i.test(e))).toBe(true);
});
});
+207
View File
@@ -0,0 +1,207 @@
import { describe, expect, it } from "vitest";
import {
defaultCloudflaredConfig,
defaultNetbirdConfig,
defaultNewtConfig,
defaultTailscaleConfig,
defaultVPNConfig,
defaultWireguardConfig,
defaultZerotierConfig,
} from "../../default-configs";
import { validateVpnConfig, vpnConfigWarning } from "../vpn";
function base() {
return { ...defaultVPNConfig(), enabled: true };
}
describe("validateVpnConfig", () => {
it("returns no errors when VPN is disabled", () => {
expect(validateVpnConfig(defaultVPNConfig())).toEqual([]);
});
it("flags tailscale missing auth key", () => {
const vpn = {
...base(),
type: "tailscale" as const,
tailscale: defaultTailscaleConfig(),
servicesUsingVpn: ["web"],
};
expect(
validateVpnConfig(vpn).some((e) => /auth key is required/i.test(e)),
).toBe(true);
});
it("accepts a tskey-prefixed auth key", () => {
const ts = defaultTailscaleConfig();
ts.authKey = "tskey-abc123";
const vpn = {
...base(),
type: "tailscale" as const,
tailscale: ts,
servicesUsingVpn: ["web"],
};
expect(validateVpnConfig(vpn)).toEqual([]);
});
it("requires a serve target service when serve is enabled", () => {
const ts = defaultTailscaleConfig();
ts.authKey = "tskey-abc123";
ts.enableServe = true;
const vpn = {
...base(),
type: "tailscale" as const,
tailscale: ts,
servicesUsingVpn: ["web"],
};
expect(
validateVpnConfig(vpn).some((e) => /target service/i.test(e)),
).toBe(true);
});
it("flags missing newt id/secret", () => {
const vpn = {
...base(),
type: "newt" as const,
newt: defaultNewtConfig(),
servicesUsingVpn: ["web"],
};
const errs = validateVpnConfig(vpn);
expect(errs.some((e) => /Newt ID is required/i.test(e))).toBe(true);
expect(errs.some((e) => /Newt secret is required/i.test(e))).toBe(true);
});
it("validates optional newt extras when set", () => {
const newt = defaultNewtConfig();
newt.newtId = "${NEWT_ID}";
newt.newtSecret = "${NEWT_SECRET}";
newt.port = "70000"; // invalid
newt.mtu = "200"; // below 576
newt.dns = "not-an-ip";
newt.pingInterval = "5"; // missing unit
newt.healthFile = "relative/path";
newt.preferEndpoint = "not a url";
const vpn = {
...base(),
type: "newt" as const,
newt,
servicesUsingVpn: ["web"],
};
const errs = validateVpnConfig(vpn);
expect(errs.some((e) => /port/i.test(e))).toBe(true);
expect(errs.some((e) => /MTU/i.test(e))).toBe(true);
expect(errs.some((e) => /DNS/i.test(e))).toBe(true);
expect(errs.some((e) => /ping interval/i.test(e))).toBe(true);
expect(errs.some((e) => /health file/i.test(e))).toBe(true);
expect(errs.some((e) => /prefer endpoint/i.test(e))).toBe(true);
});
it("accepts a populated newt config with all extras valid", () => {
const newt = defaultNewtConfig();
newt.newtId = "${NEWT_ID}";
newt.newtSecret = "${NEWT_SECRET}";
newt.port = "34534";
newt.mtu = "1280";
newt.dns = "9.9.9.9";
newt.pingInterval = "3s";
newt.pingTimeout = "5s";
newt.healthFile = "/tmp/healthy";
newt.preferEndpoint = "https://preferred.example.com";
const vpn = {
...base(),
type: "newt" as const,
newt,
servicesUsingVpn: ["web"],
};
expect(validateVpnConfig(vpn)).toEqual([]);
});
it("flags missing cloudflared tunnel token", () => {
const vpn = {
...base(),
type: "cloudflared" as const,
cloudflared: defaultCloudflaredConfig(),
servicesUsingVpn: ["web"],
};
expect(
validateVpnConfig(vpn).some((e) => /tunnel token/i.test(e)),
).toBe(true);
});
it("flags missing wireguard config path", () => {
const wg = defaultWireguardConfig();
wg.configPath = "";
const vpn = {
...base(),
type: "wireguard" as const,
wireguard: wg,
servicesUsingVpn: ["web"],
};
expect(
validateVpnConfig(vpn).some((e) => /config path is required/i.test(e)),
).toBe(true);
});
it("flags malformed zerotier network ID", () => {
const zt = defaultZerotierConfig();
zt.networkId = "not-hex";
const vpn = {
...base(),
type: "zerotier" as const,
zerotier: zt,
servicesUsingVpn: ["web"],
};
expect(
validateVpnConfig(vpn).some((e) => /ZeroTier network ID/i.test(e)),
).toBe(true);
});
it("accepts env-ref for zerotier network ID and netbird key", () => {
const zt = defaultZerotierConfig();
zt.networkId = "${ZT_NETWORK_ID}";
const nb = defaultNetbirdConfig();
nb.setupKey = "${NB_SETUP_KEY}";
expect(
validateVpnConfig({
...base(),
type: "zerotier",
zerotier: zt,
servicesUsingVpn: ["web"],
}),
).toEqual([]);
expect(
validateVpnConfig({
...base(),
type: "netbird",
netbird: nb,
servicesUsingVpn: ["web"],
}),
).toEqual([]);
});
it("requires at least one routed service", () => {
const ts = defaultTailscaleConfig();
ts.authKey = "tskey-abc123";
const vpn = {
...base(),
type: "tailscale" as const,
tailscale: ts,
servicesUsingVpn: [],
};
expect(
validateVpnConfig(vpn).some((e) =>
/at least one service must be routed/i.test(e),
),
).toBe(true);
});
it("vpnConfigWarning returns first issue or null", () => {
expect(vpnConfigWarning(defaultVPNConfig())).toBeNull();
const vpn = {
...base(),
type: "cloudflared" as const,
cloudflared: defaultCloudflaredConfig(),
servicesUsingVpn: [],
};
expect(vpnConfigWarning(vpn)).toMatch(/tunnel token/i);
});
});
+96
View File
@@ -0,0 +1,96 @@
// Pre-import validation for a raw Docker Compose YAML string.
// Parses the file with js-yaml, lifts services/networks/volumes into our
// internal types using template-import helpers, then runs the same
// validateServices/Networks/Volumes pipeline used by the YAML output panel.
//
// Returns a list of human-readable error strings (empty = clean).
import jsyaml from "js-yaml";
import type { ServiceConfig } from "../../types/compose";
import {
parseComposeService,
parseComposeTemplate,
} from "../template-import";
import {
validateNetworks,
validateServices,
validateVolumes,
} from "../validation";
export interface ComposeValidationResult {
ok: boolean;
errors: string[];
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
export function validateComposeYaml(yaml: string): ComposeValidationResult {
if (!yaml || !yaml.trim()) {
return { ok: false, errors: ["Compose file is empty"] };
}
let parsed: unknown;
try {
parsed = jsyaml.load(yaml);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return { ok: false, errors: [`Invalid YAML: ${msg}`] };
}
if (!isPlainObject(parsed)) {
return { ok: false, errors: ["Compose file must be a YAML mapping"] };
}
const errors: string[] = [];
if (!isPlainObject(parsed.services) || Object.keys(parsed.services).length === 0) {
errors.push("Compose file must declare at least one service under `services:`");
}
// Reuse the template-import parser to lift the raw structure into our
// internal types; this gives us free coverage of all the format quirks
// (string vs object ports, dict vs array env, etc.).
const services: ServiceConfig[] = [];
let networksParsed: ReturnType<typeof parseComposeService>["networks"] = [];
let volumesParsed: ReturnType<typeof parseComposeService>["volumes"] = [];
try {
const result = parseComposeTemplate(yaml);
const allNetworks = isPlainObject((parsed as Record<string, unknown>).networks)
? ((parsed as Record<string, unknown>).networks as Record<string, unknown>)
: {};
const allVolumes = isPlainObject((parsed as Record<string, unknown>).volumes)
? ((parsed as Record<string, unknown>).volumes as Record<string, unknown>)
: {};
for (const svc of result.services) {
const lifted = parseComposeService(svc, allNetworks, allVolumes);
services.push(lifted.service);
networksParsed = networksParsed.concat(lifted.networks);
volumesParsed = volumesParsed.concat(lifted.volumes);
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
errors.push(`Failed to parse compose structure: ${msg}`);
}
errors.push(...validateServices(services));
// De-duplicate networks/volumes by name so we don't double-report.
const dedupBy = <T extends { name: string }>(list: T[]): T[] => {
const seen = new Set<string>();
const out: T[] = [];
for (const item of list) {
if (seen.has(item.name)) continue;
seen.add(item.name);
out.push(item);
}
return out;
};
errors.push(...validateNetworks(dedupBy(networksParsed)));
errors.push(...validateVolumes(dedupBy(volumesParsed)));
return { ok: errors.length === 0, errors };
}
+345
View File
@@ -0,0 +1,345 @@
// Zod schemas for Docker Compose v3+ input validation.
// Each export pairs a Zod schema with a `validate(value): string | null` adapter
// that matches the legacy validation.ts signature so existing call sites can
// upgrade incrementally.
import { z } from "zod";
// ---------- Regexes (single source of truth) ----------
// Service name: starts with alnum, then alnum/dot/dash/underscore. Max 63 chars (DNS label).
const SERVICE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
// Container name: same as service name but stricter — Docker requires [a-zA-Z0-9][a-zA-Z0-9_.-]+
const CONTAINER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/;
// Image reference: optional registry/repo path, name, optional :tag, optional @sha256:digest.
// Permissive (matches Docker's reference grammar loosely).
const IMAGE_RE =
/^(?:(?:[a-z0-9]+(?:[._-][a-z0-9]+)*(?::\d+)?\/)?(?:[a-z0-9]+(?:[._-][a-z0-9]+)*)(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*)(?::[\w][\w.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$/i;
// RFC 1123 hostname label (single label, no dots) — used for `hostname` field.
const HOSTNAME_LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
// Full FQDN (allows dots) — used for extra_hosts host part.
const HOSTNAME_FQDN_RE =
/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i;
// Env var key: starts with letter/underscore, then alphanumeric + underscore.
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/i;
// CPU value: positive decimal.
const CPU_RE = /^\d+(\.\d+)?$/;
// Memory value: positive number with optional unit (b/k/m/g, case-insensitive,
// optional 'b' suffix). Docker accepts e.g. 512m, 2g, 1024, 100kb.
const MEMORY_RE = /^\d+(?:\.\d+)?[kmg]?b?$/i;
// Docker duration: integer + ms/s/m/h. Combined like "1h30m" is also valid.
const DURATION_RE = /^(\d+(ms|s|m|h))+$/;
// User: name or uid, optional :group or :gid.
const USER_RE = /^(?:\d+|[a-z_][a-z0-9_-]*)(?::(?:\d+|[a-z_][a-z0-9_-]*))?$/i;
// Network alias (used in `networks`): same rules as service name.
const NETWORK_NAME_RE = SERVICE_NAME_RE;
// Volume name.
const VOLUME_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
// Stop signal: SIGTERM/SIGKILL/etc. or numeric.
const STOP_SIGNAL_RE = /^(SIG[A-Z]+|\d{1,2})$/;
// HTTP(S) URL.
const URL_RE = /^https?:\/\/[^\s]+$/i;
// Absolute UNIX path.
const ABS_UNIX_PATH_RE = /^\/[^\s]*$/;
// ZeroTier network ID: 16 hex chars.
const ZEROTIER_ID_RE = /^[a-f0-9]{16}$/i;
// Compose-style env reference: ${NAME} or literal value.
const ENV_OR_VALUE_RE = /^(\$\{[A-Z_][A-Z0-9_]*\}|[^\s${]+(?:\s+[^\s${]+)*)$/i;
// Tailscale auth key (literal "tskey-" prefix or env reference).
const TAILSCALE_AUTHKEY_RE = /^(tskey-[a-zA-Z0-9-]+|\$\{[A-Z_][A-Z0-9_]*\})$/;
// Docker label key: lowercased reverse-DNS-ish, dot/slash/dash allowed.
const LABEL_KEY_RE = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)*$/i;
// IPv4 octet.
const IPV4_OCTET = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
// ---------- Helpers ----------
function isValidIPv4(value: string): boolean {
const parts = value.split(".");
if (parts.length !== 4) return false;
return parts.every((p) => IPV4_OCTET.test(p));
}
function isValidIPv6(value: string): boolean {
// Minimal IPv6 sanity check: 2..8 hex groups separated by `:`, optional `::`.
if (!/^[0-9a-fA-F:]+$/.test(value)) return false;
if (value.includes(":::")) return false;
// Allow zero-compression (::) once.
const doubleColon = (value.match(/::/g) ?? []).length;
if (doubleColon > 1) return false;
return value.includes(":");
}
function isValidIPv4Cidr(value: string): boolean {
const [ip, mask] = value.split("/");
if (!ip || mask === undefined) return false;
if (!isValidIPv4(ip)) return false;
const n = Number(mask);
return Number.isInteger(n) && n >= 0 && n <= 32;
}
// ---------- Schemas ----------
export const serviceNameSchema = z
.string()
.min(1, "Service name is required")
.max(63, "Service name must be 63 characters or fewer")
.regex(
SERVICE_NAME_RE,
"Service name must start with a letter or digit and contain only letters, digits, dots, dashes, or underscores"
);
export const imageSchema = z
.string()
.min(1, "Image is required")
.max(255, "Image reference is too long")
.regex(
IMAGE_RE,
"Image must be a valid Docker reference (e.g. nginx, nginx:1.25, registry.example.com/org/app:tag)"
);
export const containerNameSchema = z
.string()
.regex(
CONTAINER_NAME_RE,
"Container name must start with a letter or digit and contain only letters, digits, dots, dashes, or underscores"
);
export const hostnameSchema = z
.string()
.max(63, "Hostname label must be 63 characters or fewer")
.regex(HOSTNAME_LABEL_RE, "Hostname must be a valid RFC 1123 label");
export const portSchema = z
.number({ message: "Port must be a number" })
.int("Port must be an integer")
.min(1, "Port must be between 1 and 65535")
.max(65535, "Port must be between 1 and 65535");
export const protocolSchema = z.enum(["tcp", "udp", "sctp"]);
export const networkModeSchema = z
.string()
.refine(
(v) =>
v === "" ||
v === "host" ||
v === "none" ||
v === "bridge" ||
/^service:[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(v) ||
/^container:[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(v),
"Network mode must be host, none, bridge, service:<name>, or container:<name>"
);
export const networkDriverSchema = z.enum([
"bridge",
"host",
"overlay",
"macvlan",
"ipvlan",
"none",
]);
export const volumeDriverSchema = z
.string()
.min(1, "Volume driver must not be empty");
export const envKeySchema = z
.string()
.regex(
ENV_KEY_RE,
"Env var key must start with a letter or underscore and contain only letters, digits, and underscores"
);
export const cpuSchema = z
.string()
.regex(CPU_RE, "CPU value must be a positive number (e.g. 0.5, 1, 2)")
.refine((v) => Number(v) >= 0, "CPU value must be non-negative");
export const memorySchema = z
.string()
.regex(
MEMORY_RE,
"Memory must be a number with optional unit (e.g. 512m, 2g, 1024)"
);
export const durationSchema = z
.string()
.regex(
DURATION_RE,
"Duration must be a number with unit ms, s, m, or h (e.g. 30s, 1m30s)"
);
export const stopSignalSchema = z
.string()
.regex(
STOP_SIGNAL_RE,
"Stop signal must be SIG<NAME> (e.g. SIGTERM) or a signal number"
);
export const userSchema = z
.string()
.regex(USER_RE, "User must be uid[:gid] or username[:group]");
export const networkNameSchema = z
.string()
.regex(NETWORK_NAME_RE, "Network name must be a valid identifier");
export const volumeNameSchema = z
.string()
.regex(VOLUME_NAME_RE, "Volume name must be a valid identifier");
export const ipv4Schema = z
.string()
.refine(isValidIPv4, "Must be a valid IPv4 address");
export const ipAddressSchema = z
.string()
.refine(
(v) => isValidIPv4(v) || isValidIPv6(v),
"Must be a valid IPv4 or IPv6 address"
);
export const ipv4CidrSchema = z
.string()
.refine(isValidIPv4Cidr, "Must be a valid IPv4 CIDR (e.g. 10.0.0.0/16)");
export const extraHostSchema = z
.string()
.refine((v) => /^[^:\s]+:[^:\s]+$/.test(v), {
message: "Extra host must be in the form host:ip",
})
.refine(
(v) => {
const parts = v.split(":");
if (parts.length < 2) return true; // first refine already flagged
const host = parts[0];
const ip = parts.slice(1).join(":");
return HOSTNAME_FQDN_RE.test(host) && (isValidIPv4(ip) || isValidIPv6(ip));
},
{ message: "Extra host must be a valid hostname:ip pair" },
);
export const restartPolicySchema = z.enum([
"no",
"always",
"on-failure",
"unless-stopped",
]);
// Volume host path: warn if relative without `.` / `~` prefix; accept absolute or named volume.
export const volumeHostPathSchema = z
.string()
.min(1, "Host path or volume name is required");
// URL (http or https).
export const urlSchema = z
.string()
.regex(URL_RE, "Must be an http(s) URL");
// Absolute UNIX path (used for wireguard config paths, zerotier identity path).
export const absoluteUnixPathSchema = z
.string()
.regex(ABS_UNIX_PATH_RE, "Must be an absolute path (e.g. /etc/wireguard/wg0.conf)");
// ZeroTier network ID (16 hex). Allow env reference for templating.
export const zerotierIdSchema = z
.string()
.refine(
(v) => ZEROTIER_ID_RE.test(v) || /^\$\{[A-Z_][A-Z0-9_]*\}$/.test(v),
"Must be a 16-character hex network ID or ${ENV_VAR}",
);
// Tailscale auth key (tskey-* or env reference).
export const tailscaleAuthKeySchema = z
.string()
.regex(
TAILSCALE_AUTHKEY_RE,
"Must be a tskey-... value or ${ENV_VAR} reference",
);
// Secret-or-env: non-empty literal OR env reference (used by tunnel tokens, secrets).
export const secretSchema = z
.string()
.min(1, "Required")
.regex(ENV_OR_VALUE_RE, "Must be a literal value or ${ENV_VAR} reference");
// Docker label key.
export const labelKeySchema = z
.string()
.regex(LABEL_KEY_RE, "Label key may use letters, digits, dots, dashes, underscores, and /");
// ---------- Legacy adapters (return string | null) ----------
function adapt(schema: z.ZodTypeAny) {
return (value: unknown): string | null => {
const result = schema.safeParse(value);
if (result.success) return null;
return result.error.issues[0]?.message ?? "Invalid value";
};
}
// Wraps a schema; treats empty string as "no input" and skips validation,
// matching the existing behavior of the legacy validators.
function adaptOptional(schema: z.ZodTypeAny) {
return (value: unknown): string | null => {
if (value === undefined || value === null || value === "") return null;
return adapt(schema)(value);
};
}
export const validate = {
serviceName: adapt(serviceNameSchema),
image: adapt(imageSchema),
containerName: adaptOptional(containerNameSchema),
hostname: adaptOptional(hostnameSchema),
port: (port: string): string | null => {
if (!port) return null;
const n = parseInt(port, 10);
if (Number.isNaN(n)) return "Port must be a number";
return adapt(portSchema)(n);
},
protocol: adaptOptional(protocolSchema),
networkMode: adaptOptional(networkModeSchema),
networkDriver: adaptOptional(networkDriverSchema),
volumeDriver: adaptOptional(volumeDriverSchema),
envKey: adaptOptional(envKeySchema),
cpu: adaptOptional(cpuSchema),
memory: adaptOptional(memorySchema),
duration: adaptOptional(durationSchema),
stopSignal: adaptOptional(stopSignalSchema),
user: adaptOptional(userSchema),
networkName: adaptOptional(networkNameSchema),
volumeName: adaptOptional(volumeNameSchema),
ipv4: adaptOptional(ipv4Schema),
ipAddress: adaptOptional(ipAddressSchema),
ipv4Cidr: adaptOptional(ipv4CidrSchema),
extraHost: adaptOptional(extraHostSchema),
restartPolicy: adaptOptional(restartPolicySchema),
volumeHostPath: adaptOptional(volumeHostPathSchema),
url: adaptOptional(urlSchema),
absoluteUnixPath: adaptOptional(absoluteUnixPathSchema),
zerotierId: adaptOptional(zerotierIdSchema),
tailscaleAuthKey: adaptOptional(tailscaleAuthKeySchema),
secret: adapt(secretSchema),
labelKey: adaptOptional(labelKeySchema),
};
+179
View File
@@ -0,0 +1,179 @@
// VPN configuration validation.
//
// `vpnConfigWarning(vpn)` returns a single warning string suitable for the
// inline alert in the VPN tab — null if the configuration is complete enough
// to render valid compose output. `validateVpnConfig(vpn)` returns the full
// list for the YAML-validation pipeline.
import type { VPNConfig } from "../../types/vpn-configs";
import { validate } from "./schemas";
function nonEmpty(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function isEnvRef(value: string): boolean {
return /^\$\{[A-Z_][A-Z0-9_]*\}$/.test(value);
}
function validateTailscale(vpn: VPNConfig, errors: string[]) {
const cfg = vpn.tailscale;
if (!cfg) return;
if (!nonEmpty(cfg.authKey)) {
errors.push("Tailscale auth key is required");
} else {
const err = validate.tailscaleAuthKey(cfg.authKey);
if (err) errors.push(`Tailscale auth key: ${err}`);
}
if (nonEmpty(cfg.hostname)) {
const err = validate.hostname(cfg.hostname);
if (err) errors.push(`Tailscale hostname: ${err}`);
}
if (nonEmpty(cfg.exitNode)) {
const err = validate.ipAddress(cfg.exitNode);
if (err) errors.push(`Tailscale exit node: ${err}`);
}
if (cfg.enableServe) {
if (!nonEmpty(cfg.serveTargetService)) {
errors.push("Tailscale serve: target service is required");
}
if (nonEmpty(cfg.serveExternalPort)) {
const err = validate.port(cfg.serveExternalPort);
if (err) errors.push(`Tailscale serve external port: ${err}`);
}
if (nonEmpty(cfg.serveInternalPort)) {
const err = validate.port(cfg.serveInternalPort);
if (err) errors.push(`Tailscale serve internal port: ${err}`);
}
}
}
function validateNewt(vpn: VPNConfig, errors: string[]) {
const cfg = vpn.newt;
if (!cfg) return;
if (!nonEmpty(cfg.newtId)) errors.push("Newt ID is required");
if (!nonEmpty(cfg.newtSecret)) errors.push("Newt secret is required");
if (nonEmpty(cfg.endpoint) && !isEnvRef(cfg.endpoint)) {
const err = validate.url(cfg.endpoint);
if (err) errors.push(`Newt endpoint: ${err}`);
}
// Optional networking fields — only validate when set.
if (nonEmpty(cfg.port)) {
const err = validate.port(cfg.port);
if (err) errors.push(`Newt port: ${err}`);
}
if (nonEmpty(cfg.mtu) && (!/^\d+$/.test(cfg.mtu) || Number(cfg.mtu) < 576 || Number(cfg.mtu) > 9000)) {
errors.push("Newt MTU must be an integer in 576–9000");
}
if (nonEmpty(cfg.dns)) {
const err = validate.ipAddress(cfg.dns);
if (err) errors.push(`Newt DNS: ${err}`);
}
if (nonEmpty(cfg.interfaceName)) {
const err = validate.networkName(cfg.interfaceName);
if (err) errors.push(`Newt interface: ${err}`);
}
if (nonEmpty(cfg.pingInterval)) {
const err = validate.duration(cfg.pingInterval);
if (err) errors.push(`Newt ping interval: ${err}`);
}
if (nonEmpty(cfg.pingTimeout)) {
const err = validate.duration(cfg.pingTimeout);
if (err) errors.push(`Newt ping timeout: ${err}`);
}
if (nonEmpty(cfg.healthFile)) {
const err = validate.absoluteUnixPath(cfg.healthFile);
if (err) errors.push(`Newt health file: ${err}`);
}
if (nonEmpty(cfg.preferEndpoint) && !isEnvRef(cfg.preferEndpoint)) {
const err = validate.url(cfg.preferEndpoint);
if (err) errors.push(`Newt prefer endpoint: ${err}`);
}
const validLevels = new Set(["DEBUG", "INFO", "WARN", "ERROR", "FATAL"]);
if (cfg.logLevel && !validLevels.has(cfg.logLevel)) {
errors.push("Newt log level must be DEBUG, INFO, WARN, ERROR, or FATAL");
}
}
function validateCloudflared(vpn: VPNConfig, errors: string[]) {
const cfg = vpn.cloudflared;
if (!cfg) return;
if (!nonEmpty(cfg.tunnelToken)) {
errors.push("Cloudflared tunnel token is required");
}
}
function validateWireguard(vpn: VPNConfig, errors: string[]) {
const cfg = vpn.wireguard;
if (!cfg) return;
if (!nonEmpty(cfg.configPath)) {
errors.push("WireGuard config path is required");
} else {
const err = validate.absoluteUnixPath(cfg.configPath);
if (err) errors.push(`WireGuard config path: ${err}`);
}
if (nonEmpty(cfg.interfaceName)) {
const err = validate.networkName(cfg.interfaceName);
if (err) errors.push(`WireGuard interface: ${err}`);
}
}
function validateZerotier(vpn: VPNConfig, errors: string[]) {
const cfg = vpn.zerotier;
if (!cfg) return;
if (!nonEmpty(cfg.networkId)) {
errors.push("ZeroTier network ID is required");
} else if (!isEnvRef(cfg.networkId)) {
const err = validate.zerotierId(cfg.networkId);
if (err) errors.push(`ZeroTier network ID: ${err}`);
}
if (nonEmpty(cfg.identityPath)) {
const err = validate.absoluteUnixPath(cfg.identityPath);
if (err) errors.push(`ZeroTier identity path: ${err}`);
}
}
function validateNetbird(vpn: VPNConfig, errors: string[]) {
const cfg = vpn.netbird;
if (!cfg) return;
if (!nonEmpty(cfg.setupKey)) errors.push("NetBird setup key is required");
if (nonEmpty(cfg.managementUrl) && !isEnvRef(cfg.managementUrl)) {
const err = validate.url(cfg.managementUrl);
if (err) errors.push(`NetBird management URL: ${err}`);
}
}
export function validateVpnConfig(vpn: VPNConfig): string[] {
if (!vpn.enabled || !vpn.type) return [];
const errors: string[] = [];
switch (vpn.type) {
case "tailscale":
validateTailscale(vpn, errors);
break;
case "newt":
validateNewt(vpn, errors);
break;
case "cloudflared":
validateCloudflared(vpn, errors);
break;
case "wireguard":
validateWireguard(vpn, errors);
break;
case "zerotier":
validateZerotier(vpn, errors);
break;
case "netbird":
validateNetbird(vpn, errors);
break;
}
if (vpn.servicesUsingVpn.length === 0) {
errors.push("At least one service must be routed through the VPN");
}
return errors;
}
/** Compact first-error summary for the inline VPN tab warning. */
export function vpnConfigWarning(vpn: VPNConfig): string | null {
const errors = validateVpnConfig(vpn);
return errors.length === 0 ? null : errors[0];
}
+2 -1
View File
@@ -62,7 +62,8 @@ export function generateVpnService(vpnConfig: VPNConfig | undefined): any {
if (!vpnConfig || !vpnConfig.enabled || !vpnConfig.type) return null;
const serviceName = getVpnServiceName(vpnConfig.type);
let service: any = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const service: any = {
restart: "always",
};
+3 -615
View File
@@ -1,616 +1,4 @@
// YAML Generation Utilities for Docker Compose
// Compatibility shim for the legacy yaml-generator entry point.
// Implementation now lives in src/utils/yaml/*.
import type {
ServiceConfig,
NetworkConfig,
VolumeConfig,
} from "../types/compose";
import type { VPNConfig } from "../types/vpn-configs";
import {
generateVpnService,
getVpnVolumes,
getVpnNetworks,
getVpnServiceName,
generateTailscaleServeConfig,
} from "./vpn-generator";
import { defaultVPNConfig } from "./default-configs";
export function generateYaml(
services: ServiceConfig[],
networks: NetworkConfig[],
volumes: VolumeConfig[],
vpnConfig?: VPNConfig
): string {
// Ensure vpnConfig has a default value
const vpn = vpnConfig || defaultVPNConfig();
const compose: any = { services: {} };
services.forEach((svc) => {
if (!svc.name) return;
const parseCommandString = (cmd: string): string[] => {
if (!cmd) return [];
if (Array.isArray(cmd)) {
return cmd;
}
try {
const parsed = JSON.parse(cmd);
if (Array.isArray(parsed)) {
return parsed;
}
} catch (e) {}
const parts = cmd.match(/(?:"[^"]*"|'[^']*'|\S+)/g) || [];
return parts.map((part) => {
const trimmed = part.replace(/^["']|["']$/g, "");
return trimmed;
});
};
// Check if service should use VPN
const shouldUseVpn =
vpn.enabled &&
vpnConfig?.type &&
vpn.servicesUsingVpn.includes(svc.name);
const vpnServiceName =
vpn.enabled && vpn.type ? getVpnServiceName(vpn.type) : null;
// VPN types that use network_mode
const usesNetworkMode =
vpn.enabled &&
vpn.type &&
["tailscale", "cloudflared"].includes(vpn.type) &&
shouldUseVpn;
compose.services[svc.name] = {
image: svc.image || undefined,
container_name: svc.container_name || undefined,
command: svc.command ? parseCommandString(svc.command) : undefined,
restart: svc.restart || undefined,
// If using VPN with network_mode, don't expose ports (they go through VPN)
ports: usesNetworkMode
? undefined
: svc.ports.length
? svc.ports
.map((p) => {
if (!p.container) return undefined;
const portStr =
p.host && p.container
? `${p.host}:${p.container}`
: p.container;
// Only add protocol if it's not "none"
return p.protocol && p.protocol !== "none"
? `${portStr}/${p.protocol}`
: portStr;
})
.filter(Boolean)
: undefined,
expose:
svc.expose && svc.expose.length > 0
? svc.expose.filter(Boolean)
: undefined,
// Network mode: use VPN network_mode if enabled, otherwise use user-defined
network_mode:
usesNetworkMode && vpnServiceName
? `service:${vpnServiceName}`
: svc.network_mode || undefined,
volumes: svc.volumes.length
? svc.volumes_syntax === "dict"
? svc.volumes
.map((v) => {
if (v.host && v.container) {
const vol: any = {
type: "bind",
source: v.host,
target: v.container,
};
if (v.read_only) {
vol.read_only = true;
}
return vol;
} else if (v.container) {
// Anonymous volume - just target path
return {
type: "volume",
target: v.container,
};
}
return undefined;
})
.filter(Boolean)
: svc.volumes
.map((v) => {
if (v.host && v.container) {
return v.read_only
? `${v.host}:${v.container}:ro`
: `${v.host}:${v.container}`;
}
return v.container ? v.container : undefined;
})
.filter(Boolean)
: undefined,
environment: svc.environment.length
? svc.environment_syntax === "dict"
? svc.environment
.filter(({ key }) => key)
.reduce(
(acc, { key, value }) => {
acc[key] = value;
return acc;
},
{} as Record<string, string>
)
: svc.environment
.filter(({ key }) => key)
.map(({ key, value }) => `${key}=${value}`)
: undefined,
healthcheck:
svc.healthcheck && svc.healthcheck.test
? {
test: parseCommandString(svc.healthcheck.test),
interval: svc.healthcheck.interval || undefined,
timeout: svc.healthcheck.timeout || undefined,
retries: svc.healthcheck.retries || undefined,
start_period: svc.healthcheck.start_period || undefined,
start_interval: svc.healthcheck.start_interval || undefined,
}
: undefined,
depends_on:
svc.depends_on && svc.depends_on.filter(Boolean).length
? svc.depends_on.filter(Boolean)
: undefined,
entrypoint: svc.entrypoint
? parseCommandString(svc.entrypoint)
: undefined,
env_file:
svc.env_file && svc.env_file.trim()
? svc.env_file.split(",").map((f) => f.trim())
: undefined,
extra_hosts:
svc.extra_hosts && svc.extra_hosts.filter(Boolean).length
? svc.extra_hosts.filter(Boolean)
: undefined,
dns:
svc.dns && svc.dns.filter(Boolean).length
? svc.dns.filter(Boolean)
: undefined,
networks: usesNetworkMode
? undefined
: shouldUseVpn && vpn.type === "newt" && vpn.newt
? [vpn.newt.networkName]
: svc.networks && svc.networks.filter(Boolean).length
? svc.networks.filter(Boolean)
: undefined,
user: svc.user || undefined,
working_dir: svc.working_dir || undefined,
labels:
svc.labels && svc.labels.filter((l) => l.key).length
? svc.labels
.filter((l) => l.key)
.map(({ key, value }) => `${key}=${value}`)
: undefined,
privileged: svc.privileged !== undefined ? svc.privileged : undefined,
read_only: svc.read_only !== undefined ? svc.read_only : undefined,
shm_size: svc.shm_size || undefined,
security_opt:
svc.security_opt && svc.security_opt.filter(Boolean).length
? svc.security_opt.filter(Boolean)
: undefined,
cap_add:
svc.cap_add && svc.cap_add.filter(Boolean).length
? svc.cap_add.filter(Boolean)
: undefined,
cap_drop:
svc.cap_drop && svc.cap_drop.filter(Boolean).length
? svc.cap_drop.filter(Boolean)
: undefined,
sysctls:
svc.sysctls && svc.sysctls.filter((s) => s.key).length
? svc.sysctls
.filter((s) => s.key)
.reduce(
(acc, { key, value }) => {
acc[key] = value || undefined;
return acc;
},
{} as Record<string, string | undefined>
)
: undefined,
devices:
svc.devices && svc.devices.filter(Boolean).length
? svc.devices.filter(Boolean)
: undefined,
tmpfs:
svc.tmpfs && svc.tmpfs.filter(Boolean).length
? svc.tmpfs.filter(Boolean)
: undefined,
ulimits:
svc.ulimits && svc.ulimits.filter((u) => u.name).length
? svc.ulimits
.filter((u) => u.name)
.reduce(
(acc, u) => {
if (u.soft && u.hard) {
acc[u.name] = {
soft: parseInt(u.soft),
hard: parseInt(u.hard),
};
} else if (u.soft) {
acc[u.name] = { soft: parseInt(u.soft) };
} else if (u.hard) {
acc[u.name] = { hard: parseInt(u.hard) };
} else {
acc[u.name] = {};
}
return acc;
},
{} as Record<string, any>
)
: undefined,
init: svc.init !== undefined ? svc.init : undefined,
stop_grace_period: svc.stop_grace_period || undefined,
stop_signal: svc.stop_signal || undefined,
tty: svc.tty !== undefined ? svc.tty : undefined,
stdin_open: svc.stdin_open !== undefined ? svc.stdin_open : undefined,
hostname: svc.hostname || undefined,
domainname: svc.domainname || undefined,
mac_address: svc.mac_address || undefined,
ipc: svc.ipc_mode || undefined,
pid: svc.pid || undefined,
uts: svc.uts || undefined,
cgroup_parent: svc.cgroup_parent || undefined,
isolation: svc.isolation || undefined,
deploy:
svc.deploy && svc.deploy.resources
? (() => {
const limits: any = {};
if (svc.deploy.resources.limits?.cpus)
limits.cpus = svc.deploy.resources.limits.cpus;
if (svc.deploy.resources.limits?.memory)
limits.memory = svc.deploy.resources.limits.memory;
const reservations: any = {};
if (svc.deploy.resources.reservations?.cpus)
reservations.cpus = svc.deploy.resources.reservations.cpus;
if (svc.deploy.resources.reservations?.memory)
reservations.memory =
svc.deploy.resources.reservations.memory;
const resources: any = {};
if (Object.keys(limits).length > 0) resources.limits = limits;
if (Object.keys(reservations).length > 0)
resources.reservations = reservations;
return Object.keys(resources).length > 0
? { resources }
: undefined;
})()
: undefined,
};
});
for (const name in compose.services) {
Object.keys(compose.services[name]).forEach(
(k) =>
compose.services[name][k] === undefined &&
delete compose.services[name][k]
);
}
// Add VPN service if enabled
if (vpn.enabled && vpn.type) {
const vpnService = generateVpnService(vpn);
if (vpnService) {
Object.assign(compose.services, vpnService);
}
}
// Add VPN volumes
const vpnVolumes = getVpnVolumes(vpn);
if (vpnVolumes.length > 0) {
volumes = [...volumes, ...vpnVolumes];
}
// Add VPN networks
const vpnNetworks = getVpnNetworks(vpn);
if (vpnNetworks.length > 0) {
networks = [...networks, ...vpnNetworks];
}
// Add Tailscale serve configs if enabled
if (
vpn.enabled &&
vpn.type === "tailscale" &&
vpn.tailscale?.enableServe &&
vpn.tailscale?.serveTargetService
) {
const ts = vpn.tailscale;
const serveConfig = generateTailscaleServeConfig(
ts.serveTargetService,
ts.serveExternalPort,
ts.serveInternalPort,
ts.servePath,
ts.serveProtocol,
ts.certDomain,
ts.serveInsideProtocol || "http"
);
if (!compose.configs) {
compose.configs = {};
}
compose.configs["serve-config"] = {
content: serveConfig,
};
}
if (networks.length) {
compose.networks = {};
networks.forEach((n) => {
if (!n.name) return;
if (n.external) {
compose.networks[n.name] = {
external: n.name_external ? { name: n.name_external } : true,
};
} else {
compose.networks[n.name] = {
driver: n.driver || undefined,
attachable: n.attachable !== undefined ? n.attachable : undefined,
internal: n.internal !== undefined ? n.internal : undefined,
enable_ipv6:
n.enable_ipv6 !== undefined ? n.enable_ipv6 : undefined,
driver_opts:
n.driver_opts && n.driver_opts.length
? n.driver_opts
.filter((opt) => opt.key)
.reduce(
(acc, { key, value }) => {
acc[key] = value;
return acc;
},
{} as Record<string, string>
)
: undefined,
labels:
n.labels && n.labels.length
? n.labels
.filter((l) => l.key)
.map(({ key, value }) => `${key}=${value}`)
: undefined,
ipam:
n.ipam.driver || n.ipam.config.length || n.ipam.options.length
? {
driver: n.ipam.driver || undefined,
config: n.ipam.config.length ? n.ipam.config : undefined,
options: n.ipam.options.length
? n.ipam.options
.filter((opt) => opt.key)
.reduce(
(acc, { key, value }) => {
acc[key] = value;
return acc;
},
{} as Record<string, string>
)
: undefined,
}
: undefined,
};
}
Object.keys(compose.networks[n.name]).forEach(
(k) =>
compose.networks[n.name][k] === undefined &&
delete compose.networks[n.name][k]
);
});
}
if (volumes.length) {
compose.volumes = {};
volumes.forEach((v) => {
if (!v.name) return;
if (v.external) {
const externalVolume: any = {
external: v.name_external ? { name: v.name_external } : true,
};
if (v.driver) {
externalVolume.driver = v.driver;
}
const driverOpts: Record<string, string> = {};
if (v.driver_opts && v.driver_opts.length) {
v.driver_opts
.filter((opt) => opt.key)
.forEach(({ key, value }) => {
driverOpts[key] = value;
});
}
if (v.driver_opts_type) driverOpts.type = v.driver_opts_type;
if (v.driver_opts_device) driverOpts.device = v.driver_opts_device;
if (v.driver_opts_o) driverOpts.o = v.driver_opts_o;
if (Object.keys(driverOpts).length > 0) {
externalVolume.driver_opts = driverOpts;
}
if (v.labels && v.labels.length) {
externalVolume.labels = v.labels
.filter((l) => l.key)
.map(({ key, value }) => `${key}=${value}`);
}
compose.volumes[v.name] = externalVolume;
} else {
const driverOpts: Record<string, string> = {};
if (v.driver_opts && v.driver_opts.length) {
v.driver_opts
.filter((opt) => opt.key)
.forEach(({ key, value }) => {
driverOpts[key] = value;
});
}
if (v.driver_opts_type) driverOpts.type = v.driver_opts_type;
if (v.driver_opts_device) driverOpts.device = v.driver_opts_device;
if (v.driver_opts_o) driverOpts.o = v.driver_opts_o;
compose.volumes[v.name] = {
driver: v.driver || undefined,
driver_opts:
Object.keys(driverOpts).length > 0 ? driverOpts : undefined,
labels:
v.labels && v.labels.length
? v.labels
.filter((l) => l.key)
.map(({ key, value }) => `${key}=${value}`)
: undefined,
};
}
Object.keys(compose.volumes[v.name]).forEach(
(k) =>
compose.volumes[v.name][k] === undefined &&
delete compose.volumes[v.name][k]
);
});
}
let yamlOutput = yamlStringify(compose);
// Add comments to YAML for VPN services
if (vpn.enabled && vpn.type) {
const lines = yamlOutput.split("\n");
const commentedLines: string[] = [];
let inVpnService = false;
let inServicesSection = false;
let inVolumesSection = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Track which section we're in
if (trimmed === "services:") {
inServicesSection = true;
inVolumesSection = false;
} else if (trimmed === "volumes:") {
inVolumesSection = true;
inServicesSection = false;
} else if (trimmed === "networks:" || trimmed === "configs:") {
inServicesSection = false;
inVolumesSection = false;
}
// Detect VPN service (only in services section, not volumes)
if (
inServicesSection &&
!inVolumesSection &&
(trimmed.startsWith(`${vpn.type}:`) || trimmed === `${vpn.type}:`)
) {
inVpnService = true;
commentedLines.push("");
commentedLines.push(`# ${vpn.type} VPN Sidecar Configuration`);
if (vpn.type === "tailscale") {
commentedLines.push("# Routes traffic through Tailscale VPN");
if (vpn.tailscale?.hostname) {
commentedLines.push(`# Hostname: ${vpn.tailscale.hostname}`);
}
if (vpn.tailscale?.enableServe) {
commentedLines.push(
"# Tailscale Serve enabled - exposes service on Tailnet"
);
}
}
}
// Detect end of service (next service or section)
if (
inVpnService &&
trimmed &&
!trimmed.startsWith(" ") &&
!trimmed.startsWith("-") &&
trimmed.endsWith(":") &&
trimmed !== `${vpn.type}:`
) {
inVpnService = false;
}
commentedLines.push(line);
}
yamlOutput = commentedLines.join("\n");
}
return yamlOutput;
}
export function yamlStringify(obj: any, indent = 0, parentKey = ""): string {
const pad = (n: number) => " ".repeat(n);
if (typeof obj !== "object" || obj === null) return String(obj);
if (Array.isArray(obj)) {
const shouldBeSingleLine =
["command", "entrypoint"].includes(parentKey) ||
(parentKey === "test" && indent > 0);
if (shouldBeSingleLine && obj.length > 0 && typeof obj[0] === "string") {
return `[${obj.map((v) => `"${v}"`).join(", ")}]`;
}
return obj
.map(
(v) =>
`\n${pad(indent)}- ${yamlStringify(v, indent + 1, parentKey).trimStart()}`
)
.join("");
}
const entries = Object.entries(obj)
.map(([k, v]) => {
if (v === undefined) return "";
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
return `\n${pad(indent)}${k}:` + yamlStringify(v, indent + 1, k);
}
if (Array.isArray(v)) {
if (
["command", "entrypoint"].includes(k) ||
(k === "test" && indent > 0)
) {
return `\n${pad(indent)}${k}: [${v.map((item) => `"${item}"`).join(", ")}]`;
}
return `\n${pad(indent)}${k}: ` + yamlStringify(v, indent + 1, k);
}
// Handle multi-line strings (like JSON in configs.content) using literal block scalar
if (
typeof v === "string" &&
k === "content" &&
parentKey &&
v.includes("\n")
) {
// Use YAML literal block scalar (|) to preserve multi-line strings
const lines = v.split("\n");
const escapedLines = lines.map((line, idx) => {
// Escape special YAML characters if needed
if (line.trim() === "" && idx === lines.length - 1) return "";
return line;
});
return `\n${pad(indent)}${k}: |\n${escapedLines.map((line) => `${pad(indent + 1)}${line}`).join("\n")}`;
}
// For regular strings, output as-is (don't add quotes unless necessary)
// Port strings (like "8080:8080" or "8080/tcp") should not be quoted
if (typeof v === "string") {
// Don't quote port mappings (format: "host:container" or "port/protocol")
const isPortMapping = /^\d+(:\d+)?(\/\w+)?$/.test(v);
// Don't quote simple numeric strings or port-like values
if (isPortMapping || /^\d+$/.test(v)) {
return `\n${pad(indent)}${k}: ${v}`;
}
// Only quote if the string contains special YAML characters that need escaping
// But exclude colons in port mappings which are already handled above
const needsQuotes =
/^[\d-]|[:{}\[\],&*#?|>'"%@`]/.test(v) || v.trim() !== v;
return `\n${pad(indent)}${k}: ${needsQuotes ? `"${v.replace(/"/g, '\\"')}"` : v}`;
}
return `\n${pad(indent)}${k}: ${v}`;
})
.join("");
return indent === 0 && entries.startsWith("\n")
? entries.slice(1)
: entries;
}
export { generateYaml, yamlStringify } from "./yaml";
+17
View File
@@ -0,0 +1,17 @@
// Shared helper: parse a command-line string into argv form.
// Used by services (command/entrypoint) and healthcheck.test.
export function parseCommandString(cmd: string): string[] {
if (!cmd) return [];
if (Array.isArray(cmd)) return cmd as unknown as string[];
try {
const parsed = JSON.parse(cmd);
if (Array.isArray(parsed)) return parsed as string[];
} catch {
// Not JSON — fall through to whitespace tokenization.
}
const parts = cmd.match(/(?:"[^"]*"|'[^']*'|\S+)/g) || [];
return parts.map((part) => part.replace(/^["']|["']$/g, ""));
}
+52
View File
@@ -0,0 +1,52 @@
// Orchestrator: composes the service / network / volume / vpn builders
// into the final YAML string. Public entry point.
import type {
NetworkConfig,
ServiceConfig,
VolumeConfig,
} from "../../types/compose";
import type { VPNConfig } from "../../types/vpn-configs";
import { defaultVPNConfig } from "../default-configs";
import { getVpnServiceName } from "../vpn-generator";
import { buildServiceEntry } from "./service";
import { buildNetworksBlock } from "./network";
import { buildVolumesBlock } from "./volume";
import { annotateVpnComments, injectVpnArtifacts } from "./vpn";
import { yamlStringify } from "./stringify";
export { yamlStringify } from "./stringify";
export function generateYaml(
services: ServiceConfig[],
networks: NetworkConfig[],
volumes: VolumeConfig[],
vpnConfig?: VPNConfig,
): string {
const vpn = vpnConfig || defaultVPNConfig();
const vpnServiceName = vpn.enabled && vpn.type ? getVpnServiceName(vpn.type) : null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const compose: Record<string, any> = { services: {} };
services.forEach((svc) => {
if (!svc.name) return;
compose.services[svc.name] = buildServiceEntry(svc, vpn, vpnServiceName);
});
const { networks: networksWithVpn, volumes: volumesWithVpn } = injectVpnArtifacts(
compose,
vpn,
networks,
volumes,
);
const networksBlock = buildNetworksBlock(networksWithVpn);
if (networksBlock) compose.networks = networksBlock;
const volumesBlock = buildVolumesBlock(volumesWithVpn);
if (volumesBlock) compose.volumes = volumesBlock;
const yaml = yamlStringify(compose);
return annotateVpnComments(yaml, vpn);
}
+62
View File
@@ -0,0 +1,62 @@
// Build the top-level `networks:` block.
import type { NetworkConfig } from "../../types/compose";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type NetworksBlock = Record<string, any>;
export function buildNetworksBlock(networks: NetworkConfig[]): NetworksBlock | undefined {
if (!networks.length) return undefined;
const out: NetworksBlock = {};
networks.forEach((n) => {
if (!n.name) return;
if (n.external) {
out[n.name] = { external: n.name_external ? { name: n.name_external } : true };
} else {
out[n.name] = {
driver: n.driver || undefined,
attachable: n.attachable !== undefined ? n.attachable : undefined,
internal: n.internal !== undefined ? n.internal : undefined,
enable_ipv6: n.enable_ipv6 !== undefined ? n.enable_ipv6 : undefined,
driver_opts:
n.driver_opts && n.driver_opts.length
? n.driver_opts
.filter((opt) => opt.key)
.reduce(
(acc, { key, value }) => {
acc[key] = value;
return acc;
},
{} as Record<string, string>,
)
: undefined,
labels:
n.labels && n.labels.length
? n.labels.filter((l) => l.key).map(({ key, value }) => `${key}=${value}`)
: undefined,
ipam:
n.ipam.driver || n.ipam.config.length || n.ipam.options.length
? {
driver: n.ipam.driver || undefined,
config: n.ipam.config.length ? n.ipam.config : undefined,
options: n.ipam.options.length
? n.ipam.options
.filter((opt) => opt.key)
.reduce(
(acc, { key, value }) => {
acc[key] = value;
return acc;
},
{} as Record<string, string>,
)
: undefined,
}
: undefined,
};
}
Object.keys(out[n.name]).forEach(
(k) => out[n.name][k] === undefined && delete out[n.name][k],
);
});
return Object.keys(out).length > 0 ? out : undefined;
}
+233
View File
@@ -0,0 +1,233 @@
// Build the Docker Compose `services.<name>` block for one service.
// Pure mapping of ServiceConfig → plain JS object that yamlStringify can emit.
import type { ServiceConfig } from "../../types/compose";
import type { VPNConfig } from "../../types/vpn-configs";
import { parseCommandString } from "./command";
const NETWORK_MODE_VPN_TYPES = ["tailscale", "cloudflared"];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ComposeServiceEntry = Record<string, any>;
export function buildServiceEntry(
svc: ServiceConfig,
vpn: VPNConfig,
vpnServiceName: string | null,
): ComposeServiceEntry {
const shouldUseVpn =
vpn.enabled && vpn.type !== null && vpn.servicesUsingVpn.includes(svc.name);
const usesNetworkMode =
vpn.enabled &&
vpn.type !== null &&
NETWORK_MODE_VPN_TYPES.includes(vpn.type) &&
shouldUseVpn;
const entry: ComposeServiceEntry = {
image: svc.image || undefined,
container_name: svc.container_name || undefined,
command: svc.command ? parseCommandString(svc.command) : undefined,
restart: svc.restart || undefined,
ports: usesNetworkMode
? undefined
: svc.ports.length
? svc.ports
.map((p) => {
if (!p.container) return undefined;
const portStr =
p.host && p.container ? `${p.host}:${p.container}` : p.container;
return p.protocol && p.protocol !== "none"
? `${portStr}/${p.protocol}`
: portStr;
})
.filter(Boolean)
: undefined,
expose:
svc.expose && svc.expose.length > 0 ? svc.expose.filter(Boolean) : undefined,
network_mode:
usesNetworkMode && vpnServiceName
? `service:${vpnServiceName}`
: svc.network_mode || undefined,
volumes: svc.volumes.length
? svc.volumes_syntax === "dict"
? svc.volumes
.map((v) => {
if (v.host && v.container) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vol: any = { type: "bind", source: v.host, target: v.container };
if (v.read_only) vol.read_only = true;
return vol;
} else if (v.container) {
return { type: "volume", target: v.container };
}
return undefined;
})
.filter(Boolean)
: svc.volumes
.map((v) => {
if (v.host && v.container) {
return v.read_only
? `${v.host}:${v.container}:ro`
: `${v.host}:${v.container}`;
}
return v.container ? v.container : undefined;
})
.filter(Boolean)
: undefined,
environment: svc.environment.length
? svc.environment_syntax === "dict"
? svc.environment
.filter(({ key }) => key)
.reduce(
(acc, { key, value }) => {
acc[key] = value;
return acc;
},
{} as Record<string, string>,
)
: svc.environment
.filter(({ key }) => key)
.map(({ key, value }) => `${key}=${value}`)
: undefined,
healthcheck:
svc.healthcheck && svc.healthcheck.test
? {
test: parseCommandString(svc.healthcheck.test),
interval: svc.healthcheck.interval || undefined,
timeout: svc.healthcheck.timeout || undefined,
retries: svc.healthcheck.retries || undefined,
start_period: svc.healthcheck.start_period || undefined,
start_interval: svc.healthcheck.start_interval || undefined,
}
: undefined,
depends_on:
svc.depends_on && svc.depends_on.filter(Boolean).length
? svc.depends_on.filter(Boolean)
: undefined,
entrypoint: svc.entrypoint ? parseCommandString(svc.entrypoint) : undefined,
env_file:
svc.env_file && svc.env_file.trim()
? svc.env_file.split(",").map((f) => f.trim())
: undefined,
extra_hosts:
svc.extra_hosts && svc.extra_hosts.filter(Boolean).length
? svc.extra_hosts.filter(Boolean)
: undefined,
dns:
svc.dns && svc.dns.filter(Boolean).length ? svc.dns.filter(Boolean) : undefined,
networks: usesNetworkMode
? undefined
: shouldUseVpn && vpn.type === "newt" && vpn.newt
? [vpn.newt.networkName]
: svc.networks && svc.networks.filter(Boolean).length
? svc.networks.filter(Boolean)
: undefined,
user: svc.user || undefined,
working_dir: svc.working_dir || undefined,
labels:
svc.labels && svc.labels.filter((l) => l.key).length
? svc.labels
.filter((l) => l.key)
.map(({ key, value }) => `${key}=${value}`)
: undefined,
privileged: svc.privileged !== undefined ? svc.privileged : undefined,
read_only: svc.read_only !== undefined ? svc.read_only : undefined,
shm_size: svc.shm_size || undefined,
security_opt:
svc.security_opt && svc.security_opt.filter(Boolean).length
? svc.security_opt.filter(Boolean)
: undefined,
cap_add:
svc.cap_add && svc.cap_add.filter(Boolean).length
? svc.cap_add.filter(Boolean)
: undefined,
cap_drop:
svc.cap_drop && svc.cap_drop.filter(Boolean).length
? svc.cap_drop.filter(Boolean)
: undefined,
sysctls:
svc.sysctls && svc.sysctls.filter((s) => s.key).length
? svc.sysctls
.filter((s) => s.key)
.reduce(
(acc, { key, value }) => {
acc[key] = value || undefined;
return acc;
},
{} as Record<string, string | undefined>,
)
: undefined,
devices:
svc.devices && svc.devices.filter(Boolean).length
? svc.devices.filter(Boolean)
: undefined,
tmpfs:
svc.tmpfs && svc.tmpfs.filter(Boolean).length
? svc.tmpfs.filter(Boolean)
: undefined,
ulimits:
svc.ulimits && svc.ulimits.filter((u) => u.name).length
? svc.ulimits
.filter((u) => u.name)
.reduce(
(acc, u) => {
if (u.soft && u.hard) {
acc[u.name] = { soft: parseInt(u.soft), hard: parseInt(u.hard) };
} else if (u.soft) {
acc[u.name] = { soft: parseInt(u.soft) };
} else if (u.hard) {
acc[u.name] = { hard: parseInt(u.hard) };
} else {
acc[u.name] = {};
}
return acc;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{} as Record<string, any>,
)
: undefined,
init: svc.init !== undefined ? svc.init : undefined,
stop_grace_period: svc.stop_grace_period || undefined,
stop_signal: svc.stop_signal || undefined,
tty: svc.tty !== undefined ? svc.tty : undefined,
stdin_open: svc.stdin_open !== undefined ? svc.stdin_open : undefined,
hostname: svc.hostname || undefined,
domainname: svc.domainname || undefined,
mac_address: svc.mac_address || undefined,
ipc: svc.ipc_mode || undefined,
pid: svc.pid || undefined,
uts: svc.uts || undefined,
cgroup_parent: svc.cgroup_parent || undefined,
isolation: svc.isolation || undefined,
deploy: buildDeployBlock(svc),
};
// Strip undefined keys so yamlStringify doesn't emit them.
Object.keys(entry).forEach((k) => entry[k] === undefined && delete entry[k]);
return entry;
}
function buildDeployBlock(svc: ServiceConfig) {
if (!svc.deploy || !svc.deploy.resources) return undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const limits: any = {};
if (svc.deploy.resources.limits?.cpus) limits.cpus = svc.deploy.resources.limits.cpus;
if (svc.deploy.resources.limits?.memory)
limits.memory = svc.deploy.resources.limits.memory;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const reservations: any = {};
if (svc.deploy.resources.reservations?.cpus)
reservations.cpus = svc.deploy.resources.reservations.cpus;
if (svc.deploy.resources.reservations?.memory)
reservations.memory = svc.deploy.resources.reservations.memory;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resources: any = {};
if (Object.keys(limits).length > 0) resources.limits = limits;
if (Object.keys(reservations).length > 0) resources.reservations = reservations;
return Object.keys(resources).length > 0 ? { resources } : undefined;
}
+71
View File
@@ -0,0 +1,71 @@
// Recursive YAML serializer for Docker Compose.
// PRESERVED VERBATIM from the original yaml-generator.ts. The byte-for-byte
// output of generateYaml() depends on every detail here — golden tests guard.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function yamlStringify(obj: any, indent = 0, parentKey = ""): string {
const pad = (n: number) => " ".repeat(n);
if (typeof obj !== "object" || obj === null) return String(obj);
if (Array.isArray(obj)) {
const shouldBeSingleLine =
["command", "entrypoint"].includes(parentKey) ||
(parentKey === "test" && indent > 0);
if (shouldBeSingleLine && obj.length > 0 && typeof obj[0] === "string") {
return `[${obj.map((v) => `"${v}"`).join(", ")}]`;
}
return obj
.map(
(v) =>
`\n${pad(indent)}- ${yamlStringify(v, indent + 1, parentKey).trimStart()}`,
)
.join("");
}
const entries = Object.entries(obj)
.map(([k, v]) => {
if (v === undefined) return "";
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
return `\n${pad(indent)}${k}:` + yamlStringify(v, indent + 1, k);
}
if (Array.isArray(v)) {
if (
["command", "entrypoint"].includes(k) ||
(k === "test" && indent > 0)
) {
return `\n${pad(indent)}${k}: [${v.map((item) => `"${item}"`).join(", ")}]`;
}
return `\n${pad(indent)}${k}: ` + yamlStringify(v, indent + 1, k);
}
// Multi-line strings (like JSON content) use literal block scalar |.
if (
typeof v === "string" &&
k === "content" &&
parentKey &&
v.includes("\n")
) {
const lines = v.split("\n");
const escapedLines = lines.map((line, idx) => {
if (line.trim() === "" && idx === lines.length - 1) return "";
return line;
});
return `\n${pad(indent)}${k}: |\n${escapedLines
.map((line) => `${pad(indent + 1)}${line}`)
.join("\n")}`;
}
if (typeof v === "string") {
const isPortMapping = /^\d+(:\d+)?(\/\w+)?$/.test(v);
if (isPortMapping || /^\d+$/.test(v)) {
return `\n${pad(indent)}${k}: ${v}`;
}
const needsQuotes =
// eslint-disable-next-line no-useless-escape
/^[\d-]|[:{}\[\],&*#?|>'"%@`]/.test(v) || v.trim() !== v;
return `\n${pad(indent)}${k}: ${needsQuotes ? `"${v.replace(/"/g, '\\"')}"` : v}`;
}
return `\n${pad(indent)}${k}: ${v}`;
})
.join("");
return indent === 0 && entries.startsWith("\n") ? entries.slice(1) : entries;
}
+66
View File
@@ -0,0 +1,66 @@
// Build the top-level `volumes:` block.
import type { VolumeConfig } from "../../types/compose";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type VolumesBlock = Record<string, any>;
export function buildVolumesBlock(volumes: VolumeConfig[]): VolumesBlock | undefined {
if (!volumes.length) return undefined;
const out: VolumesBlock = {};
volumes.forEach((v) => {
if (!v.name) return;
if (v.external) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const externalVolume: any = {
external: v.name_external ? { name: v.name_external } : true,
};
if (v.driver) externalVolume.driver = v.driver;
const driverOpts: Record<string, string> = {};
if (v.driver_opts && v.driver_opts.length) {
v.driver_opts
.filter((opt) => opt.key)
.forEach(({ key, value }) => {
driverOpts[key] = value;
});
}
if (v.driver_opts_type) driverOpts.type = v.driver_opts_type;
if (v.driver_opts_device) driverOpts.device = v.driver_opts_device;
if (v.driver_opts_o) driverOpts.o = v.driver_opts_o;
if (Object.keys(driverOpts).length > 0) externalVolume.driver_opts = driverOpts;
if (v.labels && v.labels.length) {
externalVolume.labels = v.labels
.filter((l) => l.key)
.map(({ key, value }) => `${key}=${value}`);
}
out[v.name] = externalVolume;
} else {
const driverOpts: Record<string, string> = {};
if (v.driver_opts && v.driver_opts.length) {
v.driver_opts
.filter((opt) => opt.key)
.forEach(({ key, value }) => {
driverOpts[key] = value;
});
}
if (v.driver_opts_type) driverOpts.type = v.driver_opts_type;
if (v.driver_opts_device) driverOpts.device = v.driver_opts_device;
if (v.driver_opts_o) driverOpts.o = v.driver_opts_o;
out[v.name] = {
driver: v.driver || undefined,
driver_opts: Object.keys(driverOpts).length > 0 ? driverOpts : undefined,
labels:
v.labels && v.labels.length
? v.labels.filter((l) => l.key).map(({ key, value }) => `${key}=${value}`)
: undefined,
};
}
Object.keys(out[v.name]).forEach(
(k) => out[v.name][k] === undefined && delete out[v.name][k],
);
});
return Object.keys(out).length > 0 ? out : undefined;
}

Some files were not shown because too many files have changed in this diff Show More