mirror of
https://github.com/hhftechnology/Dock-Dploy.git
synced 2026-11-12 03:47:58 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5c0f82351 | ||
|
|
f81224ae52 | ||
|
|
2d7a7c431b | ||
|
|
62c2fc4ab8 | ||
|
|
e1b498d947 | ||
|
|
0e550a69b0 | ||
|
|
e0320f7921 | ||
|
|
670f9e7dad | ||
|
|
06fc45b87a |
@@ -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
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine AS builder
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
Generated
+2612
-2242
File diff suppressed because it is too large
Load Diff
+43
-15
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1946
-1315
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
@@ -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(),
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useConversionDialog({
|
||||
const handleConversion = useCallback(
|
||||
(type: ConversionType) => {
|
||||
setConversionType(type);
|
||||
let output = "";
|
||||
let output: string;
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
|
||||
@@ -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, []);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+573
-1185
File diff suppressed because it is too large
Load Diff
+175
-275
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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: "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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, ""));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user