mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-26 18:52:41 +00:00
All page types are there and look mostly correct
This commit is contained in:
@@ -1971,10 +1971,17 @@
|
|||||||
"requireDeviceApproval": "Require Device Approvals",
|
"requireDeviceApproval": "Require Device Approvals",
|
||||||
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
||||||
"sshSettings": "SSH Settings",
|
"sshSettings": "SSH Settings",
|
||||||
|
"rdpSettings": "RDP Settings",
|
||||||
|
"vncSettings": "VNC Settings",
|
||||||
"sshServer": "SSH Server",
|
"sshServer": "SSH Server",
|
||||||
|
"rdpServer": "RDP Server",
|
||||||
|
"vncServer": "VNC Server",
|
||||||
"sshServerDescription": "Set up the authentication method, daemon location, and server destination",
|
"sshServerDescription": "Set up the authentication method, daemon location, and server destination",
|
||||||
|
"rdpServerDescription": "Configure the destination and port of the RDP server",
|
||||||
|
"vncServerDescription": "Configure the destination and port of the VNC server",
|
||||||
"sshServerMode": "Mode",
|
"sshServerMode": "Mode",
|
||||||
"sshServerModeStandard": "Standard SSH Server",
|
"sshServerModeStandard": "Standard SSH Server",
|
||||||
|
"sshServerModePangolin": "Pangolin SSH",
|
||||||
"sshServerModeStandardDescription": "Uses a Pangolin auth daemon to manage SSH authentication on the site or remote host.",
|
"sshServerModeStandardDescription": "Uses a Pangolin auth daemon to manage SSH authentication on the site or remote host.",
|
||||||
"sshServerModeNative": "Native SSH Server",
|
"sshServerModeNative": "Native SSH Server",
|
||||||
"sshServerModeNativeDescription": "SSH authentication is handled natively by an existing SSH server without a separate auth daemon.",
|
"sshServerModeNativeDescription": "SSH authentication is handled natively by an existing SSH server without a separate auth daemon.",
|
||||||
@@ -2987,7 +2994,7 @@
|
|||||||
"learnMore": "Learn more",
|
"learnMore": "Learn more",
|
||||||
"backToHome": "Go back to home",
|
"backToHome": "Go back to home",
|
||||||
"needToSignInToOrg": "Need to use your organization's identity provider?",
|
"needToSignInToOrg": "Need to use your organization's identity provider?",
|
||||||
"maintenanceMode": "Maintenance Mode",
|
"maintenanceMode": "Maintenance Page",
|
||||||
"maintenanceModeDescription": "Display a maintenance page to visitors",
|
"maintenanceModeDescription": "Display a maintenance page to visitors",
|
||||||
"maintenanceModeType": "Maintenance Mode Type",
|
"maintenanceModeType": "Maintenance Mode Type",
|
||||||
"showMaintenancePage": "Show a maintenance page to visitors",
|
"showMaintenancePage": "Show a maintenance page to visitors",
|
||||||
@@ -3017,6 +3024,7 @@
|
|||||||
"maintenanceScreenEstimatedCompletion": "Estimated Completion:",
|
"maintenanceScreenEstimatedCompletion": "Estimated Completion:",
|
||||||
"createInternalResourceDialogDestinationRequired": "Destination is required",
|
"createInternalResourceDialogDestinationRequired": "Destination is required",
|
||||||
"available": "Available",
|
"available": "Available",
|
||||||
|
"disabledResourceDescription": "When disabled, the resource will be inaccessible by everyone.",
|
||||||
"archived": "Archived",
|
"archived": "Archived",
|
||||||
"noArchivedDevices": "No archived devices found",
|
"noArchivedDevices": "No archived devices found",
|
||||||
"deviceArchived": "Device archived",
|
"deviceArchived": "Device archived",
|
||||||
|
|||||||
@@ -584,6 +584,7 @@ export default function GeneralForm() {
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="general-settings-form"
|
id="general-settings-form"
|
||||||
>
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -614,13 +615,13 @@ export default function GeneralForm() {
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"enterIdentifier"
|
"enterIdentifier"
|
||||||
)}
|
)}
|
||||||
className="flex-1"
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!resource.http && (
|
{!resource.http && (
|
||||||
<>
|
<>
|
||||||
@@ -730,8 +731,7 @@ export default function GeneralForm() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="enabled"
|
name="enabled"
|
||||||
render={() => (
|
render={() => (
|
||||||
<FormItem className="col-span-2">
|
<FormItem>
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SwitchInput
|
<SwitchInput
|
||||||
id="enable-resource"
|
id="enable-resource"
|
||||||
@@ -751,7 +751,11 @@ export default function GeneralForm() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</div>
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"disabledResourceDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -84,23 +84,13 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||||||
{
|
{
|
||||||
title: t("general"),
|
title: t("general"),
|
||||||
href: `/{orgId}/settings/resources/proxy/{niceId}/general`
|
href: `/{orgId}/settings/resources/proxy/{niceId}/general`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t(`${resource.browserAccessType}Settings`),
|
||||||
|
href: `/{orgId}/settings/resources/proxy/{niceId}/${resource.browserAccessType}`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (resource.browserAccessType === "http") {
|
|
||||||
navItems.push({
|
|
||||||
title: t("httpSettings"),
|
|
||||||
href: `/{orgId}/settings/resources/proxy/{niceId}/http`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resource.browserAccessType === "ssh") {
|
|
||||||
navItems.push({
|
|
||||||
title: t("sshSettings"),
|
|
||||||
href: `/{orgId}/settings/resources/proxy/{niceId}/ssh`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resource.http) {
|
if (resource.http) {
|
||||||
navItems.push({
|
navItems.push({
|
||||||
title: t("authentication"),
|
title: t("authentication"),
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ export default async function ResourcePage(props: {
|
|||||||
}) {
|
}) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
redirect(
|
redirect(
|
||||||
`/${params.orgId}/settings/resources/proxy/${params.niceId}/proxy`
|
`/${params.orgId}/settings/resources/proxy/${params.niceId}/general`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
250
src/app/[orgId]/settings/resources/proxy/[niceId]/rdp/page.tsx
Normal file
250
src/app/[orgId]/settings/resources/proxy/[niceId]/rdp/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||||
|
import { type Selectedsite } from "@app/components/site-selector";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { use, useActionState, useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { GetResourceResponse } from "@server/routers/resource";
|
||||||
|
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||||
|
|
||||||
|
type ExistingTarget = {
|
||||||
|
browserGatewayTargetId: number;
|
||||||
|
siteId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sshFormSchema = z.object({
|
||||||
|
authDaemonPort: z.string().refine(
|
||||||
|
(val) => {
|
||||||
|
if (!val) return true;
|
||||||
|
const n = Number(val);
|
||||||
|
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||||
|
},
|
||||||
|
{ message: "Port must be between 1 and 65535" }
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function SshSettingsPage(props: {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
}) {
|
||||||
|
const params = use(props.params);
|
||||||
|
const { resource, updateResource } = useResourceContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SshServerForm
|
||||||
|
orgId={params.orgId}
|
||||||
|
resource={resource}
|
||||||
|
updateResource={updateResource}
|
||||||
|
/>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SshServerForm({
|
||||||
|
orgId,
|
||||||
|
resource,
|
||||||
|
updateResource
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
resource: GetResourceResponse;
|
||||||
|
updateResource: ResourceContextType["updateResource"];
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Standard mode: multi-site
|
||||||
|
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||||
|
const [bgDestination, setBgDestination] = useState("");
|
||||||
|
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||||
|
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Native mode: single site
|
||||||
|
const [selectedNativeSite, setSelectedNativeSite] =
|
||||||
|
useState<Selectedsite | null>(null);
|
||||||
|
const [nativeExistingTarget, setNativeExistingTarget] =
|
||||||
|
useState<ExistingTarget | null>(null);
|
||||||
|
|
||||||
|
const { data: bgTargetsResponse } = useQuery({
|
||||||
|
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get(
|
||||||
|
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||||
|
);
|
||||||
|
return res.data.data as {
|
||||||
|
targets: Array<{
|
||||||
|
browserGatewayTargetId: number;
|
||||||
|
resourceId: number;
|
||||||
|
siteId: number;
|
||||||
|
siteName?: string;
|
||||||
|
type: string;
|
||||||
|
destination: string;
|
||||||
|
destinationPort: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bgTargetsResponse?.targets?.length) return;
|
||||||
|
const targets = bgTargetsResponse.targets;
|
||||||
|
const first = targets[0];
|
||||||
|
|
||||||
|
setBgDestination(first.destination);
|
||||||
|
setBgDestinationPort(String(first.destinationPort));
|
||||||
|
setExistingTargets(
|
||||||
|
targets.map((t) => ({
|
||||||
|
browserGatewayTargetId: t.browserGatewayTargetId,
|
||||||
|
siteId: t.siteId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
setSelectedSites(
|
||||||
|
targets.map((t) => ({
|
||||||
|
siteId: t.siteId,
|
||||||
|
name: t.siteName ?? String(t.siteId),
|
||||||
|
type: "newt" as const
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, [bgTargetsResponse]);
|
||||||
|
|
||||||
|
const [, formAction, isSubmitting] = useActionState(save, null);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
if (bgDestination && bgDestinationPort) {
|
||||||
|
const selectedSiteIds = new Set(
|
||||||
|
selectedSites.map((s) => s.siteId)
|
||||||
|
);
|
||||||
|
const existingSiteIds = new Set(
|
||||||
|
existingTargets.map((t) => t.siteId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toDelete = existingTargets.filter(
|
||||||
|
(t) => !selectedSiteIds.has(t.siteId)
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
toDelete.map((t) =>
|
||||||
|
api.delete(
|
||||||
|
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toUpdate = existingTargets.filter((t) =>
|
||||||
|
selectedSiteIds.has(t.siteId)
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
toUpdate.map((t) =>
|
||||||
|
api.post(
|
||||||
|
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
||||||
|
{
|
||||||
|
type: "rdp",
|
||||||
|
destination: bgDestination,
|
||||||
|
destinationPort: Number(bgDestinationPort),
|
||||||
|
siteId: t.siteId
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toCreate = selectedSites.filter(
|
||||||
|
(s) => !existingSiteIds.has(s.siteId)
|
||||||
|
);
|
||||||
|
const created = await Promise.all(
|
||||||
|
toCreate.map((s) =>
|
||||||
|
api.put(
|
||||||
|
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||||
|
{
|
||||||
|
siteId: s.siteId,
|
||||||
|
type: "rdp",
|
||||||
|
destination: bgDestination,
|
||||||
|
destinationPort: Number(bgDestinationPort)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||||
|
browserGatewayTargetId:
|
||||||
|
res.data.data.browserGatewayTargetId,
|
||||||
|
siteId: toCreate[i].siteId
|
||||||
|
}));
|
||||||
|
setExistingTargets([...toUpdate, ...newTargets]);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("settingsUpdated"),
|
||||||
|
description: t("settingsUpdatedDescription")
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("settingsErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
t("settingsErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>{t("rdpServer")}</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("rdpServerDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<BrowserGatewayTargetForm
|
||||||
|
orgId={orgId}
|
||||||
|
multiSite={true}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
onSitesChange={setSelectedSites}
|
||||||
|
destination={bgDestination}
|
||||||
|
destinationPort={bgDestinationPort}
|
||||||
|
onDestinationChange={setBgDestination}
|
||||||
|
onDestinationPortChange={setBgDestinationPort}
|
||||||
|
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
|
||||||
|
defaultPort={3389}
|
||||||
|
/>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
<form action={formAction} className="flex justify-end mt-4">
|
||||||
|
<Button
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
PopoverTrigger
|
PopoverTrigger
|
||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
@@ -122,6 +123,7 @@ function SshServerForm({
|
|||||||
|
|
||||||
// Standard mode: multi-site
|
// Standard mode: multi-site
|
||||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||||
|
const [selectedSite, setSelectedSite] = useState<Selectedsite | null>(null);
|
||||||
const [bgDestination, setBgDestination] = useState("");
|
const [bgDestination, setBgDestination] = useState("");
|
||||||
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||||
@@ -365,11 +367,16 @@ function SshServerForm({
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm>
|
<SettingsSectionForm variant="half">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm font-semibold">
|
<p className="text-sm font-semibold">
|
||||||
{t("sshServerMode")}
|
{t("sshServerMode")}
|
||||||
</p>
|
</p>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{sshServerMode == "standard"
|
||||||
|
? t("sshServerModeStandard")
|
||||||
|
: t("sshServerModePangolin")}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -434,7 +441,6 @@ function SshServerForm({
|
|||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</SettingsSectionForm>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -474,7 +480,7 @@ function SshServerForm({
|
|||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
) : (
|
) : standardDaemonLocation !== "site" ? (
|
||||||
<BrowserGatewayTargetForm
|
<BrowserGatewayTargetForm
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
multiSite={true}
|
multiSite={true}
|
||||||
@@ -485,9 +491,24 @@ function SshServerForm({
|
|||||||
onDestinationChange={setBgDestination}
|
onDestinationChange={setBgDestination}
|
||||||
onDestinationPortChange={setBgDestinationPort}
|
onDestinationPortChange={setBgDestinationPort}
|
||||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||||
|
defaultPort={22}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BrowserGatewayTargetForm
|
||||||
|
orgId={orgId}
|
||||||
|
multiSite={false}
|
||||||
|
selectedSite={selectedSite}
|
||||||
|
onSiteChange={setSelectedSite}
|
||||||
|
destination={bgDestination}
|
||||||
|
destinationPort={bgDestinationPort}
|
||||||
|
onDestinationChange={setBgDestination}
|
||||||
|
onDestinationPortChange={setBgDestinationPort}
|
||||||
|
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||||
|
defaultPort={22}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</SettingsSectionForm>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
<form action={formAction} className="flex justify-end mt-4">
|
<form action={formAction} className="flex justify-end mt-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
248
src/app/[orgId]/settings/resources/proxy/[niceId]/vnc/page.tsx
Normal file
248
src/app/[orgId]/settings/resources/proxy/[niceId]/vnc/page.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||||
|
import { type Selectedsite } from "@app/components/site-selector";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { use, useActionState, useEffect, useState } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { GetResourceResponse } from "@server/routers/resource";
|
||||||
|
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||||
|
|
||||||
|
type ExistingTarget = {
|
||||||
|
browserGatewayTargetId: number;
|
||||||
|
siteId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sshFormSchema = z.object({
|
||||||
|
authDaemonPort: z.string().refine(
|
||||||
|
(val) => {
|
||||||
|
if (!val) return true;
|
||||||
|
const n = Number(val);
|
||||||
|
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||||
|
},
|
||||||
|
{ message: "Port must be between 1 and 65535" }
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function SshSettingsPage(props: {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
}) {
|
||||||
|
const params = use(props.params);
|
||||||
|
const { resource, updateResource } = useResourceContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SshServerForm
|
||||||
|
orgId={params.orgId}
|
||||||
|
resource={resource}
|
||||||
|
updateResource={updateResource}
|
||||||
|
/>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SshServerForm({
|
||||||
|
orgId,
|
||||||
|
resource,
|
||||||
|
updateResource
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
resource: GetResourceResponse;
|
||||||
|
updateResource: ResourceContextType["updateResource"];
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Standard mode: multi-site
|
||||||
|
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||||
|
const [bgDestination, setBgDestination] = useState("");
|
||||||
|
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||||
|
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Native mode: single site
|
||||||
|
const [selectedNativeSite, setSelectedNativeSite] =
|
||||||
|
useState<Selectedsite | null>(null);
|
||||||
|
const [nativeExistingTarget, setNativeExistingTarget] =
|
||||||
|
useState<ExistingTarget | null>(null);
|
||||||
|
|
||||||
|
const { data: bgTargetsResponse } = useQuery({
|
||||||
|
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get(
|
||||||
|
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||||
|
);
|
||||||
|
return res.data.data as {
|
||||||
|
targets: Array<{
|
||||||
|
browserGatewayTargetId: number;
|
||||||
|
resourceId: number;
|
||||||
|
siteId: number;
|
||||||
|
siteName?: string;
|
||||||
|
type: string;
|
||||||
|
destination: string;
|
||||||
|
destinationPort: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bgTargetsResponse?.targets?.length) return;
|
||||||
|
const targets = bgTargetsResponse.targets;
|
||||||
|
const first = targets[0];
|
||||||
|
|
||||||
|
setBgDestination(first.destination);
|
||||||
|
setBgDestinationPort(String(first.destinationPort));
|
||||||
|
setExistingTargets(
|
||||||
|
targets.map((t) => ({
|
||||||
|
browserGatewayTargetId: t.browserGatewayTargetId,
|
||||||
|
siteId: t.siteId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
setSelectedSites(
|
||||||
|
targets.map((t) => ({
|
||||||
|
siteId: t.siteId,
|
||||||
|
name: t.siteName ?? String(t.siteId),
|
||||||
|
type: "newt" as const
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, [bgTargetsResponse]);
|
||||||
|
|
||||||
|
const [, formAction, isSubmitting] = useActionState(save, null);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
if (bgDestination && bgDestinationPort) {
|
||||||
|
const selectedSiteIds = new Set(
|
||||||
|
selectedSites.map((s) => s.siteId)
|
||||||
|
);
|
||||||
|
const existingSiteIds = new Set(
|
||||||
|
existingTargets.map((t) => t.siteId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toDelete = existingTargets.filter(
|
||||||
|
(t) => !selectedSiteIds.has(t.siteId)
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
toDelete.map((t) =>
|
||||||
|
api.delete(
|
||||||
|
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toUpdate = existingTargets.filter((t) =>
|
||||||
|
selectedSiteIds.has(t.siteId)
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
toUpdate.map((t) =>
|
||||||
|
api.post(
|
||||||
|
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
||||||
|
{
|
||||||
|
type: "vnc",
|
||||||
|
destination: bgDestination,
|
||||||
|
destinationPort: Number(bgDestinationPort),
|
||||||
|
siteId: t.siteId
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toCreate = selectedSites.filter(
|
||||||
|
(s) => !existingSiteIds.has(s.siteId)
|
||||||
|
);
|
||||||
|
const created = await Promise.all(
|
||||||
|
toCreate.map((s) =>
|
||||||
|
api.put(
|
||||||
|
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||||
|
{
|
||||||
|
siteId: s.siteId,
|
||||||
|
type: "vnc",
|
||||||
|
destination: bgDestination,
|
||||||
|
destinationPort: Number(bgDestinationPort)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||||
|
browserGatewayTargetId:
|
||||||
|
res.data.data.browserGatewayTargetId,
|
||||||
|
siteId: toCreate[i].siteId
|
||||||
|
}));
|
||||||
|
setExistingTargets([...toUpdate, ...newTargets]);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("settingsUpdated"),
|
||||||
|
description: t("settingsUpdatedDescription")
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("settingsErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
t("settingsErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>{t("vncServer")}</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("vncServerDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<BrowserGatewayTargetForm
|
||||||
|
orgId={orgId}
|
||||||
|
multiSite={true}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
onSitesChange={setSelectedSites}
|
||||||
|
destination={bgDestination}
|
||||||
|
destinationPort={bgDestinationPort}
|
||||||
|
onDestinationChange={setBgDestination}
|
||||||
|
onDestinationPortChange={setBgDestinationPort}
|
||||||
|
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
|
||||||
|
defaultPort={5900}
|
||||||
|
/>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
<form action={formAction} className="flex justify-end mt-4">
|
||||||
|
<Button
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -732,7 +732,6 @@ export default function Page() {
|
|||||||
{getStatusText(status)}
|
{getStatusText(status)}
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
) : (
|
) : (
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
)}
|
)}
|
||||||
@@ -1427,10 +1426,12 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
{build === "saas" &&
|
{build === "saas" &&
|
||||||
targets.length > 1 &&
|
targets.length > 1 &&
|
||||||
new Set(targets.map((t) => t.siteId)).size >
|
new Set(targets.map((t) => t.siteId))
|
||||||
1 && (
|
.size > 1 && (
|
||||||
<p className="text-sm text-muted-foreground mt-3">
|
<p className="text-sm text-muted-foreground mt-3">
|
||||||
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
|
{t(
|
||||||
|
"proxyMultiSiteRoundRobinNodeHelp"
|
||||||
|
)}{" "}
|
||||||
<a
|
<a
|
||||||
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
|
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -1627,7 +1628,7 @@ export default function Page() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
router.push(
|
router.push(
|
||||||
`/${orgId}/settings/resources/proxy/${niceId}/proxy`
|
`/${orgId}/settings/resources/proxy/${niceId}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type MultiSiteProps = {
|
|||||||
export type BrowserGatewayTargetFormProps = {
|
export type BrowserGatewayTargetFormProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
|
defaultPort: number;
|
||||||
destinationPort: string;
|
destinationPort: string;
|
||||||
onDestinationChange: (v: string) => void;
|
onDestinationChange: (v: string) => void;
|
||||||
onDestinationPortChange: (v: string) => void;
|
onDestinationPortChange: (v: string) => void;
|
||||||
@@ -115,7 +116,7 @@ export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
|
|||||||
<label className="text-sm font-semibold">{t("port")}</label>
|
<label className="text-sm font-semibold">{t("port")}</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="22"
|
placeholder={props.defaultPort.toString()}
|
||||||
value={props.destinationPort}
|
value={props.destinationPort}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
props.onDestinationPortChange(e.target.value)
|
props.onDestinationPortChange(e.target.value)
|
||||||
@@ -123,7 +124,7 @@ export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{props.multiSite && (
|
{props.multiSite === true && props.selectedSites.length > 1 && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("bgTargetMultiSiteDisclaimer")}{" "}
|
{t("bgTargetMultiSiteDisclaimer")}{" "}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|||||||
import {
|
import {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
Eye,
|
|
||||||
EyeOff,
|
EyeOff,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle
|
||||||
Clock
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
@@ -32,19 +30,40 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
|
|
||||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
||||||
|
|
||||||
|
const showCertificate = !!(
|
||||||
|
resource.http &&
|
||||||
|
resource.domainId &&
|
||||||
|
resource.fullDomain &&
|
||||||
|
build != "oss"
|
||||||
|
);
|
||||||
|
const showType = !!(resource.http && resource.browserAccessType);
|
||||||
|
const showHealth =
|
||||||
|
!["ssh", "rdp", "vnc"].includes(resource.browserAccessType || "") &&
|
||||||
|
!!resource.health &&
|
||||||
|
resource.health !== "unknown";
|
||||||
|
const showVisibility = !resource.enabled;
|
||||||
|
|
||||||
|
const numSections = [
|
||||||
|
true, // URL or Protocol
|
||||||
|
true, // Authentication or Port
|
||||||
|
showType,
|
||||||
|
showCertificate,
|
||||||
|
showHealth,
|
||||||
|
showVisibility
|
||||||
|
].filter(Boolean).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{/* 4 cols because of the certs */}
|
<InfoSections cols={numSections}>
|
||||||
<InfoSections cols={resource.http && build != "oss" ? 6 : 5}>
|
{/* <InfoSection>
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
<span className="inline-flex items-center">
|
<span className="inline-flex items-center">
|
||||||
{resource.niceId}
|
{resource.niceId}
|
||||||
</span>
|
</span>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection> */}
|
||||||
{resource.http ? (
|
{resource.http ? (
|
||||||
<>
|
<>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
@@ -62,6 +81,18 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
{showType && (
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("type")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<span className="inline-flex items-center">
|
||||||
|
{resource.browserAccessType!.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
)}
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
{t("authentication")}
|
{t("authentication")}
|
||||||
@@ -84,24 +115,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
{/* {isEnabled && (
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>Socket</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
{isAvailable ? (
|
|
||||||
<span className="flex items-center space-x-2">
|
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
||||||
<span>Online</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center space-x-2">
|
|
||||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
|
||||||
<span>Offline</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
)} */}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -149,10 +162,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
{/* </InfoSectionContent> */}
|
{/* </InfoSectionContent> */}
|
||||||
{/* </InfoSection> */}
|
{/* </InfoSection> */}
|
||||||
{/* Certificate Status Column */}
|
{/* Certificate Status Column */}
|
||||||
{resource.http &&
|
{showCertificate && (
|
||||||
resource.domainId &&
|
|
||||||
resource.fullDomain &&
|
|
||||||
build != "oss" && (
|
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
{t("certificateStatus", {
|
{t("certificateStatus", {
|
||||||
@@ -162,8 +172,8 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
<CertificateStatus
|
<CertificateStatus
|
||||||
orgId={resource.orgId}
|
orgId={resource.orgId}
|
||||||
domainId={resource.domainId}
|
domainId={resource.domainId!}
|
||||||
fullDomain={resource.fullDomain}
|
fullDomain={resource.fullDomain!}
|
||||||
autoFetch={true}
|
autoFetch={true}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
polling={true}
|
polling={true}
|
||||||
@@ -171,52 +181,50 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
)}
|
)}
|
||||||
|
{showHealth && (
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{resource.health === "healthy" && (
|
{resource.health === "healthy" && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" />
|
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||||
<span>{t("resourcesTableHealthy")}</span>
|
<span>
|
||||||
|
{t("resourcesTableHealthy")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{resource.health === "degraded" && (
|
{resource.health === "degraded" && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" />
|
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" />
|
||||||
<span>{t("resourcesTableDegraded")}</span>
|
<span>
|
||||||
|
{t("resourcesTableDegraded")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{resource.health === "unhealthy" && (
|
{resource.health === "unhealthy" && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<XCircle className="w-4 h-4 flex-shrink-0 text-destructive" />
|
<XCircle className="w-4 h-4 flex-shrink-0 text-destructive" />
|
||||||
<span>{t("resourcesTableUnhealthy")}</span>
|
<span>
|
||||||
</div>
|
{t("resourcesTableUnhealthy")}
|
||||||
)}
|
</span>
|
||||||
{(!resource.health ||
|
|
||||||
resource.health === "unknown") && (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Clock className="w-4 h-4 flex-shrink-0" />
|
|
||||||
<span>{t("resourcesTableUnknown")}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
)}
|
||||||
|
{showVisibility && (
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
|
{t("visibility")}
|
||||||
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{resource.enabled ? (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Eye className="w-4 h-4 flex-shrink-0 text-green-500" />
|
|
||||||
<span>{t("enabled")}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<EyeOff className="w-4 h-4 flex-shrink-0 text-neutral-500" />
|
<EyeOff className="w-4 h-4 flex-shrink-0 text-neutral-500" />
|
||||||
<span>{t("disabled")}</span>
|
<span>{t("disabled")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
)}
|
||||||
</InfoSections>
|
</InfoSections>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -22,13 +22,24 @@ export function SettingsSectionHeader({
|
|||||||
|
|
||||||
export function SettingsSectionForm({
|
export function SettingsSectionForm({
|
||||||
children,
|
children,
|
||||||
className
|
className,
|
||||||
|
variant = "compact"
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
variant?: "half" | "compact";
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("max-w-xl space-y-4", className)}>{children}</div>
|
<div
|
||||||
|
className={cn(
|
||||||
|
variant === "half"
|
||||||
|
? "max-w-3xl space-y-4"
|
||||||
|
: "max-w-xl space-y-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user