mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-10 05:54:58 +02:00
render targets in table, update targets
This commit is contained in:
parent
93ea7e4620
commit
cf3cf4d827
16 changed files with 744 additions and 472 deletions
|
@ -1,7 +1,7 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resources, sites, targets } from "@server/db/schema";
|
import { resources, sites, Target, targets } from "@server/db/schema";
|
||||||
import response from "@server/utils/response";
|
import response from "@server/utils/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
|
@ -23,6 +23,8 @@ const createTargetSchema = z.object({
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type CreateTargetResponse = Target;
|
||||||
|
|
||||||
export async function createTarget(
|
export async function createTarget(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
|
@ -126,7 +128,7 @@ export async function createTarget(
|
||||||
allowedIps: targetIps.flat(),
|
allowedIps: targetIps.flat(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response<CreateTargetResponse>(res, {
|
||||||
data: newTarget[0],
|
data: newTarget[0],
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|
|
@ -6,7 +6,7 @@ type OrgPageProps = {
|
||||||
|
|
||||||
export default async function SettingsPage(props: OrgPageProps) {
|
export default async function SettingsPage(props: OrgPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
redirect(`/${params.orgId}/settings/sites`);
|
redirect(`/${params.orgId}/settings/resources`);
|
||||||
|
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,479 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, use } from "react";
|
||||||
|
import { Trash2, Server, Globe, Cpu } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import api from "@app/api";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { ListTargetsResponse } from "@server/routers/target/listTargets";
|
||||||
|
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 { CreateTargetResponse, updateTarget } from "@server/routers/target";
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
flexRender,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@app/components/ui/table";
|
||||||
|
import { useToast } from "@app/hooks/useToast";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { Target } from "@server/db/schema";
|
||||||
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
|
|
||||||
|
const addTargetSchema = z.object({
|
||||||
|
ip: z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
|
||||||
|
"Invalid IP address format"
|
||||||
|
),
|
||||||
|
method: z.string(),
|
||||||
|
port: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Number(val)), {
|
||||||
|
message: "Port must be a number",
|
||||||
|
})
|
||||||
|
.transform((val) => Number(val)),
|
||||||
|
protocol: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AddTargetFormValues = z.infer<typeof addTargetSchema>;
|
||||||
|
|
||||||
|
export default function ReverseProxyTargets(props: {
|
||||||
|
params: Promise<{ resourceId: number }>;
|
||||||
|
}) {
|
||||||
|
const params = use(props.params);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { resource, updateResource } = useResourceContext();
|
||||||
|
|
||||||
|
const [targets, setTargets] = useState<ListTargetsResponse["targets"]>([]);
|
||||||
|
|
||||||
|
const addTargetForm = useForm({
|
||||||
|
resolver: zodResolver(addTargetSchema),
|
||||||
|
defaultValues: {
|
||||||
|
ip: "",
|
||||||
|
method: "http",
|
||||||
|
port: "80",
|
||||||
|
protocol: "TCP",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSites = async () => {
|
||||||
|
const res = await api
|
||||||
|
.get<AxiosResponse<ListTargetsResponse>>(
|
||||||
|
`/resource/${params.resourceId}/targets`
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to fetch targets",
|
||||||
|
description:
|
||||||
|
err.message ||
|
||||||
|
"An error occurred while fetching targets",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
setTargets(res.data.data.targets);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchSites();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function addTarget(data: AddTargetFormValues) {
|
||||||
|
const res = await api
|
||||||
|
.put<AxiosResponse<CreateTargetResponse>>(
|
||||||
|
`/resource/${params.resourceId}/target`,
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
resourceId: undefined,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to add target",
|
||||||
|
description:
|
||||||
|
err.message || "An error occurred while adding target",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 201) {
|
||||||
|
setTargets([...targets, res.data.data]);
|
||||||
|
addTargetForm.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTarget = (targetId: number) => {
|
||||||
|
api.delete(`/target/${targetId}`)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
setTargets(
|
||||||
|
targets.filter((target) => target.targetId !== targetId)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function updateTarget(targetId: number, data: Partial<Target>) {
|
||||||
|
setTargets(
|
||||||
|
targets.map((target) =>
|
||||||
|
target.targetId === targetId ? { ...target, ...data } : target
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await api.post(`/target/${targetId}`, data).catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to update target",
|
||||||
|
description:
|
||||||
|
err.message || "An error occurred while updating target",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
toast({
|
||||||
|
title: "Target updated",
|
||||||
|
description: "The target has been updated successfully",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<ListTargetsResponse["targets"][0]>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "ip",
|
||||||
|
header: "IP Address",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Input
|
||||||
|
defaultValue={row.original.ip}
|
||||||
|
onBlur={(e) =>
|
||||||
|
updateTarget(row.original.targetId, {
|
||||||
|
ip: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "port",
|
||||||
|
header: "Port",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
defaultValue={row.original.port}
|
||||||
|
onBlur={(e) =>
|
||||||
|
updateTarget(row.original.targetId, {
|
||||||
|
port: parseInt(e.target.value, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "method",
|
||||||
|
header: "Method",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Select
|
||||||
|
defaultValue={row.original.method}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateTarget(row.original.targetId, { method: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>{row.original.method}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="http">http</SelectItem>
|
||||||
|
<SelectItem value="https">https</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "protocol",
|
||||||
|
header: "Protocol",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Select
|
||||||
|
defaultValue={row.original.protocol!}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateTarget(row.original.targetId, { protocol: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>{row.original.protocol}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="TCP">TCP</SelectItem>
|
||||||
|
<SelectItem value="UDP">UDP</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "enabled",
|
||||||
|
header: "Enabled",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={row.original.enabled}
|
||||||
|
onCheckedChange={(val) =>
|
||||||
|
updateTarget(row.original.targetId, { enabled: val })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => removeTarget(row.original.targetId)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: targets,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* <div className="lg:max-w-2xl"> */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title="SSL"
|
||||||
|
description="Setup SSL to secure your connections with LetsEncrypt certificates"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch id="ssl-toggle" />
|
||||||
|
<Label htmlFor="ssl-toggle">Enable SSL (https)</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title="Targets"
|
||||||
|
description="Setup targets to route traffic to your services"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form {...addTargetForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={addTargetForm.handleSubmit(
|
||||||
|
addTarget as any
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={addTargetForm.control}
|
||||||
|
name="ip"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>IP Address</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input id="ip" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Enter the IP address of the
|
||||||
|
target
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={addTargetForm.control}
|
||||||
|
name="method"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Method</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
addTargetForm.setValue(
|
||||||
|
"method",
|
||||||
|
value
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="method">
|
||||||
|
<SelectValue placeholder="Select method" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="http">
|
||||||
|
HTTP
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="https">
|
||||||
|
HTTPS
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Choose the method for the target
|
||||||
|
connection
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={addTargetForm.control}
|
||||||
|
name="port"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Port</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
id="port"
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Specify the port number for the
|
||||||
|
target
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={addTargetForm.control}
|
||||||
|
name="protocol"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Protocol</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
addTargetForm.setValue(
|
||||||
|
"protocol",
|
||||||
|
value
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="protocol">
|
||||||
|
<SelectValue placeholder="Select protocol" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="UDP">
|
||||||
|
UDP
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="TCP">
|
||||||
|
TCP
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Select the protocol used by the
|
||||||
|
target
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">Add Target</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border mt-4">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef
|
||||||
|
.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
No results.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -97,112 +97,119 @@ export default function GeneralForm() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-0.5 select-none mb-6">
|
<div className="lg:max-w-2xl">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">
|
<div className="space-y-0.5 select-none mb-6">
|
||||||
General Settings
|
<h2 className="text-2xl font-bold tracking-tight">
|
||||||
</h2>
|
General Settings
|
||||||
<p className="text-muted-foreground">
|
</h2>
|
||||||
Configure the general settings for this resource
|
<p className="text-muted-foreground">
|
||||||
</p>
|
Configure the general settings for this resource
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is the display name of the resource.
|
This is the display name of the
|
||||||
</FormDescription>
|
resource.
|
||||||
<FormMessage />
|
</FormDescription>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
<FormField
|
/>
|
||||||
control={form.control}
|
<FormField
|
||||||
name="siteId"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="siteId"
|
||||||
<FormItem className="flex flex-col">
|
render={({ field }) => (
|
||||||
<FormLabel>Site</FormLabel>
|
<FormItem className="flex flex-col">
|
||||||
<Popover>
|
<FormLabel>Site</FormLabel>
|
||||||
<PopoverTrigger asChild>
|
<Popover>
|
||||||
<FormControl>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<FormControl>
|
||||||
variant="outline"
|
<Button
|
||||||
role="combobox"
|
variant="outline"
|
||||||
className={cn(
|
role="combobox"
|
||||||
"w-[350px] justify-between",
|
className={cn(
|
||||||
!field.value &&
|
"w-[350px] justify-between",
|
||||||
"text-muted-foreground"
|
!field.value &&
|
||||||
)}
|
"text-muted-foreground"
|
||||||
>
|
)}
|
||||||
{field.value
|
>
|
||||||
? sites.find(
|
{field.value
|
||||||
(site) =>
|
? sites.find(
|
||||||
site.siteId ===
|
(site) =>
|
||||||
field.value
|
site.siteId ===
|
||||||
)?.name
|
field.value
|
||||||
: "Select site"}
|
)?.name
|
||||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
: "Select site"}
|
||||||
</Button>
|
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</FormControl>
|
</Button>
|
||||||
</PopoverTrigger>
|
</FormControl>
|
||||||
<PopoverContent className="w-[350px] p-0">
|
</PopoverTrigger>
|
||||||
<Command>
|
<PopoverContent className="w-[350px] p-0">
|
||||||
<CommandInput placeholder="Search site..." />
|
<Command>
|
||||||
<CommandList>
|
<CommandInput placeholder="Search site..." />
|
||||||
<CommandEmpty>
|
<CommandList>
|
||||||
No site found.
|
<CommandEmpty>
|
||||||
</CommandEmpty>
|
No site found.
|
||||||
<CommandGroup>
|
</CommandEmpty>
|
||||||
{sites.map((site) => (
|
<CommandGroup>
|
||||||
<CommandItem
|
{sites.map((site) => (
|
||||||
value={site.name}
|
<CommandItem
|
||||||
key={site.siteId}
|
value={
|
||||||
onSelect={() => {
|
site.name
|
||||||
form.setValue(
|
}
|
||||||
"siteId",
|
key={
|
||||||
site.siteId
|
site.siteId
|
||||||
);
|
}
|
||||||
}}
|
onSelect={() => {
|
||||||
>
|
form.setValue(
|
||||||
<CheckIcon
|
"siteId",
|
||||||
className={cn(
|
site.siteId
|
||||||
"mr-2 h-4 w-4",
|
);
|
||||||
site.siteId ===
|
}}
|
||||||
field.value
|
>
|
||||||
? "opacity-100"
|
<CheckIcon
|
||||||
: "opacity-0"
|
className={cn(
|
||||||
)}
|
"mr-2 h-4 w-4",
|
||||||
/>
|
site.siteId ===
|
||||||
{site.name}
|
field.value
|
||||||
</CommandItem>
|
? "opacity-100"
|
||||||
))}
|
: "opacity-0"
|
||||||
</CommandGroup>
|
)}
|
||||||
</CommandList>
|
/>
|
||||||
</Command>
|
{site.name}
|
||||||
</PopoverContent>
|
</CommandItem>
|
||||||
</Popover>
|
))}
|
||||||
<FormDescription>
|
</CommandGroup>
|
||||||
This is the site that will be used in the
|
</CommandList>
|
||||||
dashboard.
|
</Command>
|
||||||
</FormDescription>
|
</PopoverContent>
|
||||||
<FormMessage />
|
</Popover>
|
||||||
</FormItem>
|
<FormDescription>
|
||||||
)}
|
This is the site that will be used in
|
||||||
/>
|
the dashboard.
|
||||||
<Button type="submit">Update Resource</Button>
|
</FormDescription>
|
||||||
</form>
|
<FormMessage />
|
||||||
</Form>
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit">Update Resource</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,8 +36,12 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||||
href: `/{orgId}/settings/resources/{resourceId}/general`,
|
href: `/{orgId}/settings/resources/{resourceId}/general`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Targets",
|
title: "Connectivity",
|
||||||
href: `/{orgId}/settings/resources/{resourceId}/targets`,
|
href: `/{orgId}/settings/resources/{resourceId}/connectivity`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Authentication",
|
||||||
|
href: `/{orgId}/settings/resources/{resourceId}/authentication`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -66,7 +70,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||||
<ResourceProvider resource={resource}>
|
<ResourceProvider resource={resource}>
|
||||||
<SidebarSettings
|
<SidebarSettings
|
||||||
sidebarNavItems={sidebarNavItems}
|
sidebarNavItems={sidebarNavItems}
|
||||||
limitWidth={true}
|
limitWidth={false}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</SidebarSettings>
|
</SidebarSettings>
|
||||||
|
|
|
@ -5,6 +5,6 @@ export default async function ResourcePage(props: {
|
||||||
}) {
|
}) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
redirect(
|
redirect(
|
||||||
`/${params.orgId}/settings/resources/${params.resourceId}/general`
|
`/${params.orgId}/settings/resources/${params.resourceId}/connectivity`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,275 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState, use } from "react";
|
|
||||||
import { PlusCircle, Trash2, Server, Globe, Cpu } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import api from "@app/api";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { ListTargetsResponse } from "@server/routers/target/listTargets";
|
|
||||||
|
|
||||||
const isValidIPAddress = (ip: string) => {
|
|
||||||
const ipv4Regex =
|
|
||||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
|
||||||
return ipv4Regex.test(ip);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ReverseProxyTargets(props: {
|
|
||||||
params: Promise<{ resourceId: number }>;
|
|
||||||
}) {
|
|
||||||
const params = use(props.params);
|
|
||||||
const [targets, setTargets] = useState<ListTargetsResponse["targets"]>([]);
|
|
||||||
const [nextId, setNextId] = useState(1);
|
|
||||||
const [ipError, setIpError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const fetchSites = async () => {
|
|
||||||
const res = await api.get<AxiosResponse<ListTargetsResponse>>(
|
|
||||||
`/resource/${params.resourceId}/targets`
|
|
||||||
);
|
|
||||||
setTargets(res.data.data.targets);
|
|
||||||
};
|
|
||||||
fetchSites();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [newTarget, setNewTarget] = useState({
|
|
||||||
resourceId: params.resourceId,
|
|
||||||
ip: "",
|
|
||||||
method: "http",
|
|
||||||
port: 80,
|
|
||||||
protocol: "TCP",
|
|
||||||
});
|
|
||||||
|
|
||||||
const addTarget = () => {
|
|
||||||
if (!isValidIPAddress(newTarget.ip)) {
|
|
||||||
setIpError("Invalid IP address format");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIpError("");
|
|
||||||
|
|
||||||
api.put(`/resource/${params.resourceId}/target`, {
|
|
||||||
...newTarget,
|
|
||||||
resourceId: undefined,
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
// console.log(res)
|
|
||||||
setTargets([
|
|
||||||
...targets,
|
|
||||||
{ ...newTarget, targetId: nextId, enabled: true },
|
|
||||||
]);
|
|
||||||
setNextId(nextId + 1);
|
|
||||||
setNewTarget({
|
|
||||||
resourceId: params.resourceId,
|
|
||||||
ip: "",
|
|
||||||
method: "GET",
|
|
||||||
port: 80,
|
|
||||||
protocol: "http",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTarget = (targetId: number) => {
|
|
||||||
api.delete(`/target/${targetId}`)
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
setTargets(
|
|
||||||
targets.filter((target) => target.targetId !== targetId)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleTarget = (targetId: number) => {
|
|
||||||
setTargets(
|
|
||||||
targets.map((target) =>
|
|
||||||
target.targetId === targetId
|
|
||||||
? { ...target, enabled: !target.enabled }
|
|
||||||
: target
|
|
||||||
)
|
|
||||||
);
|
|
||||||
api.post(`/target/${targetId}`, {
|
|
||||||
enabled: !targets.find((target) => target.targetId === targetId)
|
|
||||||
?.enabled,
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="space-y-0.5 select-none mb-6">
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Targets</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Setup the targets for the reverse proxy
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
addTarget();
|
|
||||||
}}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="ip">IP Address</Label>
|
|
||||||
<Input
|
|
||||||
id="ip"
|
|
||||||
value={newTarget.ip}
|
|
||||||
onChange={(e) => {
|
|
||||||
setNewTarget({
|
|
||||||
...newTarget,
|
|
||||||
ip: e.target.value,
|
|
||||||
});
|
|
||||||
setIpError("");
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{ipError && (
|
|
||||||
<p className="text-red-500 text-sm">{ipError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="method">Method</Label>
|
|
||||||
<Select
|
|
||||||
value={newTarget.method}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setNewTarget({ ...newTarget, method: value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="method">
|
|
||||||
<SelectValue placeholder="Select method" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="http">HTTP</SelectItem>
|
|
||||||
<SelectItem value="https">HTTPS</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="port">Port</Label>
|
|
||||||
<Input
|
|
||||||
id="port"
|
|
||||||
type="number"
|
|
||||||
value={newTarget.port}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewTarget({
|
|
||||||
...newTarget,
|
|
||||||
port: parseInt(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="protocol">Protocol</Label>
|
|
||||||
<Select
|
|
||||||
value={newTarget.protocol}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setNewTarget({ ...newTarget, protocol: value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="protocol">
|
|
||||||
<SelectValue placeholder="Select protocol" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="UDP">UDP</SelectItem>
|
|
||||||
<SelectItem value="TCP">TCP</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button type="submit">Add Target</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{targets.map((target, i) => (
|
|
||||||
<Card
|
|
||||||
key={i}
|
|
||||||
id={`target-${target.targetId}`}
|
|
||||||
className="w-full p-4"
|
|
||||||
>
|
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2 px-0 pt-0">
|
|
||||||
<CardTitle className="text-lg font-medium flex items-center">
|
|
||||||
<Server className="mr-2 h-5 w-5" />
|
|
||||||
Target {target.targetId}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex flex-col items-end space-y-2">
|
|
||||||
<Switch
|
|
||||||
checked={target.enabled}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
toggleTarget(target.targetId)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={() =>
|
|
||||||
removeTarget(target.targetId)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-0 py-2">
|
|
||||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Globe className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm">
|
|
||||||
{target.ip}:{target.port}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Cpu className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm">
|
|
||||||
{target.resourceId}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
target.enabled
|
|
||||||
? "default"
|
|
||||||
: "secondary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{target.method}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
target.enabled
|
|
||||||
? "default"
|
|
||||||
: "secondary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{target.protocol?.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import api from "@app/api";
|
import api from "@app/api";
|
||||||
|
@ -95,34 +95,56 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<>
|
||||||
<DropdownMenuTrigger asChild>
|
<div className="flex items-center justify-end">
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
<DropdownMenu>
|
||||||
<span className="sr-only">Open menu</span>
|
<DropdownMenuTrigger asChild>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
Open menu
|
||||||
|
</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link
|
||||||
|
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
||||||
|
>
|
||||||
|
View settings
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedResource(
|
||||||
|
resourceRow
|
||||||
|
);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="text-red-600 hover:text-red-800 hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant={"gray"}
|
||||||
|
className="ml-2"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(
|
||||||
|
`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Edit <ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</div>
|
||||||
<DropdownMenuContent align="end">
|
</>
|
||||||
<DropdownMenuItem>
|
|
||||||
<Link
|
|
||||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
|
||||||
>
|
|
||||||
View settings
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedResource(resourceRow);
|
|
||||||
setIsDeleteModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="text-red-600 hover:text-red-800 hover:underline cursor-pointer"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { useToast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { set, z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
Credenza,
|
Credenza,
|
||||||
CredenzaBody,
|
CredenzaBody,
|
||||||
|
@ -31,10 +31,15 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { PickSiteDefaultsResponse } from "@server/routers/site";
|
import { PickSiteDefaultsResponse } from "@server/routers/site";
|
||||||
import { generateKeypair } from "../[niceId]/components/wireguardConfig";
|
import { generateKeypair } from "../[niceId]/components/wireguardConfig";
|
||||||
import { cn } from "@app/lib/utils";
|
|
||||||
import { ChevronDownIcon } from "lucide-react";
|
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import { Checkbox } from "@app/components/ui/checkbox";
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@app/components/ui/select";
|
||||||
|
|
||||||
const method = [
|
const method = [
|
||||||
{ label: "Wireguard", value: "wg" },
|
{ label: "Wireguard", value: "wg" },
|
||||||
|
@ -213,28 +218,26 @@ sh get-docker.sh`;
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Method</FormLabel>
|
<FormLabel>Method</FormLabel>
|
||||||
<div className="relative w-max">
|
<FormControl>
|
||||||
<FormControl>
|
<Select
|
||||||
<select
|
value={field.value}
|
||||||
className={cn(
|
onValueChange={
|
||||||
buttonVariants({
|
field.onChange
|
||||||
variant:
|
}
|
||||||
"outline",
|
>
|
||||||
}),
|
<SelectTrigger>
|
||||||
"w-[200px] appearance-none font-normal"
|
<SelectValue placeholder="Select method" />
|
||||||
)}
|
</SelectTrigger>
|
||||||
{...field}
|
<SelectContent>
|
||||||
>
|
<SelectItem value="wg">
|
||||||
<option value="wg">
|
|
||||||
WireGuard
|
WireGuard
|
||||||
</option>
|
</SelectItem>
|
||||||
<option value="newt">
|
<SelectItem value="newt">
|
||||||
Newt
|
Newt
|
||||||
</option>
|
</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
</FormControl>
|
</Select>
|
||||||
<ChevronDownIcon className="absolute right-3 top-2.5 h-4 w-4 opacity-50" />
|
</FormControl>
|
||||||
</div>
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is how you will connect
|
This is how you will connect
|
||||||
your site to Fossorial.
|
your site to Fossorial.
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import api from "@app/api";
|
import api from "@app/api";
|
||||||
|
@ -104,34 +104,47 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||||
const siteRow = row.original;
|
const siteRow = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<div className="flex items-center justify-end">
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
<DropdownMenuTrigger asChild>
|
||||||
<span className="sr-only">Open menu</span>
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<span className="sr-only">Open menu</span>
|
||||||
</Button>
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</DropdownMenuTrigger>
|
</Button>
|
||||||
<DropdownMenuContent align="end">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuContent align="end">
|
||||||
<Link
|
<DropdownMenuItem>
|
||||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
<Link
|
||||||
>
|
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||||
View settings
|
>
|
||||||
</Link>
|
View settings
|
||||||
</DropdownMenuItem>
|
</Link>
|
||||||
<DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<button
|
<DropdownMenuItem>
|
||||||
onClick={() => {
|
<button
|
||||||
setSelectedSite(siteRow);
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setSelectedSite(siteRow);
|
||||||
}}
|
setIsDeleteModalOpen(true);
|
||||||
className="text-red-600 hover:text-red-800"
|
}}
|
||||||
>
|
className="text-red-600 hover:text-red-800"
|
||||||
Delete
|
>
|
||||||
</button>
|
Delete
|
||||||
</DropdownMenuItem>
|
</button>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant={"gray"}
|
||||||
|
className="ml-2"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(
|
||||||
|
`/${siteRow.orgId}/settings/sites/${siteRow.nice}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Edit <ArrowRight className="ml-2 w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -188,7 +201,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||||
setIsCreateModalOpen(true);
|
setIsCreateModalOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button onClick={callApi}>Create Newt</button>
|
{/* <button onClick={callApi}>Create Newt</button> */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,7 @@
|
||||||
--chart-4: 23.33 8.82% 60%;
|
--chart-4: 23.33 8.82% 60%;
|
||||||
--chart-5: 24 8.98% 67.25%;
|
--chart-5: 24 8.98% 67.25%;
|
||||||
|
|
||||||
--radius: 0.75rem;
|
--radius: 0.35rem;
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
--background: 0 0% 11.76%;
|
--background: 0 0% 11.76%;
|
||||||
|
@ -75,7 +75,7 @@
|
||||||
--chart-4: 23.33 23.68% 14.9%;
|
--chart-4: 23.33 23.68% 14.9%;
|
||||||
--chart-5: 24 23.81% 12.35%;
|
--chart-5: 24 23.81% 12.35%;
|
||||||
|
|
||||||
--radius: 0.75rem;
|
--radius: 0.35rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Inter } from "next/font/google";
|
import { Fira_Sans, Inter } from "next/font/google";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||||
|
|
||||||
|
|
16
src/components/SettingsSectionTitle.tsx
Normal file
16
src/components/SettingsSectionTitle.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
type SettingsSectionTitleProps = {
|
||||||
|
title: string | React.ReactNode;
|
||||||
|
description: string | React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsSectionTitle({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: SettingsSectionTitleProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5 select-none mb-6">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">{title}</h2>
|
||||||
|
<p className="text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -59,9 +59,9 @@ export function SidebarNav({
|
||||||
<div>
|
<div>
|
||||||
<div className="block lg:hidden px-4">
|
<div className="block lg:hidden px-4">
|
||||||
<Select
|
<Select
|
||||||
|
defaultValue={getSelectedValue()}
|
||||||
onValueChange={handleSelectChange}
|
onValueChange={handleSelectChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
defaultValue={getSelectedValue()}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select an option" />
|
<SelectValue placeholder="Select an option" />
|
||||||
|
|
|
@ -19,6 +19,7 @@ const buttonVariants = cva(
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
gray: "bg-accent text-accent-foreground hover:bg-accent/90",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|
|
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/30 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue