mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-13 00:16:39 +00:00
add site targets, client resources, and auto login
This commit is contained in:
@@ -43,35 +43,30 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Search, RefreshCw, Filter, Columns } from "lucide-react";
|
||||
import { GetSiteResponse, Container } from "@server/routers/site";
|
||||
import { useDockerSocket } from "@app/hooks/useDockerSocket";
|
||||
import { Container } from "@server/routers/site";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// Type definitions based on the JSON structure
|
||||
import { FaDocker } from "react-icons/fa";
|
||||
|
||||
interface ContainerSelectorProps {
|
||||
site: GetSiteResponse;
|
||||
site: { siteId: number; name: string; type: string };
|
||||
containers: Container[];
|
||||
isAvailable: boolean;
|
||||
onContainerSelect?: (hostname: string, port?: number) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export const ContainersSelector: FC<ContainerSelectorProps> = ({
|
||||
site,
|
||||
onContainerSelect
|
||||
containers,
|
||||
isAvailable,
|
||||
onContainerSelect,
|
||||
onRefresh
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const { isAvailable, containers, fetchContainers } = useDockerSocket(site);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("DockerSocket isAvailable:", isAvailable);
|
||||
if (isAvailable) {
|
||||
fetchContainers();
|
||||
}
|
||||
}, [isAvailable]);
|
||||
|
||||
if (!site || !isAvailable) {
|
||||
if (!site || !isAvailable || site.type !== "newt") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -84,13 +79,14 @@ export const ContainersSelector: FC<ContainerSelectorProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
<Button
|
||||
type="button"
|
||||
className="text-sm text-primary hover:underline cursor-pointer"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(true)}
|
||||
title={t("viewDockerContainers")}
|
||||
>
|
||||
{t("viewDockerContainers")}
|
||||
</a>
|
||||
<FaDocker size={15} />
|
||||
</Button>
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-[75vw] max-h-[75vh] flex flex-col">
|
||||
<CredenzaHeader>
|
||||
@@ -106,7 +102,7 @@ export const ContainersSelector: FC<ContainerSelectorProps> = ({
|
||||
<DockerContainersTable
|
||||
containers={containers}
|
||||
onContainerSelect={handleContainerSelect}
|
||||
onRefresh={() => fetchContainers()}
|
||||
onRefresh={onRefresh || (() => {})}
|
||||
/>
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
@@ -263,7 +259,9 @@ const DockerContainersTable: FC<{
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs hover:bg-muted"
|
||||
>
|
||||
{t("containerLabelsCount", { count: labelEntries.length })}
|
||||
{t("containerLabelsCount", {
|
||||
count: labelEntries.length
|
||||
})}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start">
|
||||
@@ -279,7 +277,10 @@ const DockerContainersTable: FC<{
|
||||
{key}
|
||||
</div>
|
||||
<div className="font-mono text-muted-foreground pl-2 break-all">
|
||||
{value || t("containerLabelEmpty")}
|
||||
{value ||
|
||||
t(
|
||||
"containerLabelEmpty"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -316,7 +317,9 @@ const DockerContainersTable: FC<{
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="link" size="sm">
|
||||
{t("containerPortsMore", { count: ports.length - 2 })}
|
||||
{t("containerPortsMore", {
|
||||
count: ports.length - 2
|
||||
})}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -356,7 +359,9 @@ const DockerContainersTable: FC<{
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onContainerSelect(row.original, ports[0])}
|
||||
onClick={() =>
|
||||
onContainerSelect(row.original, ports[0])
|
||||
}
|
||||
disabled={row.original.state !== "running"}
|
||||
>
|
||||
{t("select")}
|
||||
@@ -415,9 +420,7 @@ const DockerContainersTable: FC<{
|
||||
hideStoppedContainers) &&
|
||||
containers.length > 0 ? (
|
||||
<>
|
||||
<p>
|
||||
{t("noContainersMatchingFilters")}
|
||||
</p>
|
||||
<p>{t("noContainersMatchingFilters")}</p>
|
||||
<div className="space-x-2">
|
||||
{hideContainersWithoutPorts && (
|
||||
<Button
|
||||
@@ -446,9 +449,7 @@ const DockerContainersTable: FC<{
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
{t("noContainersFound")}
|
||||
</p>
|
||||
<p>{t("noContainersFound")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -463,7 +464,9 @@ const DockerContainersTable: FC<{
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("searchContainersPlaceholder", { count: initialFilters.length })}
|
||||
placeholder={t("searchContainersPlaceholder", {
|
||||
count: initialFilters.length
|
||||
})}
|
||||
value={searchInput}
|
||||
onChange={(event) =>
|
||||
setSearchInput(event.target.value)
|
||||
@@ -473,7 +476,10 @@ const DockerContainersTable: FC<{
|
||||
{searchInput &&
|
||||
table.getFilteredRowModel().rows.length > 0 && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
{t("searchResultsCount", { count: table.getFilteredRowModel().rows.length })}
|
||||
{t("searchResultsCount", {
|
||||
count: table.getFilteredRowModel().rows
|
||||
.length
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -644,7 +650,9 @@ const DockerContainersTable: FC<{
|
||||
{t("searching")}
|
||||
</div>
|
||||
) : (
|
||||
t("noContainersFoundMatching", { filter: globalFilter })
|
||||
t("noContainersFoundMatching", {
|
||||
filter: globalFilter
|
||||
})
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
422
src/components/CreateInternalResourceDialog.tsx
Normal file
422
src/components/CreateInternalResourceDialog.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
type Site = ListSitesResponse["sites"][0];
|
||||
|
||||
type CreateInternalResourceDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
orgId: string;
|
||||
sites: Site[];
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export default function CreateInternalResourceDialog({
|
||||
open,
|
||||
setOpen,
|
||||
orgId,
|
||||
sites,
|
||||
onSuccess
|
||||
}: CreateInternalResourceDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, t("createInternalResourceDialogNameRequired"))
|
||||
.max(255, t("createInternalResourceDialogNameMaxLength")),
|
||||
siteId: z.number().int().positive(t("createInternalResourceDialogPleaseSelectSite")),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
proxyPort: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.min(1, t("createInternalResourceDialogProxyPortMin"))
|
||||
.max(65535, t("createInternalResourceDialogProxyPortMax")),
|
||||
destinationIp: z.string().ip(t("createInternalResourceDialogInvalidIPAddressFormat")),
|
||||
destinationPort: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.min(1, t("createInternalResourceDialogDestinationPortMin"))
|
||||
.max(65535, t("createInternalResourceDialogDestinationPortMax"))
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const availableSites = sites.filter(
|
||||
(site) => site.type === "newt" && site.subnet
|
||||
);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
siteId: availableSites[0]?.siteId || 0,
|
||||
protocol: "tcp",
|
||||
proxyPort: undefined,
|
||||
destinationIp: "",
|
||||
destinationPort: undefined
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open && availableSites.length > 0) {
|
||||
form.reset({
|
||||
name: "",
|
||||
siteId: availableSites[0].siteId,
|
||||
protocol: "tcp",
|
||||
proxyPort: undefined,
|
||||
destinationIp: "",
|
||||
destinationPort: undefined
|
||||
});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async (data: FormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await api.put(`/org/${orgId}/site/${data.siteId}/resource`, {
|
||||
name: data.name,
|
||||
protocol: data.protocol,
|
||||
proxyPort: data.proxyPort,
|
||||
destinationIp: data.destinationIp,
|
||||
destinationPort: data.destinationPort,
|
||||
enabled: true
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("createInternalResourceDialogSuccess"),
|
||||
description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"),
|
||||
variant: "default"
|
||||
});
|
||||
|
||||
onSuccess?.();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Error creating internal resource:", error);
|
||||
toast({
|
||||
title: t("createInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("createInternalResourceDialogFailedToCreateInternalResource")
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (availableSites.length === 0) {
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-md">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("createInternalResourceDialogNoSitesAvailable")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("createInternalResourceDialogNoSitesAvailableDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaFooter>
|
||||
<Button onClick={() => setOpen(false)}>{t("createInternalResourceDialogClose")}</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-2xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("createInternalResourceDialogCreateClientResourceDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-6"
|
||||
id="create-internal-resource-form"
|
||||
>
|
||||
{/* Resource Properties Form */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("createInternalResourceDialogResourceProperties")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("createInternalResourceDialogName")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="siteId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>{t("createInternalResourceDialogSite")}</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? availableSites.find(
|
||||
(site) => site.siteId === field.value
|
||||
)?.name
|
||||
: t("createInternalResourceDialogSelectSite")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("createInternalResourceDialogSearchSites")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("createInternalResourceDialogNoSitesFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableSites.map((site) => (
|
||||
<CommandItem
|
||||
key={site.siteId}
|
||||
value={site.name}
|
||||
onSelect={() => {
|
||||
field.onChange(site.siteId);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
field.value === site.siteId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{site.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="protocol"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("createInternalResourceDialogProtocol")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">
|
||||
{t("createInternalResourceDialogTcp")}
|
||||
</SelectItem>
|
||||
<SelectItem value="udp">
|
||||
{t("createInternalResourceDialogUdp")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="proxyPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("createInternalResourceDialogSitePort")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value || ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === "" ? undefined : parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("createInternalResourceDialogSitePortDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Configuration Form */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("createInternalResourceDialogTargetConfiguration")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationIp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("createInternalResourceDialogDestinationIP")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("createInternalResourceDialogDestinationIPDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("createInternalResourceDialogDestinationPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value || ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === "" ? undefined : parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("createInternalResourceDialogDestinationPortDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-internal-resource-form"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCreateResource")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
276
src/components/EditInternalResourceDialog.tsx
Normal file
276
src/components/EditInternalResourceDialog.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
|
||||
type InternalResourceData = {
|
||||
id: number;
|
||||
name: string;
|
||||
orgId: string;
|
||||
siteName: string;
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
siteId: number;
|
||||
destinationIp?: string;
|
||||
destinationPort?: number;
|
||||
};
|
||||
|
||||
type EditInternalResourceDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
resource: InternalResourceData;
|
||||
orgId: string;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export default function EditInternalResourceDialog({
|
||||
open,
|
||||
setOpen,
|
||||
resource,
|
||||
orgId,
|
||||
onSuccess
|
||||
}: EditInternalResourceDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, t("editInternalResourceDialogNameRequired")).max(255, t("editInternalResourceDialogNameMaxLength")),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
proxyPort: z.number().int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")),
|
||||
destinationIp: z.string().ip(t("editInternalResourceDialogInvalidIPAddressFormat")),
|
||||
destinationPort: z.number().int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax"))
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: resource.name,
|
||||
protocol: resource.protocol as "tcp" | "udp",
|
||||
proxyPort: resource.proxyPort || undefined,
|
||||
destinationIp: resource.destinationIp || "",
|
||||
destinationPort: resource.destinationPort || undefined
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({
|
||||
name: resource.name,
|
||||
protocol: resource.protocol as "tcp" | "udp",
|
||||
proxyPort: resource.proxyPort || undefined,
|
||||
destinationIp: resource.destinationIp || "",
|
||||
destinationPort: resource.destinationPort || undefined
|
||||
});
|
||||
}
|
||||
}, [open, resource, form]);
|
||||
|
||||
const handleSubmit = async (data: FormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Update the site resource
|
||||
await api.post(`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`, {
|
||||
name: data.name,
|
||||
protocol: data.protocol,
|
||||
proxyPort: data.proxyPort,
|
||||
destinationIp: data.destinationIp,
|
||||
destinationPort: data.destinationPort
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("editInternalResourceDialogSuccess"),
|
||||
description: t("editInternalResourceDialogInternalResourceUpdatedSuccessfully"),
|
||||
variant: "default"
|
||||
});
|
||||
|
||||
onSuccess?.();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Error updating internal resource:", error);
|
||||
toast({
|
||||
title: t("editInternalResourceDialogError"),
|
||||
description: formatAxiosError(error, t("editInternalResourceDialogFailedToUpdateInternalResource")),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-2xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("editInternalResourceDialogEditClientResource")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("editInternalResourceDialogUpdateResourceProperties", { resourceName: resource.name })}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6" id="edit-internal-resource-form">
|
||||
{/* Resource Properties Form */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogResourceProperties")}</h3>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogName")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="protocol"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogProtocol")}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">TCP</SelectItem>
|
||||
<SelectItem value="udp">UDP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="proxyPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogSitePort")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Configuration Form */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogTargetConfiguration")}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationIp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogDestinationIP")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogDestinationPort")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("editInternalResourceDialogCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="edit-internal-resource-form"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("editInternalResourceDialogSaveResource")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export function LayoutSidebar({
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 pt-1">
|
||||
<div className="px-2 pt-1">
|
||||
{!isAdminPage && user.serverAdmin && (
|
||||
<div className="pb-4">
|
||||
<Link
|
||||
|
||||
@@ -54,7 +54,7 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"shadow-xs",
|
||||
"shadow-2xs",
|
||||
isCollapsed ? "w-8 h-8" : "w-full h-12 px-3 py-4"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -150,7 +150,7 @@ export function SidebarNav({
|
||||
{section.heading}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 mt-1 pl-2">
|
||||
{section.items.map((item) => {
|
||||
const hydratedHref = hydrateHref(item.href);
|
||||
const isActive = pathname.startsWith(hydratedHref);
|
||||
|
||||
@@ -9,7 +9,7 @@ const alertVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card border text-foreground",
|
||||
neutral: "bg-card border text-foreground",
|
||||
neutral: "bg-card bg-muted border text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
success:
|
||||
|
||||
@@ -30,7 +30,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo } from "react";
|
||||
|
||||
type TabFilter = {
|
||||
id: string;
|
||||
label: string;
|
||||
filterFn: (row: any) => boolean;
|
||||
};
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -46,6 +54,8 @@ type DataTableProps<TData, TValue> = {
|
||||
id: string;
|
||||
desc: boolean;
|
||||
};
|
||||
tabs?: TabFilter[];
|
||||
defaultTab?: string;
|
||||
};
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
@@ -58,17 +68,36 @@ export function DataTable<TData, TValue>({
|
||||
isRefreshing,
|
||||
searchPlaceholder = "Search...",
|
||||
searchColumn = "name",
|
||||
defaultSort
|
||||
defaultSort,
|
||||
tabs,
|
||||
defaultTab
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>(
|
||||
defaultSort ? [defaultSort] : []
|
||||
);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState<any>([]);
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
defaultTab || tabs?.[0]?.id || ""
|
||||
);
|
||||
const t = useTranslations();
|
||||
|
||||
// Apply tab filter to data
|
||||
const filteredData = useMemo(() => {
|
||||
if (!tabs || activeTab === "") {
|
||||
return data;
|
||||
}
|
||||
|
||||
const activeTabFilter = tabs.find((tab) => tab.id === activeTab);
|
||||
if (!activeTabFilter) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return data.filter(activeTabFilter.filterFn);
|
||||
}, [data, tabs, activeTab]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
data: filteredData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
@@ -90,20 +119,49 @@ export function DataTable<TData, TValue>({
|
||||
}
|
||||
});
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
// Reset to first page when changing tabs
|
||||
table.setPageIndex(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
|
||||
<div className="flex items-center w-full sm:max-w-sm sm:mr-2 relative">
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={globalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
table.setGlobalFilter(String(e.target.value))
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={globalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
table.setGlobalFilter(
|
||||
String(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
{tabs && tabs.length > 0 && (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
>
|
||||
{tab.label} (
|
||||
{data.filter(tab.filterFn).length})
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
{onRefresh && (
|
||||
|
||||
@@ -55,7 +55,7 @@ function InputOTPSlot({
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-2xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -16,7 +16,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
type={showPassword ? "text" : "password"}
|
||||
data-slot="input"
|
||||
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 inset-shadow-2xs 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 shadow-2xs",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
@@ -43,7 +43,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
type={type}
|
||||
data-slot="input"
|
||||
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 inset-shadow-2xs 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 shadow-2xs",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
|
||||
@@ -36,7 +36,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
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 shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] 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 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 shadow-2xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground cursor-pointer",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user