Compare commits

..

7 Commits

Author SHA1 Message Date
Owen
66c377a5c9 Merge branch 'main' into dev 2026-02-28 12:14:41 -08:00
Owen
50c2aa0111 Add default memory limits 2026-02-28 12:14:27 -08:00
Owen
fdeb891137 Fix pagination effecting drop downs 2026-02-28 12:07:42 -08:00
Owen Schwartz
6a6e3a43b1 Merge pull request #2562 from LaurenceJJones/fix/zod-openapi-catch-error
fix(zod): Add openapi call after catch
2026-02-28 11:04:10 -08:00
Laurence
b0a34fa21b fix(openapi): Add openapi call after catch
fix: #2561
without making an explicit call to openapi a runtime error happens because it cannot infer the type, the call to openapi is the same across the codebase
2026-02-28 11:27:19 +00:00
Owen
72bf6f3c41 Comma seperated 2026-02-27 17:53:44 -08:00
miloschwartz
ad9289e0c1 sort by name by default 2026-02-27 15:53:27 -08:00
12 changed files with 267 additions and 224 deletions

View File

@@ -4,6 +4,12 @@ services:
image: fosrl/pangolin:latest image: fosrl/pangolin:latest
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 1g
reservations:
memory: 256m
volumes: volumes:
- ./config:/app/config - ./config:/app/config
healthcheck: healthcheck:

View File

@@ -4,6 +4,12 @@ services:
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}} image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 1g
reservations:
memory: 256m
volumes: volumes:
- ./config:/app/config - ./config:/app/config
healthcheck: healthcheck:

View File

@@ -1670,10 +1670,10 @@
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.", "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo", "sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands", "sshSudoCommands": "Sudo Commands",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo.", "sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo.",
"sshCreateHomeDir": "Create Home Directory", "sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups", "sshUnixGroups": "Unix Groups",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host.", "sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",
"retryAttempts": "Retry Attempts", "retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes", "expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",

View File

@@ -370,7 +370,7 @@ export async function listClients(
? order === "asc" ? order === "asc"
? asc(clients[sort_by]) ? asc(clients[sort_by])
: desc(clients[sort_by]) : desc(clients[sort_by])
: asc(clients.clientId) : asc(clients.name)
); );
const [clientsList, totalCount] = await Promise.all([ const [clientsList, totalCount] = await Promise.all([

View File

@@ -429,7 +429,7 @@ export async function listResources(
? order === "asc" ? order === "asc"
? asc(resources[sort_by]) ? asc(resources[sort_by])
: desc(resources[sort_by]) : desc(resources[sort_by])
: asc(resources.resourceId) : asc(resources.name)
), ),
countQuery countQuery
]); ]);

View File

@@ -289,7 +289,7 @@ export async function listSites(
? order === "asc" ? order === "asc"
? asc(sites[sort_by]) ? asc(sites[sort_by])
: desc(sites[sort_by]) : desc(sites[sort_by])
: asc(sites.siteId) : asc(sites.name)
); );
const [totalCount, rows] = await Promise.all([ const [totalCount, rows] = await Promise.all([

View File

@@ -205,7 +205,7 @@ export async function listAllSiteResourcesByOrg(
? order === "asc" ? order === "asc"
? asc(siteResources[sort_by]) ? asc(siteResources[sort_by])
: desc(siteResources[sort_by]) : desc(siteResources[sort_by])
: asc(siteResources.siteResourceId) : asc(siteResources.name)
), ),
countQuery countQuery
]); ]);

View File

@@ -31,12 +31,23 @@ const listSiteResourcesQuerySchema = z.object({
sort_by: z sort_by: z
.enum(["name"]) .enum(["name"])
.optional() .optional()
.catch(undefined), .catch(undefined)
.openapi({
type: "string",
enum: ["name"],
description: "Field to sort by"
}),
order: z order: z
.enum(["asc", "desc"]) .enum(["asc", "desc"])
.optional() .optional()
.default("asc") .default("asc")
.catch("asc") .catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
})
}); });
export type ListSiteResourcesResponse = { export type ListSiteResourcesResponse = {
@@ -112,7 +123,7 @@ export async function listSiteResources(
? order === "asc" ? order === "asc"
? asc(siteResources[sort_by]) ? asc(siteResources[sort_by])
: desc(siteResources[sort_by]) : desc(siteResources[sort_by])
: asc(siteResources.siteResourceId) : asc(siteResources.name)
) )
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);

View File

@@ -89,7 +89,14 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { use, useActionState, useCallback, useEffect, useMemo, useState } from "react"; import {
use,
useActionState,
useCallback,
useEffect,
useMemo,
useState
} from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -184,29 +191,35 @@ function ProxyResourceTargetsForm({
setDockerStates((prev) => new Map(prev.set(siteId, dockerState))); setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
}; };
const refreshContainersForSite = useCallback(async (siteId: number) => { const refreshContainersForSite = useCallback(
const dockerManager = new DockerManager(api, siteId); async (siteId: number) => {
const containers = await dockerManager.fetchContainers(); const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
setDockerStates((prev) => { setDockerStates((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev);
const existingState = newMap.get(siteId); const existingState = newMap.get(siteId);
if (existingState) { if (existingState) {
newMap.set(siteId, { ...existingState, containers }); newMap.set(siteId, { ...existingState, containers });
} }
return newMap; return newMap;
}); });
}, [api]); },
[api]
);
const getDockerStateForSite = useCallback((siteId: number): DockerState => { const getDockerStateForSite = useCallback(
return ( (siteId: number): DockerState => {
dockerStates.get(siteId) || { return (
isEnabled: false, dockerStates.get(siteId) || {
isAvailable: false, isEnabled: false,
containers: [] isAvailable: false,
} containers: []
); }
}, [dockerStates]); );
},
[dockerStates]
);
const [isAdvancedMode, setIsAdvancedMode] = useState(() => { const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -220,7 +233,9 @@ function ProxyResourceTargetsForm({
const removeTarget = useCallback((targetId: number) => { const removeTarget = useCallback((targetId: number) => {
setTargets((prevTargets) => { setTargets((prevTargets) => {
const targetToRemove = prevTargets.find((target) => target.targetId === targetId); const targetToRemove = prevTargets.find(
(target) => target.targetId === targetId
);
if (targetToRemove && !targetToRemove.new) { if (targetToRemove && !targetToRemove.new) {
setTargetsToRemove((prev) => [...prev, targetId]); setTargetsToRemove((prev) => [...prev, targetId]);
} }
@@ -228,21 +243,24 @@ function ProxyResourceTargetsForm({
}); });
}, []); }, []);
const updateTarget = useCallback((targetId: number, data: Partial<LocalTarget>) => { const updateTarget = useCallback(
setTargets((prevTargets) => { (targetId: number, data: Partial<LocalTarget>) => {
const site = sites.find((site) => site.siteId === data.siteId); setTargets((prevTargets) => {
return prevTargets.map((target) => const site = sites.find((site) => site.siteId === data.siteId);
target.targetId === targetId return prevTargets.map((target) =>
? { target.targetId === targetId
...target, ? {
...data, ...target,
updated: true, ...data,
siteType: site ? site.type : target.siteType updated: true,
} siteType: site ? site.type : target.siteType
: target }
); : target
}); );
}, [sites]); });
},
[sites]
);
const openHealthCheckDialog = useCallback((target: LocalTarget) => { const openHealthCheckDialog = useCallback((target: LocalTarget) => {
setSelectedTargetForHealthCheck(target); setSelectedTargetForHealthCheck(target);
@@ -250,7 +268,6 @@ function ProxyResourceTargetsForm({
}, []); }, []);
const columns = useMemo((): ColumnDef<LocalTarget>[] => { const columns = useMemo((): ColumnDef<LocalTarget>[] => {
const priorityColumn: ColumnDef<LocalTarget> = { const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority", id: "priority",
header: () => ( header: () => (
@@ -581,7 +598,17 @@ function ProxyResourceTargetsForm({
actionsColumn actionsColumn
]; ];
} }
}, [isAdvancedMode, isHttp, sites, updateTarget, getDockerStateForSite, refreshContainersForSite, openHealthCheckDialog, removeTarget, t]); }, [
isAdvancedMode,
isHttp,
sites,
updateTarget,
getDockerStateForSite,
refreshContainersForSite,
openHealthCheckDialog,
removeTarget,
t
]);
function addNewTarget() { function addNewTarget() {
const isHttp = resource.http; const isHttp = resource.http;

View File

@@ -20,7 +20,7 @@ import {
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useEffect, useState } from "react"; import { useState, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
@@ -39,7 +39,8 @@ import { formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { ListResourcesResponse } from "@server/routers/resource"; import { useQuery } from "@tanstack/react-query";
import { orgQueries } from "@app/lib/queries";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -94,14 +95,22 @@ export default function CreateShareLinkForm({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const t = useTranslations(); const t = useTranslations();
const [resources, setResources] = useState< const { data: allResources = [] } = useQuery(
{ orgQueries.resources({ orgId: org?.org.orgId ?? "" })
resourceId: number; );
name: string;
niceId: string; const resources = useMemo(
resourceUrl: string; () =>
}[] allResources
>([]); .filter((r) => r.http)
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
niceId: r.niceId,
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
})),
[allResources]
);
const formSchema = z.object({ const formSchema = z.object({
resourceId: z.number({ message: t("shareErrorSelectResource") }), resourceId: z.number({ message: t("shareErrorSelectResource") }),
@@ -130,47 +139,6 @@ export default function CreateShareLinkForm({
} }
}); });
useEffect(() => {
if (!open) {
return;
}
async function fetchResources() {
const res = await api
.get<
AxiosResponse<ListResourcesResponse>
>(`/org/${org?.org.orgId}/resources`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("shareErrorFetchResource"),
description: formatAxiosError(
e,
t("shareErrorFetchResourceDescription")
)
});
});
if (res?.status === 200) {
setResources(
res.data.data.resources
.filter((r) => {
return r.http;
})
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
niceId: r.niceId,
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
}))
);
}
}
fetchResources();
}, [open]);
async function onSubmit(values: z.infer<typeof formSchema>) { async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true); setLoading(true);

View File

@@ -1189,137 +1189,151 @@ export function InternalResourceForm({
{/* SSH Access tab */} {/* SSH Access tab */}
{!disableEnterpriseFeatures && mode !== "cidr" && ( {!disableEnterpriseFeatures && mode !== "cidr" && (
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} /> <PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<div className="mb-8"> <div className="mb-8">
<label className="font-medium block"> <label className="font-medium block">
{t("internalResourceAuthDaemonStrategy")} {t("internalResourceAuthDaemonStrategy")}
</label> </label>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t.rich( {t.rich(
"internalResourceAuthDaemonDescription", "internalResourceAuthDaemonDescription",
{ {
docsLink: (chunks) => ( docsLink: (chunks) => (
<a <a
href={ href={
"https://docs.pangolin.net/manage/ssh#setup-choose-your-architecture" "https://docs.pangolin.net/manage/ssh#setup-choose-your-architecture"
} }
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={ className={
"text-primary inline-flex items-center gap-1" "text-primary inline-flex items-center gap-1"
} }
> >
{chunks} {chunks}
<ExternalLink className="size-3.5 shrink-0" /> <ExternalLink className="size-3.5 shrink-0" />
</a> </a>
) )
} }
)} )}
</div>
</div> </div>
</div> <div className="space-y-4">
<div className="space-y-4">
<FormField
control={form.control}
name="authDaemonMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"internalResourceAuthDaemonStrategyLabel"
)}
</FormLabel>
<FormControl>
<StrategySelect<"site" | "remote">
value={field.value ?? undefined}
options={[
{
id: "site",
title: t(
"internalResourceAuthDaemonSite"
),
description: t(
"internalResourceAuthDaemonSiteDescription"
),
disabled: sshSectionDisabled
},
{
id: "remote",
title: t(
"internalResourceAuthDaemonRemote"
),
description: t(
"internalResourceAuthDaemonRemoteDescription"
),
disabled: sshSectionDisabled
}
]}
onChange={(v) => {
if (sshSectionDisabled) return;
field.onChange(v);
if (v === "site") {
form.setValue(
"authDaemonPort",
null
);
}
}}
cols={2}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{authDaemonMode === "remote" && (
<FormField <FormField
control={form.control} control={form.control}
name="authDaemonPort" name="authDaemonMode"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t(
"internalResourceAuthDaemonPort" "internalResourceAuthDaemonStrategyLabel"
)} )}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <StrategySelect<
type="number" "site" | "remote"
min={1} >
max={65535} value={
placeholder="22123" field.value ?? undefined
{...field} }
disabled={sshSectionDisabled} options={[
value={field.value ?? ""} {
onChange={(e) => { id: "site",
if (sshSectionDisabled) return; title: t(
const v = "internalResourceAuthDaemonSite"
e.target.value; ),
if (v === "") { description: t(
field.onChange( "internalResourceAuthDaemonSiteDescription"
),
disabled:
sshSectionDisabled
},
{
id: "remote",
title: t(
"internalResourceAuthDaemonRemote"
),
description: t(
"internalResourceAuthDaemonRemoteDescription"
),
disabled:
sshSectionDisabled
}
]}
onChange={(v) => {
if (sshSectionDisabled)
return;
field.onChange(v);
if (v === "site") {
form.setValue(
"authDaemonPort",
null null
); );
return;
} }
const num = parseInt(
v,
10
);
field.onChange(
Number.isNaN(num)
? null
: num
);
}} }}
cols={2}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
)} {authDaemonMode === "remote" && (
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"internalResourceAuthDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
placeholder="22123"
{...field}
disabled={
sshSectionDisabled
}
value={
field.value ?? ""
}
onChange={(e) => {
if (
sshSectionDisabled
)
return;
const v =
e.target.value;
if (v === "") {
field.onChange(
null
);
return;
}
const num =
parseInt(v, 10);
field.onChange(
Number.isNaN(
num
)
? null
: num
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</div> </div>
</div>
)} )}
</HorizontalTabs> </HorizontalTabs>
</form> </form>

View File

@@ -4,7 +4,8 @@ import type { ListClientsResponse } from "@server/routers/client";
import type { ListDomainsResponse } from "@server/routers/domain"; import type { ListDomainsResponse } from "@server/routers/domain";
import type { import type {
GetResourceWhitelistResponse, GetResourceWhitelistResponse,
ListResourceNamesResponse ListResourceNamesResponse,
ListResourcesResponse
} from "@server/routers/resource"; } from "@server/routers/resource";
import type { ListRolesResponse } from "@server/routers/role"; import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site"; import type { ListSitesResponse } from "@server/routers/site";
@@ -90,23 +91,13 @@ export const productUpdatesQueries = {
}) })
}; };
export const clientFilterSchema = z.object({
pageSize: z.int().prefault(1000).optional()
});
export const orgQueries = { export const orgQueries = {
clients: ({ clients: ({ orgId }: { orgId: string }) =>
orgId,
filters
}: {
orgId: string;
filters?: z.infer<typeof clientFilterSchema>;
}) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "CLIENTS", filters] as const, queryKey: ["ORG", orgId, "CLIENTS"] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({ const sp = new URLSearchParams({
pageSize: (filters?.pageSize ?? 1000).toString() pageSize: "10000"
}); });
const res = await meta!.api.get< const res = await meta!.api.get<
@@ -143,9 +134,13 @@ export const orgQueries = {
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "SITES"] as const, queryKey: ["ORG", orgId, "SITES"] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: "10000"
});
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListSitesResponse> AxiosResponse<ListSitesResponse>
>(`/org/${orgId}/sites`, { signal }); >(`/org/${orgId}/sites?${sp.toString()}`, { signal });
return res.data.data.sites; return res.data.data.sites;
} }
}), }),
@@ -182,6 +177,22 @@ export const orgQueries = {
); );
return res.data.data.idps; return res.data.data.idps;
} }
}),
resources: ({ orgId }: { orgId: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "RESOURCES"] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: "10000"
});
const res = await meta!.api.get<
AxiosResponse<ListResourcesResponse>
>(`/org/${orgId}/resources?${sp.toString()}`, { signal });
return res.data.data.resources;
}
}) })
}; };