mirror of
https://github.com/hhftechnology/Dock-Dploy.git
synced 2026-08-23 12:59:27 -05:00
bugfixes-ui-updates
This commit is contained in:
@@ -86,7 +86,7 @@ export function Header({ onOpenMarket }: HeaderProps) {
|
||||
</a>
|
||||
<a
|
||||
className="icon-btn"
|
||||
href="https://github.com/hhftechnologies/Dock-Dploy"
|
||||
href="https://github.com/hhftechnology/Dock-Dploy"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="GitHub"
|
||||
|
||||
@@ -77,8 +77,14 @@ export function CodePanel({
|
||||
[output],
|
||||
);
|
||||
|
||||
const activeValidationError =
|
||||
validationError ||
|
||||
(!validatedOutput.ok ? formatValidationIssues(validatedOutput.issues) : null);
|
||||
const canCopy = validatedOutput.ok && !activeValidationError;
|
||||
const statusKind: "ok" | "err" = activeValidationError ? "err" : "ok";
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!validatedOutput.ok) return;
|
||||
if (!canCopy) return;
|
||||
try {
|
||||
await copyToClipboard(validatedOutput.output);
|
||||
setCopied(true);
|
||||
@@ -88,11 +94,6 @@ export function CodePanel({
|
||||
}
|
||||
};
|
||||
|
||||
const activeValidationError =
|
||||
validationError ||
|
||||
(!validatedOutput.ok ? formatValidationIssues(validatedOutput.issues) : null);
|
||||
const statusKind: "ok" | "err" = activeValidationError ? "err" : "ok";
|
||||
|
||||
return (
|
||||
<section className="code-col">
|
||||
<div className="code-head">
|
||||
@@ -140,7 +141,7 @@ export function CodePanel({
|
||||
type="button"
|
||||
className="code-copy"
|
||||
onClick={handleCopy}
|
||||
disabled={!validatedOutput.ok}
|
||||
disabled={!canCopy}
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
|
||||
@@ -10,7 +10,7 @@ const alertVariants = cva(
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
"border-red-600 bg-red-50 text-red-950 dark:border-red-400 dark:bg-red-950 dark:text-red-50 [&>svg]:text-current *:data-[slot=alert-description]:text-red-900 dark:*:data-[slot=alert-description]:text-red-100",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -70,16 +70,16 @@ function ToastContainer() {
|
||||
|
||||
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
|
||||
const variantStyles = {
|
||||
default: "bg-background border-border",
|
||||
success: "bg-green-500/10 border-green-500/50 text-green-700 dark:text-green-400",
|
||||
error: "bg-red-500/10 border-red-500/50 text-red-700 dark:text-red-400",
|
||||
warning: "bg-yellow-500/10 border-yellow-500/50 text-yellow-700 dark:text-yellow-400",
|
||||
default: "bg-background border-border text-foreground",
|
||||
success: "bg-emerald-50 border-emerald-600 text-emerald-950 dark:bg-emerald-950 dark:border-emerald-400 dark:text-emerald-50",
|
||||
error: "bg-red-50 border-red-600 text-red-950 dark:bg-red-950 dark:border-red-400 dark:text-red-50",
|
||||
warning: "bg-amber-50 border-amber-600 text-amber-950 dark:bg-amber-950 dark:border-amber-400 dark:text-amber-50",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 pr-8 shadow-lg transition-all",
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 pr-8 shadow-2xl transition-all",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full",
|
||||
"data-[state=open]:slide-in-from-bottom-full data-[state=open]:sm:slide-in-from-top-full",
|
||||
@@ -92,7 +92,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string)
|
||||
<div className="text-sm font-semibold">{toast.title}</div>
|
||||
)}
|
||||
{toast.description && (
|
||||
<div className="text-sm opacity-90">{toast.description}</div>
|
||||
<div className="text-sm">{toast.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{toast.action}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { TemplateDetailModal } from "../components/templates/TemplateDetailModal
|
||||
import { Field, ValidatedInput } from "../components/compose-builder/ServiceForm/Field";
|
||||
import { useTemplateStore } from "../hooks/useTemplateStore";
|
||||
import {
|
||||
defaultAuth,
|
||||
defaultBlueprint,
|
||||
defaultHealthcheck,
|
||||
defaultPrivateResource,
|
||||
@@ -55,6 +56,25 @@ export const Route = createFileRoute("/blueprint-builder")({
|
||||
|
||||
type OutputTab = "compose" | "env";
|
||||
|
||||
function nextUnusedSuffix(prefix: string, keys: string[]): number {
|
||||
const used = new Set<number>();
|
||||
const re = new RegExp(`^${prefix}-(\\d+)$`);
|
||||
keys.forEach((key) => {
|
||||
const match = re.exec(key);
|
||||
if (!match) return;
|
||||
const suffix = Number(match[1]);
|
||||
if (Number.isInteger(suffix) && suffix > 0) used.add(suffix);
|
||||
});
|
||||
|
||||
let suffix = 1;
|
||||
while (used.has(suffix)) suffix += 1;
|
||||
return suffix;
|
||||
}
|
||||
|
||||
function stripTargetMethod(target: BlueprintTarget): BlueprintTarget {
|
||||
return { ...target, method: undefined };
|
||||
}
|
||||
|
||||
function BlueprintBuilderRoute() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
@@ -139,18 +159,26 @@ function BlueprintBuilderRoute() {
|
||||
);
|
||||
|
||||
const addPrivateResource = useCallback(() => {
|
||||
setBlueprint((bp) => ({
|
||||
...bp,
|
||||
privateResources: [
|
||||
...(bp.privateResources ?? []),
|
||||
{
|
||||
...defaultPrivateResource(defaultLabelService),
|
||||
key: `private-${(bp.privateResources?.length ?? 0) + 1}`,
|
||||
name: `Private ${(bp.privateResources?.length ?? 0) + 1}`,
|
||||
alias: `private-${(bp.privateResources?.length ?? 0) + 1}`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
setBlueprint((bp) => {
|
||||
const privateResources = bp.privateResources ?? [];
|
||||
const suffix = nextUnusedSuffix(
|
||||
"private",
|
||||
privateResources.map((resource) => resource.key),
|
||||
);
|
||||
const key = `private-${suffix}`;
|
||||
return {
|
||||
...bp,
|
||||
privateResources: [
|
||||
...privateResources,
|
||||
{
|
||||
...defaultPrivateResource(defaultLabelService),
|
||||
key,
|
||||
name: `Private ${suffix}`,
|
||||
alias: key,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}, [defaultLabelService]);
|
||||
|
||||
const removePrivateResource = useCallback((idx: number) => {
|
||||
@@ -169,17 +197,24 @@ function BlueprintBuilderRoute() {
|
||||
}, []);
|
||||
|
||||
const addSite = useCallback(() => {
|
||||
setBlueprint((bp) => ({
|
||||
...bp,
|
||||
sites: [
|
||||
...(bp.sites ?? []),
|
||||
{
|
||||
...defaultSite(defaultLabelService),
|
||||
key: `site-${(bp.sites?.length ?? 0) + 1}`,
|
||||
name: `Site ${(bp.sites?.length ?? 0) + 1}`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
setBlueprint((bp) => {
|
||||
const sites = bp.sites ?? [];
|
||||
const suffix = nextUnusedSuffix(
|
||||
"site",
|
||||
sites.map((site) => site.key),
|
||||
);
|
||||
return {
|
||||
...bp,
|
||||
sites: [
|
||||
...sites,
|
||||
{
|
||||
...defaultSite(defaultLabelService),
|
||||
key: `site-${suffix}`,
|
||||
name: `Site ${suffix}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}, [defaultLabelService]);
|
||||
|
||||
const removeSite = useCallback((idx: number) => {
|
||||
@@ -202,6 +237,7 @@ function BlueprintBuilderRoute() {
|
||||
suffix += 1;
|
||||
}
|
||||
resource.blueprintName = nextName;
|
||||
resource.subdomain = nextName;
|
||||
return { ...bp, resources: [...bp.resources, resource] };
|
||||
});
|
||||
setSelectedIdx(blueprint.resources.length);
|
||||
@@ -868,6 +904,12 @@ function ResourceForm({
|
||||
onChange({
|
||||
protocol,
|
||||
proxyPort: protocol === "http" ? undefined : (resource.proxyPort ?? resource.servicePort),
|
||||
...(protocol !== "http"
|
||||
? {
|
||||
auth: defaultAuth(),
|
||||
extraTargets: resource.extraTargets.map(stripTargetMethod),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -34,7 +34,6 @@ function ConfigBuilderRoute() {
|
||||
const [config, setConfig] = useState<HomepageConfigState>({ items: [] });
|
||||
const [customOutput, setCustomOutput] = useState<string>("");
|
||||
const [currentItem, setCurrentItem] = useState<ConfigItemInput>(blankItem());
|
||||
const [itemSubmitted, setItemSubmitted] = useState(false);
|
||||
|
||||
const generateHomepageConfig = useCallback(
|
||||
(items: ConfigItem[]): string => {
|
||||
@@ -74,12 +73,10 @@ function ConfigBuilderRoute() {
|
||||
}, [configType, config.items, customOutput, generateHomepageConfig, outputValidation]);
|
||||
|
||||
const addItem = useCallback(() => {
|
||||
setItemSubmitted(true);
|
||||
const validation = validateConfigItem(currentItem);
|
||||
if (!validation.ok || !validation.data) return;
|
||||
setConfig({ items: [...config.items, validation.data] });
|
||||
setCurrentItem(blankItem());
|
||||
setItemSubmitted(false);
|
||||
}, [currentItem, config.items]);
|
||||
|
||||
const removeItem = useCallback(
|
||||
@@ -96,10 +93,8 @@ function ConfigBuilderRoute() {
|
||||
};
|
||||
|
||||
const fieldError = (path: string) =>
|
||||
itemSubmitted
|
||||
? currentItemValidation.issues.find((issue) => issue.path === path)
|
||||
?.message ?? null
|
||||
: null;
|
||||
currentItemValidation.issues.find((issue) => issue.path === path)?.message ??
|
||||
null;
|
||||
|
||||
const customYamlError =
|
||||
configType === "custom" && !outputValidation.ok
|
||||
|
||||
@@ -35,6 +35,19 @@ services:
|
||||
expect(bp.resources[1].image).toBe("postgres:16");
|
||||
});
|
||||
|
||||
it("keeps uniquified blueprint names and subdomains aligned", () => {
|
||||
const bp = fromCompose(`services:
|
||||
app:
|
||||
image: nginx
|
||||
app_:
|
||||
image: nginx
|
||||
`);
|
||||
expect(bp.resources[0].blueprintName).toBe("app");
|
||||
expect(bp.resources[0].subdomain).toBe("app");
|
||||
expect(bp.resources[1].blueprintName).toBe("app-2");
|
||||
expect(bp.resources[1].subdomain).toBe("app-2");
|
||||
});
|
||||
|
||||
it("uses an https target method for port 443 / 8443", () => {
|
||||
const bp = fromCompose(`services:
|
||||
s:
|
||||
@@ -104,6 +117,34 @@ describe("toComposeYaml", () => {
|
||||
expect(String(yaml)).not.toContain("pangolin.public-resources.worker");
|
||||
});
|
||||
|
||||
it("removes Pangolin labels from resources disabled after re-import", () => {
|
||||
const bp = fromCompose(
|
||||
`services:
|
||||
web:
|
||||
image: nginx
|
||||
ports:
|
||||
- "80"
|
||||
worker:
|
||||
image: busybox
|
||||
ports:
|
||||
- "8080"
|
||||
`,
|
||||
"example.com",
|
||||
);
|
||||
const yaml = toComposeYaml(bp);
|
||||
expect(yaml).toContain("pangolin.public-resources.web");
|
||||
expect(yaml).toContain("pangolin.public-resources.worker");
|
||||
|
||||
const bp2 = fromCompose(yaml, "example.com");
|
||||
bp2.resources = bp2.resources.filter(
|
||||
(resource) => resource.serviceContainerName !== "worker",
|
||||
);
|
||||
const finalYaml = toComposeYaml(bp2);
|
||||
|
||||
expect(finalYaml).toContain("pangolin.public-resources.web");
|
||||
expect(finalYaml).not.toContain("pangolin.public-resources.worker");
|
||||
});
|
||||
|
||||
it("adds the external pangolin network block with a literal name", () => {
|
||||
const bp = fromCompose(
|
||||
`services:
|
||||
|
||||
@@ -224,14 +224,14 @@ export function resourceFromComposeService(
|
||||
? (rawService as Record<string, unknown>)
|
||||
: {};
|
||||
const port = firstServicePort(svc) ?? DEFAULT_SERVICE_PORT;
|
||||
const sluggedKey = slug(serviceKey) || serviceKey.toLowerCase() || "service";
|
||||
const blueprintName = slug(serviceKey) || serviceKey.toLowerCase() || "service";
|
||||
|
||||
return {
|
||||
...defaultResource(),
|
||||
serviceContainerName: serviceKey,
|
||||
blueprintName: sluggedKey,
|
||||
blueprintName,
|
||||
resourceName: titleCase(serviceKey) || serviceKey,
|
||||
subdomain: sluggedKey,
|
||||
subdomain: blueprintName,
|
||||
servicePort: port,
|
||||
image: typeof svc.image === "string" ? svc.image : "",
|
||||
protocol: "http",
|
||||
@@ -278,6 +278,7 @@ export function fromCompose(yamlContent: string, baseDomain = ""): Blueprint {
|
||||
resource.blueprintName,
|
||||
seenBlueprintNames,
|
||||
);
|
||||
resource.subdomain = resource.blueprintName;
|
||||
bp.resources.push(resource);
|
||||
}
|
||||
return bp;
|
||||
@@ -491,6 +492,21 @@ function ensureService(
|
||||
return svc;
|
||||
}
|
||||
|
||||
function removeLabelsFromService(
|
||||
services: Record<string, unknown>,
|
||||
serviceName: string,
|
||||
removePrefixes: string[],
|
||||
) {
|
||||
if (!serviceName || !isPlainObject(services[serviceName])) return;
|
||||
const svc = services[serviceName] as Record<string, unknown>;
|
||||
const existing = removePangolinLabels(labelsToArray(svc.labels), removePrefixes);
|
||||
if (existing.length > 0) {
|
||||
svc.labels = existing;
|
||||
} else {
|
||||
delete svc.labels;
|
||||
}
|
||||
}
|
||||
|
||||
function applyLabelsToService(
|
||||
services: Record<string, unknown>,
|
||||
serviceName: string,
|
||||
@@ -523,6 +539,10 @@ export function toComposeYaml(bp: Blueprint): string {
|
||||
? (baseDoc.services as Record<string, unknown>)
|
||||
: ({} as Record<string, unknown>);
|
||||
|
||||
Object.keys(services).forEach((serviceName) => {
|
||||
removeLabelsFromService(services, serviceName, ["pangolin.public-resources."]);
|
||||
});
|
||||
|
||||
for (const r of bp.resources) {
|
||||
applyLabelsToService(
|
||||
services,
|
||||
|
||||
@@ -53,6 +53,23 @@ jobs:
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects GitHub Actions output without a scheduled cron trigger", () => {
|
||||
expect(
|
||||
validateSchedulerOutput(
|
||||
"github-actions",
|
||||
`name: Manual
|
||||
on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
scheduled:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo ok
|
||||
`,
|
||||
).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid generated config outputs", () => {
|
||||
expect(validateCustomYaml("valid: true").ok).toBe(true);
|
||||
expect(validateHomepageOutput("services:\n - Name: Bad\n").ok).toBe(false);
|
||||
|
||||
@@ -161,7 +161,23 @@ export function validateSchedulerOutput(
|
||||
if (type === "github-actions") {
|
||||
try {
|
||||
const parsed = yaml.load(output) as Record<string, unknown>;
|
||||
if (!parsed || typeof parsed !== "object" || !("on" in parsed)) {
|
||||
const onValue =
|
||||
parsed && typeof parsed === "object" ? parsed.on : undefined;
|
||||
const schedule =
|
||||
onValue && typeof onValue === "object" && !Array.isArray(onValue)
|
||||
? (onValue as Record<string, unknown>).schedule
|
||||
: undefined;
|
||||
const hasScheduleCron =
|
||||
Array.isArray(schedule) &&
|
||||
schedule.some(
|
||||
(item) =>
|
||||
item !== null &&
|
||||
typeof item === "object" &&
|
||||
!Array.isArray(item) &&
|
||||
typeof (item as Record<string, unknown>).cron === "string",
|
||||
);
|
||||
|
||||
if (!hasScheduleCron) {
|
||||
issues.push(
|
||||
validationError(
|
||||
"scheduler.output",
|
||||
|
||||
Reference in New Issue
Block a user