Compare commits

...

5 Commits

Author SHA1 Message Date
Owen 987b5d580e Sure up some things with browserAccessType 2026-05-15 17:26:58 -07:00
Owen cb75ffc3b7 Adjust page to be editable 2026-05-15 16:46:43 -07:00
Owen 540f0a754d Temp credential storage 2026-05-15 16:11:23 -07:00
Owen 0f9a6fd968 Support private key 2026-05-15 16:07:14 -07:00
Owen 82112abc34 Clean up toasts 2026-05-15 15:01:37 -07:00
11 changed files with 676 additions and 236 deletions
+31 -17
View File
@@ -159,7 +159,8 @@ export const resources = pgTable("resources", {
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false)
wildcard: boolean("wildcard").notNull().default(false),
browserAccessType: text("browserAccessType").default("http") // rdp, ssh, http, vnc
});
export const targets = pgTable("targets", {
@@ -196,9 +197,11 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
name: varchar("name"),
hcEnabled: boolean("hcEnabled").notNull().default(false),
hcPath: varchar("hcPath"),
@@ -1097,19 +1100,30 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
complete: boolean("complete").notNull().default(false)
});
export const statusHistory = pgTable("statusHistory", {
id: serial("id").primaryKey(),
entityType: varchar("entityType").notNull(),
entityId: integer("entityId").notNull(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: varchar("status").notNull(),
timestamp: integer("timestamp").notNull(),
}, (table) => [
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export const statusHistory = pgTable(
"statusHistory",
{
id: serial("id").primaryKey(),
entityType: varchar("entityType").notNull(),
entityId: integer("entityId").notNull(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: varchar("status").notNull(),
timestamp: integer("timestamp").notNull()
},
(table) => [
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
+31 -17
View File
@@ -180,7 +180,8 @@ export const resources = sqliteTable("resources", {
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
browserAccessType: text("browserAccessType").default("http") // rdp, ssh, http, vnc
});
export const targets = sqliteTable("targets", {
@@ -219,9 +220,11 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
name: text("name"),
hcEnabled: integer("hcEnabled", { mode: "boolean" })
.notNull()
@@ -1196,19 +1199,30 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
});
export const statusHistory = sqliteTable("statusHistory", {
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull(), // unix epoch seconds
}, (table) => [
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export const statusHistory = sqliteTable(
"statusHistory",
{
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull() // unix epoch seconds
},
(table) => [
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
+44 -1
View File
@@ -1,4 +1,5 @@
import {
browserGatewayTarget,
db,
resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility,
@@ -140,6 +141,7 @@ export type ResourceWithTargets = {
headerAuthId: number | null;
wildcard: boolean;
health: string | null;
browserAccessType: string | null;
targets: Array<{
targetId: number;
ip: string;
@@ -177,7 +179,8 @@ function queryResourcesBase() {
headerAuthId: resourceHeaderAuth.headerAuthId,
headerAuthExtendedCompatibilityId:
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
health: resources.health
health: resources.health,
browserAccessType: resources.browserAccessType
})
.from(resources)
.leftJoin(
@@ -433,6 +436,30 @@ export async function listResources(
)
.leftJoin(sites, eq(targets.siteId, sites.siteId));
const allBgTargetSites =
resourceIdList.length === 0
? []
: await db
.select({
resourceId: browserGatewayTarget.resourceId,
siteId: browserGatewayTarget.siteId,
siteName: sites.name,
siteNiceId: sites.niceId,
siteOnline: sites.online,
siteType: sites.type
})
.from(browserGatewayTarget)
.where(
inArray(
browserGatewayTarget.resourceId,
resourceIdList
)
)
.leftJoin(
sites,
eq(sites.siteId, browserGatewayTarget.siteId)
);
// avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>();
@@ -453,6 +480,7 @@ export async function listResources(
protocol: row.protocol,
proxyPort: row.proxyPort,
wildcard: row.wildcard,
browserAccessType: row.browserAccessType,
enabled: row.enabled,
domainId: row.domainId,
headerAuthId: row.headerAuthId,
@@ -493,6 +521,21 @@ export async function listResources(
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
const bgRaw = allBgTargetSites.filter(
(t) => t.resourceId === entry.resourceId
);
for (const t of bgRaw) {
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
continue;
}
const isLocal = t.siteType === "local";
siteById.set(t.siteId, {
siteId: t.siteId,
siteName: t.siteName ?? "",
siteNiceId: t.siteNiceId ?? "",
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
entry.sites = Array.from(siteById.values());
}
+6 -2
View File
@@ -24,7 +24,10 @@ import {
import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
import {
validateAndConstructDomain,
checkWildcardDomainConflict
} from "@server/lib/domainUtils";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -68,7 +71,8 @@ const updateHttpResourceBodySchema = z
maintenanceTitle: z.string().max(255).nullable().optional(),
maintenanceMessage: z.string().max(2000).nullable().optional(),
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
postAuthPath: z.string().nullable().optional()
postAuthPath: z.string().nullable().optional(),
browserAccessType: z.enum(["http", "ssh", "rdp", "vnc"]).optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"
@@ -507,7 +507,9 @@ export default function GeneralForm() {
name: data.name,
niceId: data.niceId,
subdomain: data.subdomain
? toASCII(finalizeSubdomainSanitize(data.subdomain, true))
? toASCII(
finalizeSubdomainSanitize(data.subdomain, true)
)
: undefined,
domainId: data.domainId,
proxyPort: data.proxyPort
@@ -555,13 +557,15 @@ export default function GeneralForm() {
return (
<>
<SettingsContainer>
{resource?.resourceId && resource?.orgId && (
<UptimeAlertSection
orgId={resource.orgId}
resourceId={resource.resourceId}
startingName={resource.name}
/>
)}
{resource?.resourceId &&
resource?.orgId &&
resource.browserAccessType == "http" && (
<UptimeAlertSection
orgId={resource.orgId}
resourceId={resource.resourceId}
startingName={resource.name}
/>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -121,6 +121,10 @@ export default function ReverseProxyTargetsPage(props: {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const [targetMode, setTargetMode] = useState<
"http" | "ssh" | "rdp" | "vnc"
>((resource.browserAccessType as "http" | "ssh" | "rdp" | "vnc") || "http");
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
resourceQueries.resourceTargets({
resourceId: resource.resourceId
@@ -137,9 +141,12 @@ export default function ReverseProxyTargetsPage(props: {
orgId={params.orgId}
initialTargets={remoteTargets}
resource={resource}
targetMode={targetMode}
setTargetMode={setTargetMode}
updateResource={updateResource}
/>
{resource.http && (
{resource.http && targetMode === "http" && (
<ProxyResourceHttpForm
resource={resource}
updateResource={updateResource}
@@ -159,11 +166,17 @@ export default function ReverseProxyTargetsPage(props: {
function ProxyResourceTargetsForm({
orgId,
initialTargets,
resource
resource,
targetMode,
setTargetMode,
updateResource
}: {
initialTargets: LocalTarget[];
orgId: string;
resource: GetResourceResponse;
targetMode: "http" | "ssh" | "rdp" | "vnc";
setTargetMode: (mode: "http" | "ssh" | "rdp" | "vnc") => void;
updateResource: ResourceContextType["updateResource"];
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
@@ -201,6 +214,11 @@ function ProxyResourceTargetsForm({
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
useState<LocalTarget | null>(null);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("");
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
const initializeDockerForSite = async (siteId: number) => {
if (dockerStates.has(siteId)) {
return; // Already initialized
@@ -270,6 +288,41 @@ function ProxyResourceTargetsForm({
})
);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
type: string;
destination: string;
destinationPort: number;
}>;
};
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const bgt = bgTargetsResponse.targets[0];
setTargetMode(bgt.type as "ssh" | "rdp" | "vnc");
setBgDestination(bgt.destination);
setBgDestinationPort(String(bgt.destinationPort));
setBgSiteId(bgt.siteId);
setBgTargetId(bgt.browserGatewayTargetId);
}, [bgTargetsResponse]);
useEffect(() => {
if (sites.length > 0 && bgSiteId === null) {
setBgSiteId(sites[0].siteId);
}
}, [sites, bgSiteId]);
const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
@@ -356,7 +409,7 @@ function ProxyResourceTargetsForm({
}
};
return (
return (
<div className="flex items-center justify-center w-full">
{row.original.siteType === "newt" ? (
<Button
@@ -375,7 +428,6 @@ function ProxyResourceTargetsForm({
{getStatusText(status)}
</div>
</Button>
) : (
<span>-</span>
)}
@@ -404,9 +456,15 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
updateTarget(
row.original.targetId,
config.path === null &&
config.pathMatchType === null
? {
...config,
rewritePath: null,
rewritePathType: null
}
: config
)
}
@@ -432,9 +490,15 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
updateTarget(
row.original.targetId,
config.path === null &&
config.pathMatchType === null
? {
...config,
rewritePath: null,
rewritePathType: null
}
: config
)
}
@@ -717,6 +781,55 @@ function ProxyResourceTargetsForm({
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
async function saveTargets() {
if (targetMode !== "http") {
try {
if (!bgDestination || !bgDestinationPort) {
if (bgTargetId) {
await api.delete(
`/org/${orgId}/browser-gateway-target/${bgTargetId}`
);
setBgTargetId(null);
}
} else if (bgTargetId) {
await api.post(
`/org/${orgId}/browser-gateway-target/${bgTargetId}`,
{
type: targetMode,
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: bgSiteId
}
);
} else {
const res = await api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: bgSiteId ?? sites[0]?.siteId,
type: targetMode,
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
);
setBgTargetId(res.data.data.browserGatewayTargetId);
}
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
return;
}
// Validate that no targets have blank IPs or invalid ports
const targetsWithInvalidFields = targets.filter(
(target) =>
@@ -791,12 +904,14 @@ function ProxyResourceTargetsForm({
}
toast({
title: targets.length === 0
? t("targetTargetsCleared")
: t("settingsUpdated"),
description: targets.length === 0
? t("targetTargetsClearedDescription")
: t("settingsUpdatedDescription")
title:
targets.length === 0
? t("targetTargetsCleared")
: t("settingsUpdated"),
description:
targets.length === 0
? t("targetTargetsClearedDescription")
: t("settingsUpdatedDescription")
});
setTargetsToRemove([]);
@@ -829,102 +944,187 @@ function ProxyResourceTargetsForm({
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{targets.length > 0 ? (
<div className="flex items-center gap-3 mb-4">
<span className="text-sm font-medium">Target Type</span>
<Select
value={targetMode}
onValueChange={async (v) => {
const mode = v as
| "http"
| "ssh"
| "rdp"
| "vnc";
setTargetMode(mode);
try {
await api.post(
`/resource/${resource.resourceId}`,
{ browserAccessType: mode }
);
updateResource({ browserAccessType: mode });
} catch (err) {
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="ssh">SSH</SelectItem>
<SelectItem value="rdp">RDP</SelectItem>
<SelectItem value="vnc">VNC</SelectItem>
</SelectContent>
</Select>
</div>
{targetMode === "http" ? (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => {
const isActionsColumn =
header.column
.id ===
"actions";
return (
<TableHead
key={
header.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table
.getRowModel()
.rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => {
const isActionsColumn =
cell.column
.id ===
"actions";
return (
<TableCell
key={
cell.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{flexRender(
{targets.length > 0 ? (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow
key={headerGroup.id}
>
{headerGroup.headers.map(
(header) => {
const isActionsColumn =
header
.column
.id ===
"actions";
return (
<TableHead
key={
header.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows
?.length ? (
table
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
>
{row
.getVisibleCells()
.map(
(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
) => {
const isActionsColumn =
cell
.column
.id ===
"actions";
return (
<TableCell
key={
cell.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
}
)}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={
columns.length
}
className="h-24 text-center"
>
{t("targetNoOne")}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
)}
</TableBody>
{/* <TableCaption> */}
{/* {t('targetNoOneDescription')} */}
{/* </TableCaption> */}
</Table>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
<Button
onClick={addNewTarget}
variant="outline"
>
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={
setIsAdvancedMode
}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
{t("targetNoOne")}
</TableCell>
</TableRow>
)}
</TableBody>
{/* <TableCaption> */}
{/* {t('targetNoOneDescription')} */}
{/* </TableCaption> */}
</Table>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
{t("advancedMode")}
</label>
</div>
</div>
</div>
</>
) : (
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
<Button
onClick={addNewTarget}
variant="outline"
@@ -932,50 +1132,91 @@ function ProxyResourceTargetsForm({
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
{t("advancedMode")}
</label>
</div>
</div>
</div>
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size >
1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
)}
</>
) : (
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
<Button onClick={addNewTarget} variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Destination
</label>
<Input
placeholder="192.168.1.1"
value={bgDestination}
onChange={(e) =>
setBgDestination(e.target.value)
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Port
</label>
<Input
type="number"
placeholder={
targetMode === "rdp"
? "3389"
: targetMode === "ssh"
? "22"
: "5900"
}
value={bgDestinationPort}
onChange={(e) =>
setBgDestinationPort(e.target.value)
}
/>
</div>
</div>
{sites.length > 1 && (
<div className="space-y-2">
<label className="text-sm font-medium">
Site
</label>
<Select
value={bgSiteId ? String(bgSiteId) : ""}
onValueChange={(v) =>
setBgSiteId(Number(v))
}
>
<SelectTrigger>
<SelectValue placeholder="Select a site" />
</SelectTrigger>
<SelectContent>
{sites.map((site) => (
<SelectItem
key={site.siteId}
value={String(site.siteId)}
>
{site.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
)}
</SettingsSectionBody>
<form className="self-end mt-4" action={formAction}>
@@ -125,6 +125,7 @@ export default async function ProxyResourcesPage(
fullDomain: resource.fullDomain ?? null,
ssl: resource.ssl,
wildcard: resource.wildcard,
browserAccessType: resource.browserAccessType,
targets: resource.targets?.map((target) => ({
targetId: target.targetId,
ip: target.ip,
+30 -21
View File
@@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { toast } from "@app/hooks/useToast";
import type {
UserInteraction,
@@ -63,17 +62,28 @@ export default function RdpClient({
target: Target | null;
error: string | null;
}) {
const [form, setForm] = useState<FormState>({
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
const STORAGE_KEY = "pangolin_rdp_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return {
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
};
});
const [showLogin, setShowLogin] = useState(true);
const [moduleReady, setModuleReady] = useState(false);
const [connecting, setConnecting] = useState(false);
const [unicodeMode, setUnicodeMode] = useState(false);
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
@@ -152,9 +162,11 @@ export default function RdpClient({
};
const startSession = async () => {
setConnecting(true);
const userInteraction = userInteractionRef.current;
const exts = extensionsRef.current;
if (!userInteraction || !exts) {
setConnecting(false);
toast({
variant: "destructive",
title: "Not ready",
@@ -163,11 +175,6 @@ export default function RdpClient({
return;
}
toast({
title: "Connecting...",
description: "Connection in progress"
});
userInteraction.setEnableClipboard(form.enableClipboard);
// Dispose any previous session's provider and create a fresh one so
@@ -221,6 +228,7 @@ export default function RdpClient({
}
if (!target) {
setConnecting(false);
toast({
variant: "destructive",
title: "No target",
@@ -257,19 +265,21 @@ export default function RdpClient({
try {
const sessionInfo = await userInteraction.connect(builder.build());
toast({ title: "Connected" });
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
setConnecting(false);
setShowLogin(false);
userInteraction.setVisibility(true);
const termInfo = await sessionInfo.run();
fileTransferRef.current?.dispose();
fileTransferRef.current = null;
toast({
title: "Session terminated",
description: termInfo.reason()
});
setShowLogin(true);
} catch (err) {
setConnecting(false);
setShowLogin(true);
if (isIronError(err)) {
toast({
@@ -312,9 +322,7 @@ export default function RdpClient({
<div className="min-h-screen bg-background">
{showLogin && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">
RDP Test Connection
</h1>
<h1 className="mb-4 text-2xl font-semibold">RDP</h1>
<div className="space-y-4">
<Field label="Domain" id="domain">
@@ -381,6 +389,7 @@ export default function RdpClient({
<Button
onClick={startSession}
disabled={!moduleReady}
loading={connecting}
className="w-full"
>
{moduleReady ? "Connect" : "Loading module..."}
+104 -11
View File
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
type Target = {
ip: string;
@@ -15,6 +16,7 @@ type Target = {
type FormState = {
username: string;
password: string;
privateKey: string;
};
export default function SshClient({
@@ -24,11 +26,35 @@ export default function SshClient({
target: Target | null;
error: string | null;
}) {
const [form, setForm] = useState<FormState>({
username: "",
password: ""
const STORAGE_KEY = "pangolin_ssh_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { username: "", password: "", privateKey: "" };
});
const fileInputRef = useRef<HTMLInputElement>(null);
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target?.result;
if (typeof text === "string") {
setForm((prev) => ({ ...prev, privateKey: text }));
}
};
reader.readAsText(file);
// Reset input so the same file can be re-selected if needed.
e.target.value = "";
}
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
@@ -143,9 +169,20 @@ export default function SshClient({
wsRef.current = ws;
ws.onopen = () => {
// Send the password (or empty string) as the first frame so the
// proxy can complete SSH authentication before piping pty data.
ws.send(JSON.stringify({ type: "auth", password: form.password }));
// Send credentials as the first frame so the proxy can complete
// SSH authentication before piping pty data.
ws.send(
JSON.stringify({
type: "auth",
password: form.password,
privateKey: form.privateKey
})
);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
setConnecting(false);
setConnected(true);
};
@@ -207,9 +244,7 @@ export default function SshClient({
<div className="min-h-screen bg-background">
{!connected && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">
SSH Terminal
</h1>
<h1 className="mb-4 text-2xl font-semibold">SSH</h1>
<div className="space-y-4">
<Field label="Username" id="username">
@@ -236,6 +271,60 @@ export default function SshClient({
password: e.target.value
})
}
placeholder={
form.privateKey
? "Optional with key auth"
: ""
}
/>
</Field>
<Field label="Private Key (optional)" id="privateKey">
<Textarea
id="privateKey"
value={form.privateKey}
onChange={(e) =>
setForm({
...form,
privateKey: e.target.value
})
}
placeholder="Paste your private key here (PEM format)…"
rows={5}
className="font-mono text-xs"
/>
<div className="mt-1.5 flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
fileInputRef.current?.click()
}
>
Upload key file
</Button>
{form.privateKey && (
<button
type="button"
className="text-xs text-muted-foreground underline"
onClick={() =>
setForm((prev) => ({
...prev,
privateKey: ""
}))
}
>
Clear
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".pem,.key,.pub,*"
onChange={handleKeyFile}
/>
</Field>
@@ -247,10 +336,14 @@ export default function SshClient({
<Button
onClick={connect}
disabled={connecting || !form.username}
disabled={
connecting ||
!form.username ||
(!form.password && !form.privateKey)
}
className="w-full"
>
{connecting ? "Connecting" : "Connect"}
{connecting ? "Connecting..." : "Connect"}
</Button>
</div>
</div>
+16 -12
View File
@@ -23,8 +23,16 @@ export default function VncClient({
target: Target | null;
error: string | null;
}) {
const [form, setForm] = useState<FormState>({
password: ""
const STORAGE_KEY = "pangolin_vnc_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { password: "" };
});
const [connected, setConnected] = useState(false);
@@ -96,8 +104,6 @@ export default function VncClient({
});
const wsUrl = `${base}?${params.toString()}`;
toast({ title: "Connecting…", description: wsUrl });
// Clear the container so noVNC gets a clean mount point.
screenRef.current.innerHTML = "";
@@ -113,7 +119,11 @@ export default function VncClient({
rfb.resizeSession = true;
rfb.addEventListener("connect", () => {
toast({ title: "Connected" });
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
setConnected(true);
});
@@ -122,10 +132,6 @@ export default function VncClient({
(e: { detail: { clean: boolean } }) => {
rfbRef.current = null;
setConnected(false);
toast({
title: e.detail.clean ? "Disconnected" : "Connection lost",
variant: e.detail.clean ? "default" : "destructive"
});
}
);
@@ -155,9 +161,7 @@ export default function VncClient({
<div className="min-h-screen bg-background">
{!connected && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">
VNC Test Connection
</h1>
<h1 className="mb-4 text-2xl font-semibold">VNC</h1>
<div className="space-y-4">
<Field label="Password (optional)" id="password">
+13
View File
@@ -82,6 +82,7 @@ export type ResourceRow = {
name: string;
orgId: string;
domain: string;
browserAccessType: string | null;
authState: string;
http: boolean;
protocol: string;
@@ -493,6 +494,12 @@ export default function ProxyResourcesTable({
),
cell: ({ row }) => {
const resourceRow = row.original;
if (
!resourceRow.http ||
resourceRow.browserAccessType !== "http"
) {
return <span>-</span>;
}
return (
<TargetStatusCell
targets={resourceRow.targets}
@@ -521,6 +528,12 @@ export default function ProxyResourcesTable({
header: () => <span className="p-3">{t("uptime30d")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
if (
!resourceRow.http ||
resourceRow.browserAccessType !== "http"
) {
return <span>-</span>;
}
return <UptimeMiniBar resourceId={resourceRow.id} days={30} />;
}
},