bugfixes-ui-updates

This commit is contained in:
hhftechnologies
2026-05-19 22:13:03 +05:30
parent 9ae2847552
commit 2d0b8a4918
10 changed files with 181 additions and 49 deletions
+1 -1
View File
@@ -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"
+8 -7
View File
@@ -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} />}
+1 -1
View File
@@ -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: {
+6 -6
View File
@@ -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}
+65 -23
View File
@@ -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),
}
: {}),
});
}}
>
+2 -7
View File
@@ -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:
+23 -3
View File
@@ -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);
+17 -1
View File
@@ -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",