mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-05 02:39:07 +02:00
clean up providers ui
This commit is contained in:
@@ -42,7 +42,7 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||
import { orgQueries, resourceQueries, aiProviderQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { type GetResourceResponse } from "@server/routers/resource";
|
||||
import { CreateTargetResponse } from "@server/routers/target";
|
||||
@@ -63,9 +63,11 @@ import { ExternalLink, Info, Plus } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
forwardRef,
|
||||
useActionState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState
|
||||
} from "react";
|
||||
@@ -80,27 +82,52 @@ export type LocalTarget = Omit<
|
||||
"protocol"
|
||||
>;
|
||||
|
||||
interface ProxyResourceTargetsFormProps {
|
||||
export type ProxyResourceTargetsFormHandle = {
|
||||
save: (options?: { silent?: boolean }) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type ProxyResourceTargetsFormProps = {
|
||||
orgId: string;
|
||||
isHttp: boolean;
|
||||
initialTargets?: LocalTarget[];
|
||||
/** Edit mode: when provided, shows a save button and polls for health status */
|
||||
/** Edit mode for a public resource: save button + health polling */
|
||||
resource?: GetResourceResponse;
|
||||
/** Edit mode for an AI provider: save button + health polling */
|
||||
providerId?: number;
|
||||
updateResource?: ResourceContextType["updateResource"];
|
||||
/** Create mode: called whenever the targets list changes */
|
||||
onChange?: (targets: LocalTarget[]) => void;
|
||||
}
|
||||
/** HTTP method options for address selector. Defaults to http/https/h2c. */
|
||||
allowedMethods?: ("http" | "https" | "h2c")[];
|
||||
emptyMessage?: string;
|
||||
/** Render table without its own SettingsSection wrapper */
|
||||
embedded?: boolean;
|
||||
/** Hide the built-in save button (use ref.save from parent) */
|
||||
hideSaveButton?: boolean;
|
||||
};
|
||||
|
||||
export function ProxyResourceTargetsForm({
|
||||
orgId,
|
||||
isHttp,
|
||||
initialTargets = [],
|
||||
resource,
|
||||
updateResource,
|
||||
onChange
|
||||
}: ProxyResourceTargetsFormProps) {
|
||||
export const ProxyResourceTargetsForm = forwardRef<
|
||||
ProxyResourceTargetsFormHandle,
|
||||
ProxyResourceTargetsFormProps
|
||||
>(function ProxyResourceTargetsForm(
|
||||
{
|
||||
orgId,
|
||||
isHttp,
|
||||
initialTargets = [],
|
||||
resource,
|
||||
providerId,
|
||||
updateResource,
|
||||
onChange,
|
||||
allowedMethods = ["http", "https", "h2c"],
|
||||
emptyMessage,
|
||||
embedded = false,
|
||||
hideSaveButton = false
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const isEditMode = !!resource || !!providerId;
|
||||
|
||||
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
|
||||
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
||||
@@ -111,7 +138,7 @@ export function ProxyResourceTargetsForm({
|
||||
}, [targets]);
|
||||
|
||||
// Poll health status only in edit mode
|
||||
const { data: polledTargets } = useQuery({
|
||||
const { data: polledResourceTargets } = useQuery({
|
||||
...resourceQueries.resourceTargets({
|
||||
resourceId: resource?.resourceId ?? 0
|
||||
}),
|
||||
@@ -119,6 +146,18 @@ export function ProxyResourceTargetsForm({
|
||||
enabled: !!resource
|
||||
});
|
||||
|
||||
const { data: polledProviderTargets } = useQuery({
|
||||
...aiProviderQueries.providerTargets({
|
||||
providerId: providerId ?? 0
|
||||
}),
|
||||
refetchInterval: 10_000,
|
||||
enabled: !!providerId
|
||||
});
|
||||
|
||||
const polledTargets = providerId
|
||||
? polledProviderTargets
|
||||
: polledResourceTargets;
|
||||
|
||||
useEffect(() => {
|
||||
if (!polledTargets) return;
|
||||
setTargets((prev) =>
|
||||
@@ -427,6 +466,7 @@ export function ProxyResourceTargetsForm({
|
||||
isHttp={isHttp}
|
||||
proxyTarget={row.original}
|
||||
updateTarget={updateTarget}
|
||||
allowedMethods={allowedMethods}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -576,16 +616,22 @@ export function ProxyResourceTargetsForm({
|
||||
refreshContainersForSite,
|
||||
openHealthCheckDialog,
|
||||
removeTarget,
|
||||
allowedMethods,
|
||||
t
|
||||
]);
|
||||
|
||||
function addNewTarget() {
|
||||
const defaultMethod = providerId
|
||||
? (allowedMethods[0] ?? "https")
|
||||
: isHttp
|
||||
? "http"
|
||||
: null;
|
||||
const newTarget: LocalTarget = {
|
||||
targetId: -Date.now(),
|
||||
ip: "",
|
||||
mode: ((resource?.mode as LocalTarget["mode"]) ??
|
||||
(isHttp ? "http" : "tcp")) as LocalTarget["mode"],
|
||||
method: isHttp ? "http" : null,
|
||||
method: defaultMethod,
|
||||
port: 0,
|
||||
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
||||
siteName: sites.length > 0 ? sites[0].name : "",
|
||||
@@ -595,8 +641,8 @@ export function ProxyResourceTargetsForm({
|
||||
rewritePathType: null,
|
||||
priority: 100,
|
||||
enabled: true,
|
||||
resourceId: resource?.resourceId ?? 0,
|
||||
providerId: null,
|
||||
resourceId: resource?.resourceId ?? null,
|
||||
providerId: providerId ?? null,
|
||||
hcEnabled: false,
|
||||
hcPath: null,
|
||||
hcMethod: null,
|
||||
@@ -671,10 +717,16 @@ export function ProxyResourceTargetsForm({
|
||||
}
|
||||
}, [isAdvancedMode]);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
|
||||
const [, formAction, isSubmitting] = useActionState(
|
||||
async () => {
|
||||
await saveTargets();
|
||||
return null;
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
const addTargetButton = (
|
||||
<Button onClick={addNewTarget} variant="outline">
|
||||
<Button type="button" onClick={addNewTarget} variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("addTarget")}
|
||||
</Button>
|
||||
@@ -682,8 +734,8 @@ export function ProxyResourceTargetsForm({
|
||||
|
||||
const hasTargets = targets.length > 0;
|
||||
|
||||
async function saveTargets() {
|
||||
if (!resource) return;
|
||||
async function saveTargets(options?: { silent?: boolean }) {
|
||||
if (!isEditMode) return true;
|
||||
|
||||
const targetsWithInvalidFields = targets.filter(
|
||||
(target) =>
|
||||
@@ -699,7 +751,7 @@ export function ProxyResourceTargetsForm({
|
||||
title: t("targetErrorInvalidIp"),
|
||||
description: t("targetErrorInvalidIpDescription")
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -743,9 +795,12 @@ export function ProxyResourceTargetsForm({
|
||||
}
|
||||
|
||||
if (target.new) {
|
||||
const createPath = providerId
|
||||
? `/ai-provider/${providerId}/target`
|
||||
: `/resource/${resource!.resourceId}/target`;
|
||||
const res = await api.put<
|
||||
AxiosResponse<CreateTargetResponse>
|
||||
>(`/resource/${resource.resourceId}/target`, data);
|
||||
>(createPath, data);
|
||||
target.targetId = res.data.data.targetId;
|
||||
target.new = false;
|
||||
} else if (target.updated) {
|
||||
@@ -754,24 +809,33 @@ export function ProxyResourceTargetsForm({
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title:
|
||||
targets.length === 0
|
||||
? t("targetTargetsCleared")
|
||||
: t("settingsUpdated"),
|
||||
description:
|
||||
targets.length === 0
|
||||
? t("targetTargetsClearedDescription")
|
||||
: t("settingsUpdatedDescription")
|
||||
});
|
||||
if (!options?.silent) {
|
||||
toast({
|
||||
title:
|
||||
targets.length === 0
|
||||
? t("targetTargetsCleared")
|
||||
: t("settingsUpdated"),
|
||||
description:
|
||||
targets.length === 0
|
||||
? t("targetTargetsClearedDescription")
|
||||
: t("settingsUpdatedDescription")
|
||||
});
|
||||
}
|
||||
|
||||
setTargetsToRemove([]);
|
||||
router.refresh();
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.resourceTargets({
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
if (providerId) {
|
||||
await queryClient.invalidateQueries(
|
||||
aiProviderQueries.providerTargets({ providerId })
|
||||
);
|
||||
} else if (resource) {
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.resourceTargets({
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
@@ -782,151 +846,164 @@ export function ProxyResourceTargetsForm({
|
||||
t("settingsErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: saveTargets
|
||||
}));
|
||||
|
||||
const advancedModeToggleId = providerId
|
||||
? `advanced-mode-toggle-provider-${providerId}`
|
||||
: resource
|
||||
? `advanced-mode-toggle-resource-${resource.resourceId}`
|
||||
: "advanced-mode-toggle";
|
||||
|
||||
const targetsTable = (
|
||||
<>
|
||||
<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";
|
||||
const isSiteColumn =
|
||||
header.column.id === "site";
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: isSiteColumn
|
||||
? "w-45"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{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";
|
||||
const isSiteColumn =
|
||||
cell.column.id === "site";
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: isSiteColumn
|
||||
? "w-45"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<DataTableEmptyState
|
||||
colSpan={columns.length}
|
||||
message={emptyMessage ?? t("targetNoOne")}
|
||||
action={addTargetButton}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{hasTargets && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
{addTargetButton}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id={advancedModeToggleId}
|
||||
checked={isAdvancedMode}
|
||||
onCheckedChange={setIsAdvancedMode}
|
||||
/>
|
||||
<label
|
||||
htmlFor={advancedModeToggleId}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>{t("targets")}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("targetsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<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";
|
||||
const isSiteColumn =
|
||||
header.column.id === "site";
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: isSiteColumn
|
||||
? "w-45"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{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";
|
||||
const isSiteColumn =
|
||||
cell.column.id ===
|
||||
"site";
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: isSiteColumn
|
||||
? "w-45"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<DataTableEmptyState
|
||||
colSpan={columns.length}
|
||||
message={t("targetNoOne")}
|
||||
action={addTargetButton}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{hasTargets && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
{addTargetButton}
|
||||
<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>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
{embedded ? (
|
||||
<div className="space-y-4">{targetsTable}</div>
|
||||
) : (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("targets")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("targetsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>{targetsTable}</SettingsSectionBody>
|
||||
|
||||
{/* Save button — only shown in edit mode */}
|
||||
{resource && (
|
||||
<form className="self-end mt-4" action={formAction}>
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveResourceTargets")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</SettingsSection>
|
||||
{isEditMode && !hideSaveButton && (
|
||||
<form className="self-end mt-4" action={formAction}>
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveResourceTargets")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{selectedTargetForHealthCheck && (
|
||||
<HealthCheckCredenza
|
||||
@@ -987,4 +1064,4 @@ export function ProxyResourceTargetsForm({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user