mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-28 22:00:51 +00:00
Compare commits
21 Commits
1.15.0-s.2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37c4a7b690 | ||
|
|
b735e7c34d | ||
|
|
5f85c3b3b8 | ||
|
|
5d9cb9fa21 | ||
|
|
643d56958d | ||
|
|
f378d6f040 | ||
|
|
bb57794388 | ||
|
|
a9ca49b8a2 | ||
|
|
c1b473294e | ||
|
|
e3e4bdfe09 | ||
|
|
bfbeace2e2 | ||
|
|
5f19918ca0 | ||
|
|
2959ad0e70 | ||
|
|
a76eec7bb7 | ||
|
|
068b2a0dcd | ||
|
|
316b7e5653 | ||
|
|
00fc1da33c | ||
|
|
9ef93df54f | ||
|
|
fd9fdf6399 | ||
|
|
8fa1701e06 | ||
|
|
4abe83f8a9 |
28
.github/workflows/cicd.yml
vendored
28
.github/workflows/cicd.yml
vendored
@@ -482,14 +482,32 @@ jobs:
|
|||||||
echo "==> cosign sign (key) --recursive ${REF}"
|
echo "==> cosign sign (key) --recursive ${REF}"
|
||||||
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
||||||
|
|
||||||
|
# Retry wrapper for verification to handle registry propagation delays
|
||||||
|
retry_verify() {
|
||||||
|
local cmd="$1"
|
||||||
|
local attempts=6
|
||||||
|
local delay=5
|
||||||
|
local i=1
|
||||||
|
until eval "$cmd"; do
|
||||||
|
if [ $i -ge $attempts ]; then
|
||||||
|
echo "Verification failed after $attempts attempts"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
|
||||||
|
sleep $delay
|
||||||
|
i=$((i+1))
|
||||||
|
delay=$((delay*2))
|
||||||
|
# Cap the delay to avoid very long waits
|
||||||
|
if [ $delay -gt 60 ]; then delay=60; fi
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
echo "==> cosign verify (public key) ${REF}"
|
echo "==> cosign verify (public key) ${REF}"
|
||||||
cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text
|
retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"
|
||||||
|
|
||||||
echo "==> cosign verify (keyless policy) ${REF}"
|
echo "==> cosign verify (keyless policy) ${REF}"
|
||||||
cosign verify \
|
retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"
|
||||||
--certificate-oidc-issuer "${issuer}" \
|
|
||||||
--certificate-identity-regexp "${id_regex}" \
|
|
||||||
"${REF}" -o text
|
|
||||||
|
|
||||||
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
|
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
|
|
||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { getOrgTierData } from "@server/lib/billing";
|
import { getOrgTierData } from "#private/lib/billing";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
import {
|
import {
|
||||||
approvals,
|
approvals,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { approvals, clients, db, orgs, type Approval } from "@server/db";
|
import { approvals, clients, db, orgs, type Approval } from "@server/db";
|
||||||
import { getOrgTierData } from "@server/lib/billing";
|
import { getOrgTierData } from "#private/lib/billing";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { and, eq, type InferInsertModel } from "drizzle-orm";
|
import { and, eq, type InferInsertModel } from "drizzle-orm";
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export async function upsertLoginPageBranding(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const parsedBody = bodySchema.safeParse(req.body);
|
const parsedBody = await bodySchema.safeParseAsync(req.body);
|
||||||
if (!parsedBody.success) {
|
if (!parsedBody.success) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
|
||||||
import { sendTerminateClient } from "./terminate";
|
|
||||||
import { OlmErrorCodes } from "../olm/error";
|
|
||||||
|
|
||||||
const archiveClientSchema = z.strictObject({
|
const archiveClientSchema = z.strictObject({
|
||||||
clientId: z.string().transform(Number).pipe(z.int().positive())
|
clientId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
@@ -77,9 +74,6 @@ export async function archiveClient(
|
|||||||
.update(clients)
|
.update(clients)
|
||||||
.set({ archived: true })
|
.set({ archived: true })
|
||||||
.where(eq(clients.clientId, clientId));
|
.where(eq(clients.clientId, clientId));
|
||||||
|
|
||||||
// Rebuild associations to clean up related data
|
|
||||||
await rebuildClientAssociationsFromClient(client, trx);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { olms, clients } from "@server/db";
|
import { olms } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -8,9 +8,6 @@ import response from "@server/lib/response";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
|
||||||
import { sendTerminateClient } from "../client/terminate";
|
|
||||||
import { OlmErrorCodes } from "./error";
|
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -37,26 +34,7 @@ export async function archiveUserOlm(
|
|||||||
|
|
||||||
const { olmId } = parsedParams.data;
|
const { olmId } = parsedParams.data;
|
||||||
|
|
||||||
// Archive the OLM and disconnect associated clients in a transaction
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// Find all clients associated with this OLM
|
|
||||||
const associatedClients = await trx
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.olmId, olmId));
|
|
||||||
|
|
||||||
// Disconnect clients from the OLM (set olmId to null)
|
|
||||||
for (const client of associatedClients) {
|
|
||||||
await trx
|
|
||||||
.update(clients)
|
|
||||||
.set({ olmId: null })
|
|
||||||
.where(eq(clients.clientId, client.clientId));
|
|
||||||
|
|
||||||
await rebuildClientAssociationsFromClient(client, trx);
|
|
||||||
await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, olmId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Archive the OLM (set archived to true)
|
|
||||||
await trx
|
await trx
|
||||||
.update(olms)
|
.update(olms)
|
||||||
.set({ archived: true })
|
.set({ archived: true })
|
||||||
|
|||||||
@@ -19,17 +19,6 @@ export interface ApprovalFeedPageProps {
|
|||||||
export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
|
export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
let approvals: ApprovalItem[] = [];
|
|
||||||
const res = await internal
|
|
||||||
.get<
|
|
||||||
AxiosResponse<{ approvals: ApprovalItem[] }>
|
|
||||||
>(`/org/${params.orgId}/approvals`, await authCookieHeader())
|
|
||||||
.catch((e) => {});
|
|
||||||
|
|
||||||
if (res && res.status === 200) {
|
|
||||||
approvals = res.data.data.approvals;
|
|
||||||
}
|
|
||||||
|
|
||||||
let org: GetOrgResponse | null = null;
|
let org: GetOrgResponse | null = null;
|
||||||
const orgRes = await getCachedOrg(params.orgId);
|
const orgRes = await getCachedOrg(params.orgId);
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
SelectValue
|
SelectValue
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { ContainersSelector } from "@app/components/ContainersSelector";
|
|
||||||
import { HeadersInput } from "@app/components/HeadersInput";
|
import { HeadersInput } from "@app/components/HeadersInput";
|
||||||
import {
|
import {
|
||||||
PathMatchDisplay,
|
PathMatchDisplay,
|
||||||
@@ -19,6 +18,7 @@ import {
|
|||||||
PathRewriteDisplay,
|
PathRewriteDisplay,
|
||||||
PathRewriteModal
|
PathRewriteModal
|
||||||
} from "@app/components/PathMatchRenameModal";
|
} from "@app/components/PathMatchRenameModal";
|
||||||
|
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
@@ -30,15 +30,6 @@ import {
|
|||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList
|
|
||||||
} from "@app/components/ui/command";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -48,11 +39,6 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger
|
|
||||||
} from "@app/components/ui/popover";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -73,12 +59,9 @@ import { useResourceContext } from "@app/hooks/useResourceContext";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||||
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
|
||||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
|
||||||
import { tlsNameSchema } from "@server/lib/schemas";
|
import { tlsNameSchema } from "@server/lib/schemas";
|
||||||
import { type GetResourceResponse } from "@server/routers/resource";
|
import { type GetResourceResponse } from "@server/routers/resource";
|
||||||
import type { ListSitesResponse } from "@server/routers/site";
|
import type { ListSitesResponse } from "@server/routers/site";
|
||||||
@@ -98,7 +81,6 @@ import {
|
|||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckIcon,
|
|
||||||
CircleCheck,
|
CircleCheck,
|
||||||
CircleX,
|
CircleX,
|
||||||
Info,
|
Info,
|
||||||
@@ -107,7 +89,7 @@ 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, useEffect, 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";
|
||||||
|
|
||||||
@@ -202,7 +184,7 @@ function ProxyResourceTargetsForm({
|
|||||||
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshContainersForSite = async (siteId: number) => {
|
const refreshContainersForSite = useCallback(async (siteId: number) => {
|
||||||
const dockerManager = new DockerManager(api, siteId);
|
const dockerManager = new DockerManager(api, siteId);
|
||||||
const containers = await dockerManager.fetchContainers();
|
const containers = await dockerManager.fetchContainers();
|
||||||
|
|
||||||
@@ -214,9 +196,9 @@ function ProxyResourceTargetsForm({
|
|||||||
}
|
}
|
||||||
return newMap;
|
return newMap;
|
||||||
});
|
});
|
||||||
};
|
}, [api]);
|
||||||
|
|
||||||
const getDockerStateForSite = (siteId: number): DockerState => {
|
const getDockerStateForSite = useCallback((siteId: number): DockerState => {
|
||||||
return (
|
return (
|
||||||
dockerStates.get(siteId) || {
|
dockerStates.get(siteId) || {
|
||||||
isEnabled: false,
|
isEnabled: false,
|
||||||
@@ -224,7 +206,7 @@ function ProxyResourceTargetsForm({
|
|||||||
containers: []
|
containers: []
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
}, [dockerStates]);
|
||||||
|
|
||||||
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
|
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -234,8 +216,40 @@ function ProxyResourceTargetsForm({
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const getColumns = (): ColumnDef<LocalTarget>[] => {
|
const isHttp = resource.http;
|
||||||
const isHttp = resource.http;
|
|
||||||
|
const removeTarget = useCallback((targetId: number) => {
|
||||||
|
setTargets((prevTargets) => {
|
||||||
|
const targetToRemove = prevTargets.find((target) => target.targetId === targetId);
|
||||||
|
if (targetToRemove && !targetToRemove.new) {
|
||||||
|
setTargetsToRemove((prev) => [...prev, targetId]);
|
||||||
|
}
|
||||||
|
return prevTargets.filter((target) => target.targetId !== targetId);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateTarget = useCallback((targetId: number, data: Partial<LocalTarget>) => {
|
||||||
|
setTargets((prevTargets) => {
|
||||||
|
const site = sites.find((site) => site.siteId === data.siteId);
|
||||||
|
return prevTargets.map((target) =>
|
||||||
|
target.targetId === targetId
|
||||||
|
? {
|
||||||
|
...target,
|
||||||
|
...data,
|
||||||
|
updated: true,
|
||||||
|
siteType: site ? site.type : target.siteType
|
||||||
|
}
|
||||||
|
: target
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [sites]);
|
||||||
|
|
||||||
|
const openHealthCheckDialog = useCallback((target: LocalTarget) => {
|
||||||
|
setSelectedTargetForHealthCheck(target);
|
||||||
|
setHealthCheckDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columns = useMemo((): ColumnDef<LocalTarget>[] => {
|
||||||
|
|
||||||
const priorityColumn: ColumnDef<LocalTarget> = {
|
const priorityColumn: ColumnDef<LocalTarget> = {
|
||||||
id: "priority",
|
id: "priority",
|
||||||
@@ -419,213 +433,15 @@ function ProxyResourceTargetsForm({
|
|||||||
accessorKey: "address",
|
accessorKey: "address",
|
||||||
header: () => <span className="p-3">{t("address")}</span>,
|
header: () => <span className="p-3">{t("address")}</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const selectedSite = sites.find(
|
|
||||||
(site) => site.siteId === row.original.siteId
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleContainerSelectForTarget = (
|
|
||||||
hostname: string,
|
|
||||||
port?: number
|
|
||||||
) => {
|
|
||||||
updateTarget(row.original.targetId, {
|
|
||||||
...row.original,
|
|
||||||
ip: hostname,
|
|
||||||
...(port && { port: port })
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center w-full">
|
<ResourceTargetAddressItem
|
||||||
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
|
isHttp={isHttp}
|
||||||
{selectedSite &&
|
sites={sites}
|
||||||
selectedSite.type === "newt" &&
|
getDockerStateForSite={getDockerStateForSite}
|
||||||
(() => {
|
proxyTarget={row.original}
|
||||||
const dockerState = getDockerStateForSite(
|
refreshContainersForSite={refreshContainersForSite}
|
||||||
selectedSite.siteId
|
updateTarget={updateTarget}
|
||||||
);
|
/>
|
||||||
return (
|
|
||||||
<ContainersSelector
|
|
||||||
site={selectedSite}
|
|
||||||
containers={dockerState.containers}
|
|
||||||
isAvailable={
|
|
||||||
dockerState.isAvailable
|
|
||||||
}
|
|
||||||
onContainerSelect={
|
|
||||||
handleContainerSelectForTarget
|
|
||||||
}
|
|
||||||
onRefresh={() =>
|
|
||||||
refreshContainersForSite(
|
|
||||||
selectedSite.siteId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
|
|
||||||
!row.original.siteId &&
|
|
||||||
"text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="truncate max-w-[150px]">
|
|
||||||
{row.original.siteId
|
|
||||||
? selectedSite?.name
|
|
||||||
: t("siteSelect")}
|
|
||||||
</span>
|
|
||||||
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="p-0 w-[180px]">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder={t("siteSearch")}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>
|
|
||||||
{t("siteNotFound")}
|
|
||||||
</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{sites.map((site) => (
|
|
||||||
<CommandItem
|
|
||||||
key={site.siteId}
|
|
||||||
value={`${site.siteId}:${site.name}`}
|
|
||||||
onSelect={() =>
|
|
||||||
updateTarget(
|
|
||||||
row.original
|
|
||||||
.targetId,
|
|
||||||
{
|
|
||||||
siteId: site.siteId
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
site.siteId ===
|
|
||||||
row.original
|
|
||||||
.siteId
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{site.name}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{resource.http && (
|
|
||||||
<Select
|
|
||||||
defaultValue={row.original.method ?? "http"}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateTarget(row.original.targetId, {
|
|
||||||
...row.original,
|
|
||||||
method: value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 px-2 w-[70px] text-sm font-normal border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent">
|
|
||||||
{row.original.method || "http"}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="http">
|
|
||||||
http
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="https">
|
|
||||||
https
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="h2c">h2c</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{resource.http && (
|
|
||||||
<div className="flex items-center justify-center px-2 h-9">
|
|
||||||
{"://"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Input
|
|
||||||
defaultValue={row.original.ip}
|
|
||||||
placeholder="Host"
|
|
||||||
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
|
|
||||||
onBlur={(e) => {
|
|
||||||
const input = e.target.value.trim();
|
|
||||||
const hasProtocol =
|
|
||||||
/^(https?|h2c):\/\//.test(input);
|
|
||||||
const hasPort = /:\d+(?:\/|$)/.test(input);
|
|
||||||
|
|
||||||
if (hasProtocol || hasPort) {
|
|
||||||
const parsed = parseHostTarget(input);
|
|
||||||
if (parsed) {
|
|
||||||
updateTarget(
|
|
||||||
row.original.targetId,
|
|
||||||
{
|
|
||||||
...row.original,
|
|
||||||
method: hasProtocol
|
|
||||||
? parsed.protocol
|
|
||||||
: row.original.method,
|
|
||||||
ip: parsed.host,
|
|
||||||
port: hasPort
|
|
||||||
? parsed.port
|
|
||||||
: row.original.port
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
updateTarget(
|
|
||||||
row.original.targetId,
|
|
||||||
{
|
|
||||||
...row.original,
|
|
||||||
ip: input
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
updateTarget(row.original.targetId, {
|
|
||||||
...row.original,
|
|
||||||
ip: input
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-center px-2 h-9">
|
|
||||||
{":"}
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
placeholder="Port"
|
|
||||||
defaultValue={
|
|
||||||
row.original.port === 0
|
|
||||||
? ""
|
|
||||||
: row.original.port
|
|
||||||
}
|
|
||||||
className="w-[75px] pl-0 border-none placeholder-gray-400"
|
|
||||||
onBlur={(e) => {
|
|
||||||
const value = parseInt(e.target.value, 10);
|
|
||||||
if (!isNaN(value) && value > 0) {
|
|
||||||
updateTarget(row.original.targetId, {
|
|
||||||
...row.original,
|
|
||||||
port: value
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateTarget(row.original.targetId, {
|
|
||||||
...row.original,
|
|
||||||
port: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
size: 400,
|
size: 400,
|
||||||
@@ -765,7 +581,7 @@ function ProxyResourceTargetsForm({
|
|||||||
actionsColumn
|
actionsColumn
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
};
|
}, [isAdvancedMode, isHttp, sites, updateTarget, getDockerStateForSite, refreshContainersForSite, openHealthCheckDialog, removeTarget, t]);
|
||||||
|
|
||||||
function addNewTarget() {
|
function addNewTarget() {
|
||||||
const isHttp = resource.http;
|
const isHttp = resource.http;
|
||||||
@@ -806,32 +622,6 @@ function ProxyResourceTargetsForm({
|
|||||||
setTargets((prev) => [...prev, newTarget]);
|
setTargets((prev) => [...prev, newTarget]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeTarget = (targetId: number) => {
|
|
||||||
setTargets([
|
|
||||||
...targets.filter((target) => target.targetId !== targetId)
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!targets.find((target) => target.targetId === targetId)?.new) {
|
|
||||||
setTargetsToRemove([...targetsToRemove, targetId]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
|
|
||||||
const site = sites.find((site) => site.siteId === data.siteId);
|
|
||||||
setTargets(
|
|
||||||
targets.map((target) =>
|
|
||||||
target.targetId === targetId
|
|
||||||
? {
|
|
||||||
...target,
|
|
||||||
...data,
|
|
||||||
updated: true,
|
|
||||||
siteType: site ? site.type : target.siteType
|
|
||||||
}
|
|
||||||
: target
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTargetHealthCheck(targetId: number, config: any) {
|
function updateTargetHealthCheck(targetId: number, config: any) {
|
||||||
setTargets(
|
setTargets(
|
||||||
targets.map((target) =>
|
targets.map((target) =>
|
||||||
@@ -846,14 +636,6 @@ function ProxyResourceTargetsForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const openHealthCheckDialog = (target: LocalTarget) => {
|
|
||||||
console.log(target);
|
|
||||||
setSelectedTargetForHealthCheck(target);
|
|
||||||
setHealthCheckDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = getColumns();
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: targets,
|
data: targets,
|
||||||
columns,
|
columns,
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
|
import DomainPicker from "@app/components/DomainPicker";
|
||||||
|
import HealthCheckDialog from "@app/components/HealthCheckDialog";
|
||||||
|
import {
|
||||||
|
PathMatchDisplay,
|
||||||
|
PathMatchModal,
|
||||||
|
PathRewriteDisplay,
|
||||||
|
PathRewriteModal
|
||||||
|
} from "@app/components/PathMatchRenameModal";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
@@ -9,6 +18,10 @@ import {
|
|||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionTitle
|
SettingsSectionTitle
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { StrategySelect } from "@app/components/StrategySelect";
|
||||||
|
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -18,22 +31,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { Resource } from "@server/db";
|
|
||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -41,48 +39,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
import { ListDomainsResponse } from "@server/routers/domain";
|
import { Switch } from "@app/components/ui/switch";
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList
|
|
||||||
} from "@app/components/ui/command";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger
|
|
||||||
} from "@app/components/ui/popover";
|
|
||||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
CircleCheck,
|
|
||||||
CircleX,
|
|
||||||
Info,
|
|
||||||
MoveRight,
|
|
||||||
Plus,
|
|
||||||
Settings,
|
|
||||||
SquareArrowOutUpRight
|
|
||||||
} from "lucide-react";
|
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import DomainPicker from "@app/components/DomainPicker";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import { ContainersSelector } from "@app/components/ContainersSelector";
|
|
||||||
import {
|
|
||||||
ColumnDef,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getCoreRowModel,
|
|
||||||
useReactTable,
|
|
||||||
flexRender,
|
|
||||||
Row
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -91,30 +48,49 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow
|
||||||
} from "@app/components/ui/table";
|
} from "@app/components/ui/table";
|
||||||
import { Switch } from "@app/components/ui/switch";
|
|
||||||
import { ArrayElement } from "@server/types/ArrayElement";
|
|
||||||
import { isTargetValid } from "@server/lib/validators";
|
|
||||||
import { ListTargetsResponse } from "@server/routers/target";
|
|
||||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
|
||||||
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
|
||||||
import { toASCII, toUnicode } from "punycode";
|
|
||||||
import { DomainRow } from "@app/components/DomainsTable";
|
|
||||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger
|
TooltipTrigger
|
||||||
} from "@app/components/ui/tooltip";
|
} from "@app/components/ui/tooltip";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||||
|
import { orgQueries } from "@app/lib/queries";
|
||||||
|
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Resource } from "@server/db";
|
||||||
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
|
import { ListTargetsResponse } from "@server/routers/target";
|
||||||
|
import { ArrayElement } from "@server/types/ArrayElement";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
PathMatchDisplay,
|
ColumnDef,
|
||||||
PathMatchModal,
|
flexRender,
|
||||||
PathRewriteDisplay,
|
getCoreRowModel,
|
||||||
PathRewriteModal
|
getFilteredRowModel,
|
||||||
} from "@app/components/PathMatchRenameModal";
|
getPaginationRowModel,
|
||||||
import { Badge } from "@app/components/ui/badge";
|
getSortedRowModel,
|
||||||
import HealthCheckDialog from "@app/components/HealthCheckDialog";
|
useReactTable
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
} from "@tanstack/react-table";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import {
|
||||||
|
CircleCheck,
|
||||||
|
CircleX,
|
||||||
|
Info,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
|
SquareArrowOutUpRight
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { toASCII } from "punycode";
|
||||||
|
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
const baseResourceFormSchema = z.object({
|
const baseResourceFormSchema = z.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
@@ -204,10 +180,6 @@ const addTargetSchema = z
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
|
|
||||||
type HttpResourceFormValues = z.infer<typeof httpResourceFormSchema>;
|
|
||||||
type TcpUdpResourceFormValues = z.infer<typeof tcpUdpResourceFormSchema>;
|
|
||||||
|
|
||||||
type ResourceType = "http" | "raw";
|
type ResourceType = "http" | "raw";
|
||||||
|
|
||||||
interface ResourceTypeOption {
|
interface ResourceTypeOption {
|
||||||
@@ -217,7 +189,7 @@ interface ResourceTypeOption {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocalTarget = Omit<
|
export type LocalTarget = Omit<
|
||||||
ArrayElement<ListTargetsResponse["targets"]> & {
|
ArrayElement<ListTargetsResponse["targets"]> & {
|
||||||
new?: boolean;
|
new?: boolean;
|
||||||
updated?: boolean;
|
updated?: boolean;
|
||||||
@@ -233,18 +205,16 @@ export default function Page() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const [loadingPage, setLoadingPage] = useState(true);
|
const { data: sites = [], isLoading: loadingPage } = useQuery(
|
||||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
orgQueries.sites({ orgId: orgId as string })
|
||||||
const [baseDomains, setBaseDomains] = useState<
|
);
|
||||||
{ domainId: string; baseDomain: string }[]
|
|
||||||
>([]);
|
|
||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [showSnippets, setShowSnippets] = useState(false);
|
const [showSnippets, setShowSnippets] = useState(false);
|
||||||
const [niceId, setNiceId] = useState<string>("");
|
const [niceId, setNiceId] = useState<string>("");
|
||||||
|
|
||||||
// Target management state
|
// Target management state
|
||||||
const [targets, setTargets] = useState<LocalTarget[]>([]);
|
const [targets, setTargets] = useState<LocalTarget[]>([]);
|
||||||
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
|
||||||
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
|
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
|
||||||
new Map()
|
new Map()
|
||||||
);
|
);
|
||||||
@@ -405,102 +375,60 @@ export default function Page() {
|
|||||||
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshContainersForSite = 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]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getDockerStateForSite = useCallback(
|
||||||
|
(siteId: number): DockerState => {
|
||||||
|
return (
|
||||||
|
dockerStates.get(siteId) || {
|
||||||
|
isEnabled: false,
|
||||||
|
isAvailable: false,
|
||||||
|
containers: []
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dockerStates]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeTarget = useCallback((targetId: number) => {
|
||||||
|
setTargets((prevTargets) => {
|
||||||
|
return prevTargets.filter((target) => target.targetId !== targetId);
|
||||||
});
|
});
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const getDockerStateForSite = (siteId: number): DockerState => {
|
const updateTarget = useCallback(
|
||||||
return (
|
(targetId: number, data: Partial<LocalTarget>) => {
|
||||||
dockerStates.get(siteId) || {
|
setTargets((prevTargets) => {
|
||||||
isEnabled: false,
|
const site = sites.find((site) => site.siteId === data.siteId);
|
||||||
isAvailable: false,
|
return prevTargets.map((target) =>
|
||||||
containers: []
|
target.targetId === targetId
|
||||||
}
|
? {
|
||||||
);
|
...target,
|
||||||
};
|
...data,
|
||||||
|
updated: true,
|
||||||
async function addTarget(data: z.infer<typeof addTargetSchema>) {
|
siteType: site ? site.type : target.siteType
|
||||||
const site = sites.find((site) => site.siteId === data.siteId);
|
}
|
||||||
|
: target
|
||||||
const isHttp = baseForm.watch("http");
|
);
|
||||||
|
});
|
||||||
const newTarget: LocalTarget = {
|
},
|
||||||
...data,
|
[sites]
|
||||||
path: isHttp ? data.path || null : null,
|
);
|
||||||
pathMatchType: isHttp ? data.pathMatchType || null : null,
|
|
||||||
rewritePath: isHttp ? data.rewritePath || null : null,
|
|
||||||
rewritePathType: isHttp ? data.rewritePathType || null : null,
|
|
||||||
siteType: site?.type || null,
|
|
||||||
enabled: true,
|
|
||||||
targetId: new Date().getTime(),
|
|
||||||
new: true,
|
|
||||||
resourceId: 0, // Will be set when resource is created
|
|
||||||
priority: isHttp ? data.priority || 100 : 100, // Default priority
|
|
||||||
hcEnabled: false,
|
|
||||||
hcPath: null,
|
|
||||||
hcMethod: null,
|
|
||||||
hcInterval: null,
|
|
||||||
hcTimeout: null,
|
|
||||||
hcHeaders: null,
|
|
||||||
hcScheme: null,
|
|
||||||
hcHostname: null,
|
|
||||||
hcPort: null,
|
|
||||||
hcFollowRedirects: null,
|
|
||||||
hcHealth: "unknown",
|
|
||||||
hcStatus: null,
|
|
||||||
hcMode: null,
|
|
||||||
hcUnhealthyInterval: null,
|
|
||||||
hcTlsServerName: null
|
|
||||||
};
|
|
||||||
|
|
||||||
setTargets([...targets, newTarget]);
|
|
||||||
addTargetForm.reset({
|
|
||||||
ip: "",
|
|
||||||
method: baseForm.watch("http") ? "http" : null,
|
|
||||||
port: "" as any as number,
|
|
||||||
path: null,
|
|
||||||
pathMatchType: null,
|
|
||||||
rewritePath: null,
|
|
||||||
rewritePathType: null,
|
|
||||||
priority: isHttp ? 100 : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeTarget = (targetId: number) => {
|
|
||||||
setTargets([
|
|
||||||
...targets.filter((target) => target.targetId !== targetId)
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!targets.find((target) => target.targetId === targetId)?.new) {
|
|
||||||
setTargetsToRemove([...targetsToRemove, targetId]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
|
|
||||||
const site = sites.find((site) => site.siteId === data.siteId);
|
|
||||||
setTargets(
|
|
||||||
targets.map((target) =>
|
|
||||||
target.targetId === targetId
|
|
||||||
? {
|
|
||||||
...target,
|
|
||||||
...data,
|
|
||||||
updated: true,
|
|
||||||
siteType: site ? site.type : target.siteType
|
|
||||||
}
|
|
||||||
: target
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
setCreateLoading(true);
|
setCreateLoading(true);
|
||||||
@@ -638,82 +566,18 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
// Initialize Docker for newt sites
|
||||||
setLoadingPage(true);
|
for (const site of sites) {
|
||||||
|
if (site.type === "newt") {
|
||||||
|
initializeDockerForSite(site.siteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchSites = async () => {
|
// If there's at least one site, set it as the default in the form
|
||||||
const res = await api
|
if (sites.length > 0) {
|
||||||
.get<
|
addTargetForm.setValue("siteId", sites[0].siteId);
|
||||||
AxiosResponse<ListSitesResponse>
|
}
|
||||||
>(`/org/${orgId}/sites/`)
|
}, [sites]);
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("sitesErrorFetch"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("sitesErrorFetchDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res?.status === 200) {
|
|
||||||
setSites(res.data.data.sites);
|
|
||||||
|
|
||||||
// Initialize Docker for newt sites
|
|
||||||
for (const site of res.data.data.sites) {
|
|
||||||
if (site.type === "newt") {
|
|
||||||
initializeDockerForSite(site.siteId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there's only one site, set it as the default in the form
|
|
||||||
if (res.data.data.sites.length) {
|
|
||||||
addTargetForm.setValue(
|
|
||||||
"siteId",
|
|
||||||
res.data.data.sites[0].siteId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchDomains = async () => {
|
|
||||||
const res = await api
|
|
||||||
.get<
|
|
||||||
AxiosResponse<ListDomainsResponse>
|
|
||||||
>(`/org/${orgId}/domains/`)
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("domainsErrorFetch"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("domainsErrorFetchDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res?.status === 200) {
|
|
||||||
const rawDomains = res.data.data.domains as DomainRow[];
|
|
||||||
const domains = rawDomains.map((domain) => ({
|
|
||||||
...domain,
|
|
||||||
baseDomain: toUnicode(domain.baseDomain)
|
|
||||||
}));
|
|
||||||
setBaseDomains(domains);
|
|
||||||
// if (domains.length) {
|
|
||||||
// httpForm.setValue("domainId", domains[0].domainId);
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await fetchSites();
|
|
||||||
await fetchDomains();
|
|
||||||
|
|
||||||
setLoadingPage(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function TargetHealthCheck(targetId: number, config: any) {
|
function TargetHealthCheck(targetId: number, config: any) {
|
||||||
setTargets(
|
setTargets(
|
||||||
@@ -729,16 +593,15 @@ export default function Page() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const openHealthCheckDialog = (target: LocalTarget) => {
|
const openHealthCheckDialog = useCallback((target: LocalTarget) => {
|
||||||
console.log(target);
|
console.log(target);
|
||||||
setSelectedTargetForHealthCheck(target);
|
setSelectedTargetForHealthCheck(target);
|
||||||
setHealthCheckDialogOpen(true);
|
setHealthCheckDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const getColumns = (): ColumnDef<LocalTarget>[] => {
|
const isHttp = baseForm.watch("http");
|
||||||
const baseColumns: ColumnDef<LocalTarget>[] = [];
|
|
||||||
const isHttp = baseForm.watch("http");
|
|
||||||
|
|
||||||
|
const columns = useMemo((): ColumnDef<LocalTarget>[] => {
|
||||||
const priorityColumn: ColumnDef<LocalTarget> = {
|
const priorityColumn: ColumnDef<LocalTarget> = {
|
||||||
id: "priority",
|
id: "priority",
|
||||||
header: () => (
|
header: () => (
|
||||||
@@ -875,7 +738,7 @@ export default function Page() {
|
|||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer max-w-[200px]"
|
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer max-w-50"
|
||||||
>
|
>
|
||||||
<PathMatchDisplay
|
<PathMatchDisplay
|
||||||
value={{
|
value={{
|
||||||
@@ -899,7 +762,7 @@ export default function Page() {
|
|||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full max-w-[200px]"
|
className="w-full max-w-50"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
{t("matchPath")}
|
{t("matchPath")}
|
||||||
@@ -918,216 +781,16 @@ export default function Page() {
|
|||||||
const addressColumn: ColumnDef<LocalTarget> = {
|
const addressColumn: ColumnDef<LocalTarget> = {
|
||||||
accessorKey: "address",
|
accessorKey: "address",
|
||||||
header: () => <span className="p-3">{t("address")}</span>,
|
header: () => <span className="p-3">{t("address")}</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const selectedSite = sites.find(
|
<ResourceTargetAddressItem
|
||||||
(site) => site.siteId === row.original.siteId
|
isHttp={isHttp}
|
||||||
);
|
sites={sites}
|
||||||
|
getDockerStateForSite={getDockerStateForSite}
|
||||||
const handleContainerSelectForTarget = (
|
proxyTarget={row.original}
|
||||||
hostname: string,
|
refreshContainersForSite={refreshContainersForSite}
|
||||||
port?: number
|
updateTarget={updateTarget}
|
||||||
) => {
|
/>
|
||||||
updateTarget(row.original.targetId, {
|
),
|
||||||
...row.original,
|
|
||||||
ip: hostname,
|
|
||||||
...(port && { port: port })
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center w-full">
|
|
||||||
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
|
|
||||||
{selectedSite &&
|
|
||||||
selectedSite.type === "newt" &&
|
|
||||||
(() => {
|
|
||||||
const dockerState = getDockerStateForSite(
|
|
||||||
selectedSite.siteId
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<ContainersSelector
|
|
||||||
site={selectedSite}
|
|
||||||
containers={dockerState.containers}
|
|
||||||
isAvailable={
|
|
||||||
dockerState.isAvailable
|
|
||||||
}
|
|
||||||
onContainerSelect={
|
|
||||||
handleContainerSelectForTarget
|
|
||||||
}
|
|
||||||
onRefresh={() =>
|
|
||||||
refreshContainersForSite(
|
|
||||||
selectedSite.siteId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
|
|
||||||
!row.original.siteId &&
|
|
||||||
"text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="truncate max-w-[150px]">
|
|
||||||
{row.original.siteId
|
|
||||||
? selectedSite?.name
|
|
||||||
: t("siteSelect")}
|
|
||||||
</span>
|
|
||||||
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="p-0 w-[180px]">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder={t("siteSearch")}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>
|
|
||||||
{t("siteNotFound")}
|
|
||||||
</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{sites.map((site) => (
|
|
||||||
<CommandItem
|
|
||||||
key={site.siteId}
|
|
||||||
value={`${site.siteId}:${site.name}`}
|
|
||||||
onSelect={() =>
|
|
||||||
updateTarget(
|
|
||||||
row.original
|
|
||||||
.targetId,
|
|
||||||
{
|
|
||||||
siteId: site.siteId
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
site.siteId ===
|
|
||||||
row.original
|
|
||||||
.siteId
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{site.name}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{isHttp && (
|
|
||||||
<Select
|
|
||||||
defaultValue={row.original.method ?? "http"}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateTarget(row.original.targetId, {
|
|
||||||
...row.original,
|
|
||||||
method: value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 px-2 w-[70px] border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent">
|
|
||||||
{row.original.method || "http"}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="http">
|
|
||||||
http
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="https">
|
|
||||||
https
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="h2c">h2c</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isHttp && (
|
|
||||||
<div className="flex items-center justify-center px-2 h-9">
|
|
||||||
{"://"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Input
|
|
||||||
defaultValue={row.original.ip}
|
|
||||||
placeholder="Host"
|
|
||||||
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
|
|
||||||
onBlur={(e) => {
|
|
||||||
const input = e.target.value.trim();
|
|
||||||
const hasProtocol =
|
|
||||||
/^(https?|h2c):\/\//.test(input);
|
|
||||||
const hasPort = /:\d+(?:\/|$)/.test(input);
|
|
||||||
|
|
||||||
if (hasProtocol || hasPort) {
|
|
||||||
const parsed = parseHostTarget(input);
|
|
||||||
if (parsed) {
|
|
||||||
updateTarget(
|
|
||||||
row.original.targetId,
|
|
||||||
{
|
|
||||||
...row.original,
|
|
||||||
method: hasProtocol
|
|
||||||
? parsed.protocol
|
|
||||||
: row.original.method,
|
|
||||||
ip: parsed.host,
|
|
||||||
port: hasPort
|
|
||||||
? parsed.port
|
|
||||||
: row.original.port
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
updateTarget(
|
|
||||||
row.original.targetId,
|
|
||||||
{
|
|
||||||
...row.original,
|
|
||||||
ip: input
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
updateTarget(row.original.targetId, {
|
|
||||||
...row.original,
|
|
||||||
ip: input
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-center px-2 h-9">
|
|
||||||
{":"}
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
placeholder="Port"
|
|
||||||
defaultValue={
|
|
||||||
row.original.port === 0
|
|
||||||
? ""
|
|
||||||
: row.original.port
|
|
||||||
}
|
|
||||||
className="w-[75px] pl-0 border-none placeholder-gray-400"
|
|
||||||
onBlur={(e) => {
|
|
||||||
const value = parseInt(e.target.value, 10);
|
|
||||||
if (!isNaN(value) && value > 0) {
|
|
||||||
updateTarget(row.original.targetId, {
|
|
||||||
...row.original,
|
|
||||||
port: value
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateTarget(row.original.targetId, {
|
|
||||||
...row.original,
|
|
||||||
port: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
size: 400,
|
size: 400,
|
||||||
minSize: 350,
|
minSize: 350,
|
||||||
maxSize: 500
|
maxSize: 500
|
||||||
@@ -1186,7 +849,7 @@ export default function Page() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={noPathMatch}
|
disabled={noPathMatch}
|
||||||
className="w-full max-w-[200px]"
|
className="w-full max-w-50"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
{t("rewritePath")}
|
{t("rewritePath")}
|
||||||
@@ -1265,9 +928,17 @@ export default function Page() {
|
|||||||
actionsColumn
|
actionsColumn
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
};
|
}, [
|
||||||
|
isAdvancedMode,
|
||||||
const columns = getColumns();
|
isHttp,
|
||||||
|
sites,
|
||||||
|
updateTarget,
|
||||||
|
getDockerStateForSite,
|
||||||
|
refreshContainersForSite,
|
||||||
|
openHealthCheckDialog,
|
||||||
|
removeTarget,
|
||||||
|
t
|
||||||
|
]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: targets,
|
data: targets,
|
||||||
@@ -1649,9 +1320,6 @@ export default function Page() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
{/* <TableCaption> */}
|
|
||||||
{/* {t('targetNoOneDescription')} */}
|
|
||||||
{/* </TableCaption> */}
|
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|||||||
@@ -94,12 +94,6 @@ export default function DomainPicker({
|
|||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
console.log({
|
|
||||||
defaultFullDomain,
|
|
||||||
defaultSubdomain,
|
|
||||||
defaultDomainId
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data = [], isLoading: loadingDomains } = useQuery(
|
const { data = [], isLoading: loadingDomains } = useQuery(
|
||||||
orgQueries.domains({ orgId })
|
orgQueries.domains({ orgId })
|
||||||
);
|
);
|
||||||
@@ -369,9 +363,6 @@ export default function DomainPicker({
|
|||||||
setSelectedProvidedDomain(null);
|
setSelectedProvidedDomain(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log({
|
|
||||||
setSelectedBaseDomain: option
|
|
||||||
});
|
|
||||||
setSelectedBaseDomain(option);
|
setSelectedBaseDomain(option);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
@@ -442,9 +433,6 @@ export default function DomainPicker({
|
|||||||
0,
|
0,
|
||||||
providedDomainsShown
|
providedDomainsShown
|
||||||
);
|
);
|
||||||
console.log({
|
|
||||||
displayedProvidedOptions
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedDomainNamespaceId =
|
const selectedDomainNamespaceId =
|
||||||
selectedProvidedDomain?.domainNamespaceId ??
|
selectedProvidedDomain?.domainNamespaceId ??
|
||||||
|
|||||||
241
src/components/resource-target-address-item.tsx
Normal file
241
src/components/resource-target-address-item.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import type { DockerState } from "@app/lib/docker";
|
||||||
|
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||||
|
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||||
|
import type { ListSitesResponse } from "@server/routers/site";
|
||||||
|
import { type ListTargetsResponse } from "@server/routers/target";
|
||||||
|
import type { ArrayElement } from "@server/types/ArrayElement";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ContainersSelector } from "./ContainersSelector";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList
|
||||||
|
} from "./ui/command";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
|
||||||
|
|
||||||
|
type SiteWithUpdateAvailable = ListSitesResponse["sites"][number];
|
||||||
|
|
||||||
|
export type LocalTarget = Omit<
|
||||||
|
ArrayElement<ListTargetsResponse["targets"]> & {
|
||||||
|
new?: boolean;
|
||||||
|
updated?: boolean;
|
||||||
|
siteType: string | null;
|
||||||
|
},
|
||||||
|
"protocol"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ResourceTargetAddressItemProps = {
|
||||||
|
getDockerStateForSite: (siteId: number) => DockerState;
|
||||||
|
updateTarget: (targetId: number, data: Partial<LocalTarget>) => void;
|
||||||
|
sites: SiteWithUpdateAvailable[];
|
||||||
|
proxyTarget: LocalTarget;
|
||||||
|
isHttp: boolean;
|
||||||
|
refreshContainersForSite: (siteId: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResourceTargetAddressItem({
|
||||||
|
sites,
|
||||||
|
getDockerStateForSite,
|
||||||
|
updateTarget,
|
||||||
|
proxyTarget,
|
||||||
|
isHttp,
|
||||||
|
refreshContainersForSite
|
||||||
|
}: ResourceTargetAddressItemProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const selectedSite = sites.find(
|
||||||
|
(site) => site.siteId === proxyTarget.siteId
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleContainerSelectForTarget = (
|
||||||
|
hostname: string,
|
||||||
|
port?: number
|
||||||
|
) => {
|
||||||
|
updateTarget(proxyTarget.targetId, {
|
||||||
|
...proxyTarget,
|
||||||
|
ip: hostname,
|
||||||
|
...(port && { port: port })
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center w-full" key={proxyTarget.targetId}>
|
||||||
|
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
|
||||||
|
{selectedSite &&
|
||||||
|
selectedSite.type === "newt" &&
|
||||||
|
(() => {
|
||||||
|
const dockerState = getDockerStateForSite(
|
||||||
|
selectedSite.siteId
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<ContainersSelector
|
||||||
|
site={selectedSite}
|
||||||
|
containers={dockerState.containers}
|
||||||
|
isAvailable={dockerState.isAvailable}
|
||||||
|
onContainerSelect={
|
||||||
|
handleContainerSelectForTarget
|
||||||
|
}
|
||||||
|
onRefresh={() =>
|
||||||
|
refreshContainersForSite(
|
||||||
|
selectedSite.siteId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
"w-45 justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
|
||||||
|
"rounded-l-md rounded-r-xs",
|
||||||
|
!proxyTarget.siteId && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-37.5">
|
||||||
|
{proxyTarget.siteId
|
||||||
|
? selectedSite?.name
|
||||||
|
: t("siteSelect")}
|
||||||
|
</span>
|
||||||
|
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0 w-45">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={t("siteSearch")} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{sites.map((site) => (
|
||||||
|
<CommandItem
|
||||||
|
key={site.siteId}
|
||||||
|
value={`${site.siteId}:${site.name}`}
|
||||||
|
onSelect={() =>
|
||||||
|
updateTarget(
|
||||||
|
proxyTarget.targetId,
|
||||||
|
{
|
||||||
|
siteId: site.siteId
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
site.siteId ===
|
||||||
|
proxyTarget.siteId
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{site.name}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{isHttp && (
|
||||||
|
<Select
|
||||||
|
defaultValue={proxyTarget.method ?? "http"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateTarget(proxyTarget.targetId, {
|
||||||
|
...proxyTarget,
|
||||||
|
method: value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 px-2 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-xs">
|
||||||
|
{proxyTarget.method || "http"}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="http">http</SelectItem>
|
||||||
|
<SelectItem value="https">https</SelectItem>
|
||||||
|
<SelectItem value="h2c">h2c</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isHttp && (
|
||||||
|
<div className="flex items-center justify-center px-2 h-9">
|
||||||
|
{"://"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
defaultValue={proxyTarget.ip}
|
||||||
|
placeholder="Host"
|
||||||
|
className="flex-1 min-w-30 px-2 border-none placeholder-gray-400 rounded-xs"
|
||||||
|
onBlur={(e) => {
|
||||||
|
const input = e.target.value.trim();
|
||||||
|
const hasProtocol = /^(https?|h2c):\/\//.test(input);
|
||||||
|
const hasPort = /:\d+(?:\/|$)/.test(input);
|
||||||
|
|
||||||
|
if (hasProtocol || hasPort) {
|
||||||
|
const parsed = parseHostTarget(input);
|
||||||
|
if (parsed) {
|
||||||
|
updateTarget(proxyTarget.targetId, {
|
||||||
|
...proxyTarget,
|
||||||
|
method: hasProtocol
|
||||||
|
? parsed.protocol
|
||||||
|
: proxyTarget.method,
|
||||||
|
ip: parsed.host,
|
||||||
|
port: hasPort
|
||||||
|
? parsed.port
|
||||||
|
: proxyTarget.port
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateTarget(proxyTarget.targetId, {
|
||||||
|
...proxyTarget,
|
||||||
|
ip: input
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateTarget(proxyTarget.targetId, {
|
||||||
|
...proxyTarget,
|
||||||
|
ip: input
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center px-2 h-9">
|
||||||
|
{":"}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="Port"
|
||||||
|
defaultValue={
|
||||||
|
proxyTarget.port === 0 ? "" : proxyTarget.port
|
||||||
|
}
|
||||||
|
className="w-18.75 px-2 border-none placeholder-gray-400 rounded-l-xs"
|
||||||
|
onBlur={(e) => {
|
||||||
|
const value = parseInt(e.target.value, 10);
|
||||||
|
if (!isNaN(value) && value > 0) {
|
||||||
|
updateTarget(proxyTarget.targetId, {
|
||||||
|
...proxyTarget,
|
||||||
|
port: value
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateTarget(proxyTarget.targetId, {
|
||||||
|
...proxyTarget,
|
||||||
|
port: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,8 +44,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50",
|
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
"focus-visible:outline-none focus-visible:border-ring focus-visible:ring-offset-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ function SelectTrigger({
|
|||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full",
|
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0",
|
||||||
|
// "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -60,7 +62,7 @@ function SelectContent({
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-sm",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-sm",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className
|
||||||
@@ -73,7 +75,7 @@ function SelectContent({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Reference in New Issue
Block a user