diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 29c145604..764e343d7 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -125,7 +125,8 @@ export const targets = pgTable("targets", { path: text("path"), pathMatchType: text("pathMatchType"), // exact, prefix, regex rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target - rewritePathType: text("rewritePathType") // exact, prefix, regex, stripPrefix + rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix + priority: integer("priority").notNull().default(100) }); export const targetHealthCheck = pgTable("targetHealthCheck", { diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 62fca8b4e..21e44a927 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -137,7 +137,8 @@ export const targets = sqliteTable("targets", { path: text("path"), pathMatchType: text("pathMatchType"), // exact, prefix, regex rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target - rewritePathType: text("rewritePathType") // exact, prefix, regex, stripPrefix + rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix + priority: integer("priority").notNull().default(100) }); export const targetHealthCheck = sqliteTable("targetHealthCheck", { diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index e65251915..c142cdc08 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -113,8 +113,12 @@ export async function updateProxyResources( internalPort: internalPortToCreate, path: targetData.path, pathMatchType: targetData["path-match"], +<<<<<<< HEAD rewritePath: targetData.rewritePath, rewritePathType: targetData["rewrite-match"] +======= + priority: targetData.priority +>>>>>>> b8d96345 (priority add for traefik config setup) }) .returning(); @@ -362,8 +366,12 @@ export async function updateProxyResources( enabled: targetData.enabled, path: targetData.path, pathMatchType: targetData["path-match"], +<<<<<<< HEAD rewritePath: targetData.rewritePath, rewritePathType: targetData["rewrite-match"] +======= + priority: targetData.priority +>>>>>>> b8d96345 (priority add for traefik config setup) }) .where(eq(targets.targetId, existingTarget.targetId)) .returning(); diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 54105dde8..bc152d572 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -33,7 +33,8 @@ export const TargetSchema = z.object({ "path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable(), healthcheck: TargetHealthCheckSchema.optional(), rewritePath: z.string().optional(), - "rewrite-match": z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable() + "rewrite-match": z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(), + priority: z.number().int().min(1).max(1000).optional().default(100) }); export type TargetData = z.infer; diff --git a/server/lib/traefik/privateGetTraefikConfig.ts b/server/lib/traefik/privateGetTraefikConfig.ts index 7f1ff614c..f8e7b8b5a 100644 --- a/server/lib/traefik/privateGetTraefikConfig.ts +++ b/server/lib/traefik/privateGetTraefikConfig.ts @@ -20,7 +20,7 @@ import { loginPage, targetHealthCheck } from "@server/db"; -import { and, eq, inArray, or, isNull, ne, isNotNull } from "drizzle-orm"; +import { and, eq, inArray, or, isNull, ne, isNotNull, desc } from "drizzle-orm"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import config from "@server/lib/config"; @@ -77,7 +77,8 @@ export async function getTraefikConfig( hcHealth: targetHealthCheck.hcHealth, path: targets.path, pathMatchType: targets.pathMatchType, - + priority: targets.priority, + // Site fields siteId: sites.siteId, siteType: sites.type, @@ -118,7 +119,8 @@ export async function getTraefikConfig( ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true : eq(resources.http, true) ) - ); + ) + .orderBy(desc(targets.priority), targets.targetId); // stable ordering // Group by resource and include targets with their unique site data const resourcesMap = new Map(); @@ -127,6 +129,7 @@ export async function getTraefikConfig( const resourceId = row.resourceId; const targetPath = sanitizePath(row.path) || ""; // Handle null/undefined paths const pathMatchType = row.pathMatchType || ""; + const priority = row.priority ?? 100; if (filterOutNamespaceDomains && row.domainNamespaceId) { return; @@ -155,7 +158,8 @@ export async function getTraefikConfig( targets: [], headers: row.headers, path: row.path, // the targets will all have the same path - pathMatchType: row.pathMatchType // the targets will all have the same pathMatchType + pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType + priority: priority // may be null, we fallback later }); } @@ -168,6 +172,7 @@ export async function getTraefikConfig( port: row.port, internalPort: row.internalPort, enabled: row.targetEnabled, + priority: row.priority, site: { siteId: row.siteId, type: row.siteType, @@ -331,9 +336,30 @@ export async function getTraefikConfig( } let rule = `Host(\`${fullDomain}\`)`; - let priority = 100; + + // priority logic + let priority: number; + if (resource.priority && resource.priority != 100) { + priority = resource.priority; + } else { + priority = 100; + if (resource.path && resource.pathMatchType) { + priority += 10; + if (resource.pathMatchType === "exact") { + priority += 5; + } else if (resource.pathMatchType === "prefix") { + priority += 3; + } else if (resource.pathMatchType === "regex") { + priority += 2; + } + if (resource.path === "/") { + priority = 1; // lowest for catch-all + } + } + } + if (resource.path && resource.pathMatchType) { - priority += 1; + //priority += 1; // add path to rule based on match type let path = resource.path; // if the path doesn't start with a /, add it @@ -389,7 +415,7 @@ export async function getTraefikConfig( return ( (targets as TargetWithSite[]) - .filter((target: TargetWithSite) => { + .filter((target: TargetWithSite) => { if (!target.enabled) { return false; } @@ -410,7 +436,7 @@ export async function getTraefikConfig( ) { return false; } - } else if (target.site.type === "newt") { + } else if (target.site.type === "newt") { if ( !target.internalPort || !target.method || @@ -418,10 +444,10 @@ export async function getTraefikConfig( ) { return false; } - } - return true; - }) - .map((target: TargetWithSite) => { + } + return true; + }) + .map((target: TargetWithSite) => { if ( target.site.type === "local" || target.site.type === "wireguard" @@ -429,14 +455,14 @@ export async function getTraefikConfig( return { url: `${target.method}://${target.ip}:${target.port}` }; - } else if (target.site.type === "newt") { + } else if (target.site.type === "newt") { const ip = target.site.subnet!.split("/")[0]; return { url: `${target.method}://${ip}:${target.internalPort}` }; - } - }) + } + }) // filter out duplicates .filter( (v, i, a) => diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 0b4735637..46dd39161 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -52,8 +52,7 @@ const createTargetSchema = z hcStatus: z.number().int().optional().nullable(), path: z.string().optional().nullable(), pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), - rewritePath: z.string().optional().nullable(), - rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable() + priority: z.number().int().min(1).max(1000) }) .strict(); diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index 178ec9672..04966f6ea 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -62,7 +62,8 @@ function queryTargets(resourceId: number) { path: targets.path, pathMatchType: targets.pathMatchType, rewritePath: targets.rewritePath, - rewritePathType: targets.rewritePathType + rewritePathType: targets.rewritePathType, + priority: targets.priority, }) .from(targets) .leftJoin(sites, eq(sites.siteId, targets.siteId)) diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index af629729f..5e111f0d1 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -49,8 +49,7 @@ const updateTargetBodySchema = z hcStatus: z.number().int().optional().nullable(), path: z.string().optional().nullable(), pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), - rewritePath: z.string().optional().nullable(), - rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable() + priority: z.number().int().min(1).max(1000).optional(), }) .strict() .refine((data) => Object.keys(data).length > 0, { diff --git a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index a4277f6b2..7df76cb5d 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -74,7 +74,10 @@ import { CircleX, ArrowRight, Plus, - MoveRight + MoveRight, + ArrowUp, + Info, + ArrowDown } from "lucide-react"; import { ContainersSelector } from "@app/components/ContainersSelector"; import { useTranslations } from "next-intl"; @@ -106,6 +109,7 @@ import { PathRewriteModal } from "@app/components/PathMatchRenameModal"; import { Badge } from "@app/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; const addTargetSchema = z .object({ @@ -301,7 +305,8 @@ export default function ReverseProxyTargets(props: { path: null, pathMatchType: null, rewritePath: null, - rewritePathType: null + rewritePathType: null, + priority: 100 } as z.infer }); @@ -485,6 +490,7 @@ export default function ReverseProxyTargets(props: { targetId: new Date().getTime(), new: true, resourceId: resource.resourceId, + priority: 100, hcEnabled: false, hcPath: null, hcMethod: null, @@ -509,7 +515,8 @@ export default function ReverseProxyTargets(props: { path: null, pathMatchType: null, rewritePath: null, - rewritePathType: null + rewritePathType: null, + priority: 100, }); } @@ -587,7 +594,8 @@ export default function ReverseProxyTargets(props: { path: target.path, pathMatchType: target.pathMatchType, rewritePath: target.rewritePath, - rewritePathType: target.rewritePathType + rewritePathType: target.rewritePathType, + priority: target.priority }; if (target.new) { @@ -660,6 +668,46 @@ export default function ReverseProxyTargets(props: { } const columns: ColumnDef[] = [ + { + id: "priority", + header: () => ( +
+ Priority + + + + + + +

Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.

+
+
+
+
+ ), + cell: ({ row }) => { + return ( +
+ { + const value = parseInt(e.target.value, 10); + if (value >= 1 && value <= 1000) { + updateTarget(row.original.targetId, { + ...row.original, + priority: value + }); + } + }} + /> +
+ ); + } + }, { accessorKey: "path", header: t("matchPath"), diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index 1810f09e4..55a7a7bec 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -58,7 +58,7 @@ import { } from "@app/components/ui/popover"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { cn } from "@app/lib/cn"; -import { ArrowRight, MoveRight, Plus, SquareArrowOutUpRight } from "lucide-react"; +import { ArrowRight, Info, MoveRight, Plus, SquareArrowOutUpRight } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; import Link from "next/link"; import { useTranslations } from "next-intl"; @@ -92,6 +92,7 @@ import { parseHostTarget } from "@app/lib/parseHostTarget"; import { toASCII, toUnicode } from 'punycode'; import { DomainRow } from "../../../../../components/DomainsTable"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal"; @@ -119,7 +120,8 @@ const addTargetSchema = z.object({ path: z.string().optional().nullable(), pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), rewritePath: z.string().optional().nullable(), - rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable() + rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(), + priority: z.number().int().min(1).max(1000) }).refine( (data) => { // If path is provided, pathMatchType must be provided @@ -262,6 +264,7 @@ export default function Page() { pathMatchType: null, rewritePath: null, rewritePathType: null, + priority: 100, } as z.infer }); @@ -341,6 +344,7 @@ export default function Page() { targetId: new Date().getTime(), new: true, resourceId: 0, // Will be set when resource is created + priority: 100, // Default priority hcEnabled: false, hcPath: null, hcMethod: null, @@ -366,6 +370,7 @@ export default function Page() { pathMatchType: null, rewritePath: null, rewritePathType: null, + priority: 100, }); } @@ -475,7 +480,8 @@ export default function Page() { path: target.path, pathMatchType: target.pathMatchType, rewritePath: target.rewritePath, - rewritePathType: target.rewritePathType + rewritePathType: target.rewritePathType, + priority: target.priority }; await api.put(`/resource/${id}/target`, data); @@ -598,6 +604,46 @@ export default function Page() { }, []); const columns: ColumnDef[] = [ + { + id: "priority", + header: () => ( +
+ Priority + + + + + + +

Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.

+
+
+
+
+ ), + cell: ({ row }) => { + return ( +
+ { + const value = parseInt(e.target.value, 10); + if (value >= 1 && value <= 1000) { + updateTarget(row.original.targetId, { + ...row.original, + priority: value + }); + } + }} + /> +
+ ); + } + }, { accessorKey: "path", header: t("matchPath"),